Repository navigation
Description
Summary
A single message with a created_at in the future silently marks every subsequent
message in that channel as already read, until wall-clock time catches up with the bad
timestamp. The reader gets no unread badge, no unread divider, and — since #5983 — no
thread resume, because the channel looks fully caught up.
Nostr created_at is self-asserted by the sending client. The relay bounds it for
moderation commands but not for ordinary messages, so any client with a skewed clock (or
one that simply sets the field) can poison a channel's read state for everyone who opens it.
Mechanism
Read state is written from the newest message's own timestamp, not from the current time:
mobile/lib/features/channels/channel_detail_page.dart:100-123
int? _channelReadTimestamp({...}) {
...
var latest = 0;
for (final event in events) {
if (event.threadReference.parentId != null) continue;
if (event.createdAt > latest) {
latest = event.createdAt; // <-- absorbs a future createdAt verbatim
}
}
...
}which is then committed as the channel marker (channel_detail_page.dart:295):
.markContextRead(channel.id, readTimestamp);Unreadness is a strict comparison against that marker
(mobile/lib/shared/read_state/message_read_state.dart:43):
return readAt == null || createdAt > readAt;So once the marker holds a future value, a genuinely new message with a correct
createdAt fails createdAt > readAt and is classified read. Desktop compares the
same way (desktop/src/features/messages/lib/unreadMarker.ts:131 and :64), so the read
side is identical; I did not trace desktop's mark-read write path, so whether it derives
the marker the same way needs confirming.
Markers are monotonic, so this does not self-correct and the reader cannot clear it from
the UI — marking the channel read again just re-commits the same future value. The channel
stays silent until real time passes the bad timestamp.
Why this is reachable
created_at is not validated against the clock on the ordinary message path. The codebase
already treats skew as a real threat, but only for moderation:
crates/buzz-relay/src/handlers/moderation_commands.rs:81
const MAX_COMMAND_SKEW_SECS: i64 = 120;event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)
That bound is applied to moderation commands and nothing else. The precedent exists; it
just is not applied where read state depends on it.
Impact
- Unread badges stop appearing for the affected channel.
- The unread divider stops rendering.
- Thread resume (feat(mobile,desktop): open a thread at the last-read reply #5983) tail-pins, because every reply looks read.
- Affects any reader who opens the channel, not only the client with the bad clock.
- Not recoverable through the UI.
Not introduced by #5983
This predates that PR and is orthogonal to it. #5983 only makes the symptom more visible,
because it added a third behaviour that keys off the same comparison. Filing separately as
discussed there rather than expanding that PR's scope.
Possible fixes
- Bound
created_aton ingest, reusing the existing skew concept — reject or clamp
events whose timestamp is more than some tolerance ahead of relay time. Most direct, and
consistent withMAX_COMMAND_SKEW_SECS. Rejecting is a protocol-visible behaviour change,
so clamping-on-store may be preferable. - Clamp when deriving the read marker — take
min(max(createdAt), now)in
_channelReadTimestampand desktop's equivalent, so a bad event cannot push the frontier
past the present. Smallest client-side change; leaves the bad event itself unread-forever
rather than suppressing everything after it. - Store a received-at alongside
created_atand compute unreadness from that.
Most correct, largest change, and it is what removes the client's ability to influence
another reader's read state at all.
I would lean toward 1 plus 2 — the relay bound stops propagation, and the client clamp
protects readers against events already stored.
Reproduction
- Publish a message to a channel with
created_atset well ahead of now (e.g.now + 86400). - Open that channel so the read marker is committed.
- Have another user post a normal message.
- The new message shows as read: no badge, no divider, and opening its thread pins to the tail.
What I verified
- The mobile derivation and comparison above, by reading the code paths cited.
- That
MAX_COMMAND_SKEW_SECSappears only inmoderation_commands.rs
(grep -rn MAX_COMMAND_SKEW_SECS crates/). - That desktop's unread comparison has the same
createdAt > readAtshape.
What I did not verify
- Desktop's mark-read write path, i.e. whether it derives the channel marker from
max(createdAt)the way mobile does. - Whether any relay-side path other than moderation commands bounds
created_at. - Actual end-to-end reproduction against a live relay — this is from code reading.
🤖 Generated with Claude Code
Activity
mfethe1 commentedon Aug 18, 2026
Correction: the relay already bounds
created_atfor every eventI got a load-bearing fact wrong when I filed this, and since #6063 is being built against it,
the correction matters.
What I claimed: that the relay bounds
created_atonly for moderation commands(
MAX_COMMAND_SKEW_SECS), leaving ordinary messages unbounded — and therefore that apoisoned read marker is permanent and unrecoverable.
What is actually true:
crates/buzz-relay/src/handlers/ingest.rs:2005-2012applies asymmetric ±15 minute bound to every ingested event:
It sits after signature verification and before any kind-specific branch, and both transports
reach it (WS
handlers/event.rs, HTTPapi/bridge.rs). So option (1) in the original report —"bound
created_aton ingest" — is already shipped.How I got it wrong: I grepped for
MAX_COMMAND_SKEW_SECS, the constant I had alreadyfound, rather than for timestamp validation generally, and concluded from its absence elsewhere
that no other bound existed. I did list "whether any relay-side path other than moderation
commands bounds
created_at" under what I did not verify, but I still led with the claim asthough it were established, including in the title.
What this changes
The worst poison this relay will admit is
now + 900. A marker pushed there stops suppressingmessages once wall-clock passes it, so the failure self-heals within 15 minutes rather than
persisting indefinitely. The severity in the original report is overstated by a wide margin.
What still stands
The client-side defect is real and worth fixing:
_channelReadTimestampstill commits unclampedmax(createdAt)as the channel marker(
mobile/lib/features/channels/channel_detail_page.dart:110-119), and unreadness is stillcreatedAt > readAt(mobile/lib/shared/read_state/message_read_state.dart:43). A futuretimestamp inside the ±900s window suppresses genuinely new messages for up to 15 minutes,
which is long enough to miss something.
writing read state directly rather than deriving it from an admitted event, is not covered by
a relay bound at all.
path is unconstrained.
So the mitigation belongs on the client regardless — but it is defending a 15-minute window
under normal operation, not an unbounded one, and I would size the fix accordingly.
Sorry for the noise. Flagging it here rather than quietly editing the issue body so anyone who
already read it sees the correction.