Skip to content

Future-dated created_at suppresses unread state for every later message in a channel #6046

Open

Description

@mfethe1

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

  1. Bound created_at on 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 with MAX_COMMAND_SKEW_SECS. Rejecting is a protocol-visible behaviour change,
    so clamping-on-store may be preferable.
  2. Clamp when deriving the read marker — take min(max(createdAt), now) in
    _channelReadTimestamp and 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.
  3. Store a received-at alongside created_at and 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

  1. Publish a message to a channel with created_at set well ahead of now (e.g. now + 86400).
  2. Open that channel so the read marker is committed.
  3. Have another user post a normal message.
  4. 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_SECS appears only in moderation_commands.rs
    (grep -rn MAX_COMMAND_SKEW_SECS crates/).
  • That desktop's unread comparison has the same createdAt > readAt shape.

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

  1. mfethe1 commented on Aug 18, 2026

    @mfethe1
    Author

    Correction: the relay already bounds created_at for every event

    I 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_at only for moderation commands
    (MAX_COMMAND_SKEW_SECS), leaving ordinary messages unbounded — and therefore that a
    poisoned read marker is permanent and unrecoverable.

    What is actually true: crates/buzz-relay/src/handlers/ingest.rs:2005-2012 applies a
    symmetric ±15 minute bound to every ingested event:

    const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes
    let now = chrono::Utc::now().timestamp();
    let event_ts = event.created_at.as_secs() as i64;
    if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS {
        return Err(IngestError::Rejected(
            "invalid: event timestamp too far from server time".into(),
        ));
    }

    It sits after signature verification and before any kind-specific branch, and both transports
    reach it (WS handlers/event.rs, HTTP api/bridge.rs). So option (1) in the original report —
    "bound created_at on ingest" — is already shipped.

    How I got it wrong: I grepped for MAX_COMMAND_SKEW_SECS, the constant I had already
    found, 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 as
    though 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 suppressing
    messages 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:

    • _channelReadTimestamp still commits unclamped max(createdAt) as the channel marker
      (mobile/lib/features/channels/channel_detail_page.dart:110-119), and unreadness is still
      createdAt > readAt (mobile/lib/shared/read_state/message_read_state.dart:43). A future
      timestamp inside the ±900s window suppresses genuinely new messages for up to 15 minutes,
      which is long enough to miss something.
    • Markers persist and sync. A marker written before the ingest gate existed, or by a client
      writing read state directly rather than deriving it from an admitted event, is not covered by
      a relay bound at all.
    • The bound protects only events admitted by this relay. Anything replicated in by another
      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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions