signal: fix rate-limit keying, peer/type validation, admit ordering
Fix round 1 on Task 5 review. - Important 1: the signal rate limiter is now keyed on session+room (session+"|"+sqid), never on the body's claimed peer id, and the check runs before the body is read at all, so an oversized flood still spends a token. rateLimiter gains a bounded cap with oldest-first eviction (newRateLimiter now takes rate, burst, cap). - Important 2: peer must be exactly 32 lowercase hex or the request is 400, checked before it is ever used as a log argument or a hub lookup key; an invalid peer is never logged. The logged "type" is now one of the wire's own constants or the literal "unknown", never arbitrary attacker text. - Important 3: hub.Admit now settles the actor's own authority (found, standing applied, admitted, member) completely before it ever looks at the target, so a participation with no admitting authority can no longer distinguish an absent target (formerly 409) from a present one (formerly 404) by the shape of the refusal, and the target's StandingFor is never spent on an actor with no business asking. - The captured-log test now uses a real guest (a distinctive address whose derived name is its own needle), an admit round, and a forced internal-error arm (dropping the meetings table, not closing the whole database — closing it also breaks rastrillo's own per-request session lookup before the request ever reaches the signal handler), and positively asserts the stale-gen/type/internal outcome lines are present as well as absent of content. - Minors: 404 bodies now carry boot, identical for ErrNotYours and ErrUnknownPeer; TestSignalStaleGenAnswers409WithTheCurrentGen renamed to TestSignalStaleGenIsRefusedWith409; client.signalRaw added for the body shapes client.signal cannot express, driving a new table test (chat/share/bogus/malformed-JSON/missing-peer/ malformed-admit-payload/well-formed-absent-admit-target); comments added on the Retry-After rounding and the unreachable ErrNotTargeted arm; unit tests added for ratelimit.go itself (burst, refill, retryAfter bounds, idle eviction, the cap). Every new or changed test was run against the pre-fix code (reverted signal.go/ratelimit.go/app.go for Important 1/2, admission.go for Important 3) and confirmed to fail there before being run against the fix; see task-5-report.md for specifics, including one test (TestSignalRateLimitIsPerSessionNotPerClaimedPeer) that had to be rewritten mid-round because its first version passed vacuously against the old code too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
8 files changed,
+559
−72
internal/meet/app.go+1 −1internal/meet/hub/admission.go+46 −21internal/meet/hub/admission_test.go+39 −0internal/meet/ratelimit.go+47 −12internal/meet/ratelimit_test.go+109 −0internal/meet/signal.go+77 −21internal/meettest/signal_test.go+212 −17internal/meettest/sse_test.go+28 −0
CI passed — run details
diff --git a/internal/meet/app.go b/internal/meet/app.go| index 75bc5ea..70f3f40 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -270,7 +270,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| a.hub = hub.New(hub.Config{ |
| Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, |
| }) |
| - a.signalLimiter = newRateLimiter(30, 60) |
| + a.signalLimiter = newRateLimiter(30, 60, maxSignalBuckets) |
| writer, err := d.G.DB() |
| if err != nil { |
diff --git a/internal/meet/hub/admission.go b/internal/meet/hub/admission.go| index ee870fa..d2192cc 100644 |
| --- a/internal/meet/hub/admission.go |
| +++ b/internal/meet/hub/admission.go |
| @@ -209,53 +209,78 @@ func (h *Hub) standing(ctx context.Context, sqid, session string, peer PeerID) ( |
| // Admit: the actor must be admitted with current member kind; the target |
| // waiting with entry authority now; the cap under the lock (spec §4.3). |
| +// |
| +// The actor's own authority is settled completely — found, current |
| +// standing applied, admitted, member — before the target is looked at |
| +// at all. The other order (found first in an earlier revision) let any |
| +// joined participation, admitted or not, learn from the shape of the |
| +// refusal alone whether a candidate peer id existed in the room, and |
| +// spent a StandingFor call on the target even when the actor had no |
| +// business asking (review Important 3). |
| func (h *Hub) Admit(ctx context.Context, sqid, session string, actor, target PeerID) error { |
| actorKind, actorOK, err := h.standing(ctx, sqid, session, actor) |
| if err != nil { |
| return err |
| } |
| + |
| h.mu.Lock() |
| - var targetPerson int64 |
| - found := false |
| - if r, ok := h.rooms[sqid]; ok { |
| - var tp *Participation |
| - if tp, found = r.peers[target]; found { |
| - targetPerson = tp.PersonID |
| + a, ok := h.findLocked(sqid, session, actor) |
| + if !ok { |
| + h.mu.Unlock() |
| + return ErrUnknownPeer |
| + } |
| + r := h.rooms[sqid] |
| + if !actorOK { |
| + lost := h.standingLostLocked(r, a) |
| + h.mu.Unlock() |
| + if lost { |
| + return ErrNotYours |
| } |
| + // An admitted guest whose grant lapsed keeps the room but never |
| + // gains the authority to let anyone else in. |
| + return ErrNotMember |
| } |
| - h.mu.Unlock() |
| - if !found { |
| + h.setKindLocked(r, a, actorKind) |
| + if !a.Admitted { |
| + h.mu.Unlock() |
| + return ErrNotAdmitted |
| + } |
| + if a.Kind != KindMember { |
| + h.mu.Unlock() |
| + return ErrNotMember |
| + } |
| + // Only now, with the actor's authority settled, is the target looked |
| + // at — first for its bare existence in this room, still under this |
| + // same lock, before it costs a StandingFor call at all. |
| + tp, found := r.peers[target] |
| + if !found || !tp.Joined || tp.Admitted { |
| + h.mu.Unlock() |
| return ErrNotWaiting |
| } |
| + targetPerson := tp.PersonID |
| + h.mu.Unlock() |
| + |
| targetKind, targetOK, err := h.cfg.StandingFor(ctx, sqid, targetPerson) |
| if err != nil { |
| return err |
| } |
| + |
| h.mu.Lock() |
| defer h.mu.Unlock() |
| // Both participations are re-found: either may have ended while the |
| - // two standing reads were in flight. |
| - a, ok := h.findLocked(sqid, session, actor) |
| + // target's standing read was in flight. |
| + a, ok = h.findLocked(sqid, session, actor) |
| if !ok { |
| return ErrUnknownPeer |
| } |
| - r := h.rooms[sqid] |
| - if !actorOK { |
| - if h.standingLostLocked(r, a) { |
| - return ErrNotYours |
| - } |
| - // An admitted guest whose grant lapsed keeps the room but never |
| - // gains the authority to let anyone else in. |
| - return ErrNotMember |
| - } |
| - h.setKindLocked(r, a, actorKind) |
| + r = h.rooms[sqid] |
| if !a.Admitted { |
| return ErrNotAdmitted |
| } |
| if a.Kind != KindMember { |
| return ErrNotMember |
| } |
| - tp, ok := r.peers[target] |
| + tp, ok = r.peers[target] |
| if !ok || !tp.Joined || tp.Admitted { |
| return ErrNotWaiting |
| } |
diff --git a/internal/meet/hub/admission_test.go b/internal/meet/hub/admission_test.go| index 1cc0b11..7c769af 100644 |
| --- a/internal/meet/hub/admission_test.go |
| +++ b/internal/meet/hub/admission_test.go |
| @@ -96,6 +96,45 @@ func TestOnlyAnAdmittedMemberMayAdmit(t *testing.T) { |
| } |
| } |
| +// Task 5 review Important 3: Admit must settle the actor's own |
| +// authority completely before it ever looks at the target, so a |
| +// participation with no admitting authority cannot use the shape of the |
| +// refusal to learn whether a candidate peer id exists in the room, and |
| +// the target's StandingFor is never spent on an actor that had no |
| +// business asking. |
| +func TestAdmitByAWaitingGuestNeverLooksAtTheTargetOrItsStanding(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + var calls int |
| + h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| + calls++ |
| + if id < 100 { |
| + return KindMember, true, nil |
| + } |
| + return KindGuest, true, nil |
| + }}) |
| + ctx := context.Background() |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) // waiting, never admitted |
| + |
| + calls = 0 |
| + errAbsent := h.Admit(ctx, "r1", "sg", g.Peer, "0123456789abcdef0123456789abcdef") |
| + callsForAbsent := calls |
| + |
| + m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + h.Join(ctx, "r1", "sm", m.Peer) // present, and admitted immediately as a member |
| + |
| + calls = 0 |
| + errPresent := h.Admit(ctx, "r1", "sg", g.Peer, m.Peer) |
| + callsForPresent := calls |
| + |
| + if errAbsent != ErrNotAdmitted || errPresent != ErrNotAdmitted { |
| + t.Fatalf("waiting guest's admit: absent target %v, present target %v, want ErrNotAdmitted both times", errAbsent, errPresent) |
| + } |
| + if callsForAbsent != 1 || callsForPresent != 1 { |
| + t.Fatalf("StandingFor calls: absent target %d, present target %d, want 1 both times (the actor's own — the target is never reached)", callsForAbsent, callsForPresent) |
| + } |
| +} |
| + |
| func TestCapAtJoinAndAtAdmit(t *testing.T) { |
| now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| h := testHub(&now) |
diff --git a/internal/meet/ratelimit.go b/internal/meet/ratelimit.go| index b6113b4..58a5ffa 100644 |
| --- a/internal/meet/ratelimit.go |
| +++ b/internal/meet/ratelimit.go |
| @@ -5,19 +5,34 @@ import ( |
| "time" |
| ) |
| +// maxSignalBuckets bounds the signalling limiter's map (review Important |
| +// 1): a generous multiple of what any real deployment holds at once |
| +// (sixteen participations to a room), so legitimate traffic never |
| +// evicts anything, while an attacker opening session after session |
| +// cannot grow the map without bound. |
| +const maxSignalBuckets = 4096 |
| + |
| // rateLimiter is a per-key token bucket (spec §4.4): rate tokens are |
| // added to a key's bucket per second, up to burst held, and one message |
| -// spends one token. It is keyed by peer id for the signalling endpoint, |
| -// so one noisy peer never spends another's budget. |
| +// spends one token. POST /m/{sqid}/signal keys it on session and room |
| +// (session+"|"+sqid), not on anything the request body claims: the |
| +// session half comes from the session cookie the request actually |
| +// presented, so nobody can spend another session's budget or dodge |
| +// their own by rotating a claimed peer id (review Important 1). |
| // |
| -// Entries are process-local, like the hub itself, and are evicted once |
| -// idle for a minute so a room that closed does not hold its peers' |
| -// buckets forever. The sweep runs inline, every thousandth call, rather |
| -// than on a timer: nothing here needs a goroutine, and the call rate |
| -// this guards is exactly what would drive the sweep anyway. |
| +// Entries are process-local, like the hub itself, and bounded by cap: |
| +// once the map holds cap keys, the least recently touched bucket is |
| +// evicted to make room, so an attacker opening unbounded sessions |
| +// cannot grow the map without bound. A bucket is also dropped once idle |
| +// for a minute; either way it is simply recreated at burst on its next |
| +// visit, which is correct — a key that has been quiet owes nothing. The |
| +// idle sweep runs inline, every thousandth call, rather than on a |
| +// timer: nothing here needs a goroutine, and the call rate this guards |
| +// is exactly what would drive the sweep anyway. |
| type rateLimiter struct { |
| rate float64 // tokens added per second |
| burst float64 // bucket capacity |
| + cap int // 0 means unbounded — used only by tests |
| mu sync.Mutex |
| buckets map[string]*tokenBucket |
| @@ -29,8 +44,8 @@ type tokenBucket struct { |
| seenAt time.Time |
| } |
| -func newRateLimiter(rate, burst int) *rateLimiter { |
| - return &rateLimiter{rate: float64(rate), burst: float64(burst), buckets: map[string]*tokenBucket{}} |
| +func newRateLimiter(rate, burst, cap int) *rateLimiter { |
| + return &rateLimiter{rate: float64(rate), burst: float64(burst), cap: cap, buckets: map[string]*tokenBucket{}} |
| } |
| // allow spends one token for key, refilling by the time elapsed since |
| @@ -47,6 +62,9 @@ func (l *rateLimiter) allow(key string, now time.Time) (ok bool, retryAfter time |
| b, found := l.buckets[key] |
| if !found { |
| + if l.cap > 0 && len(l.buckets) >= l.cap { |
| + l.evictOldestLocked() |
| + } |
| b = &tokenBucket{tokens: l.burst, seenAt: now} |
| l.buckets[key] = b |
| } else if elapsed := now.Sub(b.seenAt).Seconds(); elapsed > 0 { |
| @@ -62,9 +80,7 @@ func (l *rateLimiter) allow(key string, now time.Time) (ok bool, retryAfter time |
| return true, 0 |
| } |
| -// sweepLocked drops any bucket idle for a full minute: it will simply be |
| -// recreated at burst on its next visit, which is correct — a key that |
| -// has been quiet for a minute owes nothing. |
| +// sweepLocked drops any bucket idle for a full minute. |
| func (l *rateLimiter) sweepLocked(now time.Time) { |
| for k, b := range l.buckets { |
| if now.Sub(b.seenAt) > time.Minute { |
| @@ -72,3 +88,22 @@ func (l *rateLimiter) sweepLocked(now time.Time) { |
| } |
| } |
| } |
| + |
| +// evictOldestLocked drops the bucket least recently touched, so a |
| +// bounded limiter never grows past cap: a burst of distinct keys |
| +// displaces the quietest one rather than growing the map without bound |
| +// (review Important 1). O(n) in the bucket count, which only matters |
| +// right at the cap — everyday traffic never reaches it. |
| +func (l *rateLimiter) evictOldestLocked() { |
| + var oldestKey string |
| + var oldestAt time.Time |
| + first := true |
| + for k, b := range l.buckets { |
| + if first || b.seenAt.Before(oldestAt) { |
| + oldestKey, oldestAt, first = k, b.seenAt, false |
| + } |
| + } |
| + if !first { |
| + delete(l.buckets, oldestKey) |
| + } |
| +} |
diff --git a/internal/meet/ratelimit_test.go b/internal/meet/ratelimit_test.go| new file mode 100644 |
| index 0000000..1d02c6f |
| --- /dev/null |
| +++ b/internal/meet/ratelimit_test.go |
| @@ -0,0 +1,109 @@ |
| +package meet |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Task 5 review minor 5: unit coverage for ratelimit.go's own behaviour, |
| +// independent of the signal endpoint that happens to be its only caller. |
| + |
| +func TestRateLimiterAllowsUpToBurstThenRefuses(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + l := newRateLimiter(30, 3, 0) |
| + for i := 0; i < 3; i++ { |
| + if ok, _ := l.allow("k", now); !ok { |
| + t.Fatalf("call %d within burst was refused", i) |
| + } |
| + } |
| + ok, retry := l.allow("k", now) |
| + if ok { |
| + t.Fatal("a call past the burst, with no time elapsed, was allowed") |
| + } |
| + if retry <= 0 { |
| + t.Fatalf("retryAfter %v, want > 0", retry) |
| + } |
| +} |
| + |
| +func TestRateLimiterRefillsAfterAnExplicitElapsedDuration(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + l := newRateLimiter(30, 1, 0) |
| + if ok, _ := l.allow("k", now); !ok { |
| + t.Fatal("the first call, at burst, was refused") |
| + } |
| + if ok, _ := l.allow("k", now); ok { |
| + t.Fatal("a second call at the same instant was allowed with an empty bucket") |
| + } |
| + // One token at 30/s refills in 1/30 s ≈ 33 ms; 40 ms is comfortably |
| + // past that. |
| + later := now.Add(40 * time.Millisecond) |
| + if ok, _ := l.allow("k", later); !ok { |
| + t.Fatal("a call after the refill window was still refused") |
| + } |
| +} |
| + |
| +func TestRateLimiterRetryAfterIsBoundedByOneTokensRefillTime(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + l := newRateLimiter(30, 1, 0) |
| + l.allow("k", now) |
| + _, retry := l.allow("k", now) |
| + oneToken := time.Second / 30 |
| + if retry <= 0 || retry > oneToken+time.Millisecond { |
| + t.Fatalf("retryAfter %v, want in (0, %v]", retry, oneToken) |
| + } |
| +} |
| + |
| +func TestRateLimiterEvictsABucketIdleOverAMinute(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + l := newRateLimiter(30, 60, 0) |
| + l.allow("k", now) |
| + |
| + l.mu.Lock() |
| + l.sweepLocked(now.Add(59 * time.Second)) |
| + _, stillThereAt59s := l.buckets["k"] |
| + l.sweepLocked(now.Add(61 * time.Second)) |
| + _, stillThereAt61s := l.buckets["k"] |
| + l.mu.Unlock() |
| + |
| + if !stillThereAt59s { |
| + t.Fatal("a bucket under a minute idle was swept early") |
| + } |
| + if stillThereAt61s { |
| + t.Fatal("a bucket over a minute idle was not swept") |
| + } |
| +} |
| + |
| +func TestRateLimiterCapEvictsTheLeastRecentlyTouchedBucket(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + l := newRateLimiter(30, 60, 2) |
| + l.allow("a", now) |
| + l.allow("b", now.Add(time.Second)) |
| + |
| + l.mu.Lock() |
| + n := len(l.buckets) |
| + l.mu.Unlock() |
| + if n != 2 { |
| + t.Fatalf("buckets after two distinct keys: %d, want 2", n) |
| + } |
| + |
| + // A third key must not grow the map past the cap: "a", the oldest, |
| + // is evicted to make room. |
| + l.allow("c", now.Add(2*time.Second)) |
| + |
| + l.mu.Lock() |
| + _, aStillThere := l.buckets["a"] |
| + _, bStillThere := l.buckets["b"] |
| + _, cThere := l.buckets["c"] |
| + size := len(l.buckets) |
| + l.mu.Unlock() |
| + |
| + if size > 2 { |
| + t.Fatalf("bucket count %d exceeds the cap of 2", size) |
| + } |
| + if aStillThere { |
| + t.Fatal("the least recently touched bucket ('a') was not evicted to make room") |
| + } |
| + if !bStillThere || !cThere { |
| + t.Fatal("a bucket newer than the one evicted was lost instead") |
| + } |
| +} |
diff --git a/internal/meet/signal.go b/internal/meet/signal.go| index 120f658..72ff5f4 100644 |
| --- a/internal/meet/signal.go |
| +++ b/internal/meet/signal.go |
| @@ -5,6 +5,7 @@ import ( |
| "errors" |
| "io" |
| "net/http" |
| + "regexp" |
| "strconv" |
| "github.com/go-chi/chi/v5" |
| @@ -15,6 +16,31 @@ import ( |
| // maxSignalBody is spec §4.4's 64 KB cap, refused before decoding. |
| const maxSignalBody = 64 * 1024 |
| +// peerIDPattern is the exact shape a server-issued peer id takes: |
| +// sixteen random bytes in hex (hub's randomID). Anything else is |
| +// refused before it is ever used as a hub lookup key or a log argument |
| +// (review Important 2) — a peer field padded to tens of kilobytes never |
| +// reaches either. |
| +var peerIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) |
| + |
| +func validPeerID(p hub.PeerID) bool { return peerIDPattern.MatchString(string(p)) } |
| + |
| +// knownSignalTypes bounds what the log's "type" argument can ever say: |
| +// one of the wire's own names, or the literal "unknown" — never an |
| +// attacker's arbitrary string, however long (review Important 2). |
| +var knownSignalTypes = map[string]bool{ |
| + hub.TypeJoin: true, hub.TypeAdmit: true, hub.TypeLeave: true, |
| + hub.TypeOffer: true, hub.TypeAnswer: true, hub.TypeCandidate: true, |
| + hub.TypeReset: true, hub.TypeChat: true, hub.TypeShare: true, |
| +} |
| + |
| +func safeSignalType(t string) string { |
| + if knownSignalTypes[t] { |
| + return t |
| + } |
| + return "unknown" |
| +} |
| + |
| // signalBody is the one shape every upstream message arrives in. |
| // Payload is a json.RawMessage, which encoding/json only ever populates |
| // with a syntactically valid JSON value — decoding into this struct is |
| @@ -45,6 +71,24 @@ func (a *app) signal(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + // Rate limiting is keyed on session and room, never on anything the |
| + // request body claims, and happens before a single byte of the body |
| + // is read: session+room is unforgeable (the session half comes from |
| + // the cookie the request actually presented) and is also the one |
| + // bucket a signed-in person's traffic in this room can ever spend, |
| + // whatever peer id they name — and checking it first means an |
| + // oversized flood spends a token too, rather than being free to |
| + // retry forever below the size cap (review Important 1). |
| + if allowed, retry := a.signalLimiter.allow(session+"|"+sqid, a.store.now()); !allowed { |
| + // Retry-After is whole seconds per RFC 9110. At this limiter's |
| + // rate, one token refills in 1/30 s = 33 ms, so retry is always |
| + // sub-second and this always rounds up to "1" — coarse on |
| + // purpose, not a miscalculation (review minor 11). |
| + w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1)) |
| + a.refuseSignal(w, sqid, "", "", http.StatusTooManyRequests, "rate", map[string]any{"error": "rate"}) |
| + return |
| + } |
| + |
| // The size cap is enforced by refusing to read past it, not by |
| // checking Content-Length: a refused body is never read past the |
| // limit (spec §4.4). |
| @@ -61,20 +105,17 @@ func (a *app) signal(w http.ResponseWriter, r *http.Request) { |
| } |
| var body signalBody |
| - if err := json.Unmarshal(raw, &body); err != nil || body.Peer == "" || body.Type == "" { |
| - a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| - return |
| - } |
| - |
| - // Rate limiting is keyed by peer id and happens before any lookup or |
| - // dispatch, so a peer that has burned its budget is refused without |
| - // the hub doing any work on its behalf (spec §4.4). |
| - if allowed, retry := a.signalLimiter.allow(string(body.Peer), a.store.now()); !allowed { |
| - w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1)) |
| - a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusTooManyRequests, "rate", map[string]any{"error": "rate"}) |
| + if err := json.Unmarshal(raw, &body); err != nil || body.Type == "" || !validPeerID(body.Peer) { |
| + // peer is never logged here: an invalid peer is exactly what may |
| + // have failed the check, and it has not been validated as |
| + // bounded, printable text (review Important 2). |
| + a.refuseSignal(w, sqid, "", body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| return |
| } |
| + // From here body.Peer is known to be exactly 32 lowercase hex — |
| + // bounded and safe to use as a log argument and as the hub's lookup |
| + // key. |
| var herr error |
| switch body.Type { |
| case hub.TypeJoin: |
| @@ -83,7 +124,7 @@ func (a *app) signal(w http.ResponseWriter, r *http.Request) { |
| var p struct { |
| Peer hub.PeerID `json:"peer"` |
| } |
| - if err := json.Unmarshal(body.Payload, &p); err != nil || p.Peer == "" { |
| + if err := json.Unmarshal(body.Payload, &p); err != nil || !validPeerID(p.Peer) { |
| a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| return |
| } |
| @@ -117,16 +158,27 @@ func (a *app) signal(w http.ResponseWriter, r *http.Request) { |
| case errors.Is(herr, hub.ErrNotWaiting): |
| a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusConflict, "not-waiting", map[string]any{"error": "conflict"}) |
| case errors.Is(herr, hub.ErrNotTargeted): |
| + // Defence, not a reachable path: ErrNotTargeted only ever comes |
| + // from Relay's own isTargeted guard, and this dispatch only ever |
| + // calls Relay for offer/answer/candidate — already targeted |
| + // types — so this arm cannot fire today. Kept in case that |
| + // invariant ever drifts (review minor 10). |
| a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "type", map[string]any{"error": "bad-request"}) |
| case errors.Is(herr, hub.ErrNotYours), errors.Is(herr, hub.ErrUnknownPeer), |
| errors.Is(herr, hub.ErrNotAdmitted), errors.Is(herr, hub.ErrNotMember): |
| // A meeting, or a peer, that is not yours does not exist (§2.5). |
| // ErrNotYours and ErrUnknownPeer share this one body on purpose: |
| - // the review ruling requires the two to be indistinguishable. |
| - a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusNotFound, "binding", map[string]any{"error": "not-found"}) |
| + // the review ruling requires the two to be indistinguishable, |
| + // down to the boot id stream.go's own 404s carry. |
| + a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusNotFound, "binding", map[string]any{"error": "not-found", "boot": a.hub.Boot()}) |
| default: |
| - a.logger.Error("meet: signal: dispatch", "room", sqid, "type", body.Type, "err", herr) |
| - a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusInternalServerError, "internal", nil) |
| + // herr here is a driver error surfaced by a.hubStanding's |
| + // database reads (every other outcome StandingFor or the hub can |
| + // produce already has its own case above) — never a row value, |
| + // so nothing user-supplied ever reaches this argument (review |
| + // minor 9). |
| + a.logger.Error("meet: signal refused", "room", sqid, "peer", string(body.Peer), "type", safeSignalType(body.Type), "outcome", "internal", "err", herr) |
| + streamJSON(w, http.StatusInternalServerError, nil) |
| } |
| } |
| @@ -139,10 +191,14 @@ func (a *app) refuseSignal(w http.ResponseWriter, sqid string, peer hub.PeerID, |
| streamJSON(w, status, body) |
| } |
| -// logRefusal is the only signalling log line, and it carries no content: |
| -// no payload, no chat text, no SDP, no candidate, no display name, no |
| -// title and no link token (spec §4.4, §0.1). An accepted message logs |
| -// nothing at all. |
| +// logRefusal is the only signalling log line for a case the switch |
| +// above already names, and it carries no content: no payload, no chat |
| +// text, no SDP, no candidate, no display name, no title and no link |
| +// token (spec §4.4, §0.1) — and, since peer and typ may not yet be |
| +// validated wherever this is called with an empty peer, only ever a |
| +// peer already known to be exactly 32 lowercase hex and a type already |
| +// known to be one of the wire's own names or "unknown" (review |
| +// Important 2). An accepted message logs nothing at all. |
| func (a *app) logRefusal(sqid string, peer hub.PeerID, typ, outcome string) { |
| - a.logger.Warn("meet: signal refused", "room", sqid, "peer", string(peer), "type", typ, "outcome", outcome) |
| + a.logger.Warn("meet: signal refused", "room", sqid, "peer", string(peer), "type", safeSignalType(typ), "outcome", outcome) |
| } |
diff --git a/internal/meettest/signal_test.go b/internal/meettest/signal_test.go| index c225192..be4b9b1 100644 |
| --- a/internal/meettest/signal_test.go |
| +++ b/internal/meettest/signal_test.go |
| @@ -3,6 +3,7 @@ package meettest |
| import ( |
| "bytes" |
| "encoding/json" |
| + "fmt" |
| "log/slog" |
| "net/http" |
| "net/url" |
| @@ -22,12 +23,24 @@ func TestSignalNeedsAPeerBoundToThisSessionAndRoom(t *testing.T) { |
| c.signIn("paul@example.com") |
| s := c.stream("/m/" + sqid + "/stream") |
| defer s.close() |
| - peer, _ := s.hello() |
| + peer, boot := s.hello() |
| resp, unknownBody := c.signal(sqid, "0123456789abcdef0123456789abcdef", `{"type":"join"}`) |
| if resp.StatusCode != http.StatusNotFound { |
| t.Fatalf("foreign peer: %d", resp.StatusCode) |
| } |
| + // Minor 6: the 404 body carries boot, identical for ErrUnknownPeer |
| + // and ErrNotYours, the same shape stream.go's own 404s carry. |
| + var unknownAnswer struct { |
| + Error string |
| + Boot string |
| + } |
| + if err := json.Unmarshal(unknownBody, &unknownAnswer); err != nil { |
| + t.Fatalf("404 body %s: %v", unknownBody, err) |
| + } |
| + if unknownAnswer.Error != "not-found" || unknownAnswer.Boot != boot { |
| + t.Fatalf("404 body %+v, want error=not-found boot=%q", unknownAnswer, boot) |
| + } |
| other := x.browser(t) |
| other.get("/j/" + tok) |
| @@ -64,6 +77,131 @@ func TestSignalRefusesAPeerFromAnotherMeeting(t *testing.T) { |
| } |
| } |
| +// Task 5 review Important 1: the limiter is keyed on session+room, never |
| +// on anything the request body claims, so a second session flooding the |
| +// endpoint spends only its own bucket. |
| +func TestSignalRateLimitIsPerSessionNotPerClaimedPeer(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + victim := x.browser(t) |
| + victim.signIn("paul@example.com") |
| + vs := victim.stream("/m/" + sqid + "/stream") |
| + defer vs.close() |
| + vpeer, _ := vs.hello() |
| + if resp, _ := victim.signal(sqid, vpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("victim join: %d", resp.StatusCode) |
| + } |
| + |
| + // A second session in the same room, flooding while *claiming the |
| + // victim's own peer id* — peer ids are published in rosters, so |
| + // this is exactly what a limiter keyed on the body's claimed peer |
| + // cannot tell from the victim's own traffic (review Important 1). |
| + attacker := newMemberBrowser(t, x, "paul@rslw.com") |
| + as := attacker.stream("/m/" + sqid + "/stream") |
| + defer as.close() |
| + as.hello() // the attacker's own peer, unused below on purpose |
| + |
| + for i := 0; i < 80; i++ { |
| + attacker.signal(sqid, vpeer, `{"type":"leave"}`) |
| + } |
| + |
| + // The victim's own session, still using its own real peer, must be |
| + // unaffected by traffic that only ever came from another session. |
| + if resp, _ := victim.signal(sqid, vpeer, `{"type":"leave"}`); resp.StatusCode == http.StatusTooManyRequests { |
| + t.Fatal("another session impersonating this peer id in its own requests drained its rate-limit bucket") |
| + } |
| +} |
| + |
| +// Task 5 review Important 1: rotating the claimed peer id within one |
| +// session must not evade the limit — every claimed id, real or not, |
| +// spends the same session+room bucket. |
| +func TestSignalRateLimitSharesOneBucketAcrossClaimedPeerIDs(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + c.signal(sqid, peer, `{"type":"join"}`) |
| + |
| + var got429 bool |
| + for i := 0; i < 100; i++ { |
| + // A distinct, format-valid, but otherwise made-up peer id on |
| + // every call: none of them is bound to anything, so if the |
| + // limiter were still keyed on the claimed peer, rotating like |
| + // this would never be throttled. |
| + fake := fmt.Sprintf("%032x", i) |
| + resp, _ := c.signal(sqid, fake, `{"type":"join"}`) |
| + if resp.StatusCode == http.StatusTooManyRequests { |
| + got429 = true |
| + break |
| + } |
| + } |
| + if !got429 { |
| + t.Fatal("rotating the claimed peer id within one session evaded the rate limit") |
| + } |
| +} |
| + |
| +// Task 5 review Important 2: an oversized peer field is refused, and |
| +// never becomes a log argument — the log's "peer" is only ever a value |
| +// already known to be exactly 32 lowercase hex. |
| +func TestSignalRefusesAnOversizedPeerAndNeverLogsIt(t *testing.T) { |
| + var buf bytes.Buffer |
| + x := newHarnessWithLogger(t, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + sqid, _ := createMeeting(t, c, "x", "") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + s.hello() |
| + |
| + huge := strings.Repeat("a", 60*1024) |
| + resp, _ := c.signal(sqid, huge, `{"type":"join"}`) |
| + if resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("60 KB peer: %d, want 400", resp.StatusCode) |
| + } |
| + if strings.Contains(buf.String(), huge) { |
| + t.Fatal("the oversized peer string reached the log") |
| + } |
| +} |
| + |
| +// Task 5 review minor 8: the body shapes signal.go must classify |
| +// correctly, including the ones client.signal cannot express (a |
| +// malformed document, a body with no peer at all). |
| +func TestSignalBodyShapeTable(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("setup join: %d", resp.StatusCode) |
| + } |
| + absent := fmt.Sprintf("%032x", 0) // well-formed, bound to nobody |
| + |
| + table := []struct { |
| + name string |
| + body string |
| + want int |
| + }{ |
| + {"chat is slice 4", `{"peer":"` + string(peer) + `","type":"chat"}`, http.StatusBadRequest}, |
| + {"share is slice 4", `{"peer":"` + string(peer) + `","type":"share"}`, http.StatusBadRequest}, |
| + {"bogus type", `{"peer":"` + string(peer) + `","type":"bogus"}`, http.StatusBadRequest}, |
| + {"malformed JSON", `{`, http.StatusBadRequest}, |
| + {"missing peer", `{"type":"join"}`, http.StatusBadRequest}, |
| + {"malformed admit payload.peer", `{"peer":"` + string(peer) + `","type":"admit","payload":{"peer":"not-hex"}}`, http.StatusBadRequest}, |
| + {"well-formed absent admit target", `{"peer":"` + string(peer) + `","type":"admit","payload":{"peer":"` + absent + `"}}`, http.StatusConflict}, |
| + } |
| + for _, tc := range table { |
| + t.Run(tc.name, func(t *testing.T) { |
| + resp, _ := c.signalRaw(sqid, tc.body) |
| + if resp.StatusCode != tc.want { |
| + t.Fatalf("%s: %d, want %d", tc.name, resp.StatusCode, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestSignalLimits(t *testing.T) { |
| x, sqid, _ := memberWithMeeting(t) |
| c := x.browser(t) |
| @@ -94,7 +232,10 @@ func TestSignalLimits(t *testing.T) { |
| } |
| } |
| -func TestSignalStaleGenAnswers409WithTheCurrentGen(t *testing.T) { |
| +// Renamed from TestSignalStaleGenAnswers409WithTheCurrentGen: the |
| +// ledger records the decision that no gen goes inline in the response |
| +// (see task-5-report.md) — the roster already carries it (spec §4.6). |
| +func TestSignalStaleGenIsRefusedWith409(t *testing.T) { |
| x, sqid, _ := memberWithMeeting(t) |
| a := x.browser(t) |
| a.signIn("paul@example.com") |
| @@ -241,32 +382,86 @@ func TestSignalAdmittedResumeReplaysAfterRoster(t *testing.T) { |
| } |
| } |
| -// The §0.1 and §4.4 promise: no payload, name, title or token in any log |
| -// line, on accepted and refused messages and on an error path. |
| +// The §0.1 and §4.4 promise: no payload, address, name, title, token or |
| +// session key in any log line, on accepted and refused messages and on |
| +// an internal-error path. Task 5 review item 4: the earlier version of |
| +// this test passed vacuously — it never exercised a guest's knock, an |
| +// admit, or the 500 arm, and its address ("paul@rslw.com") happened not |
| +// to leave a distinctive trace ("paul" and "example.com" both appear |
| +// harmlessly elsewhere). zaphod-xyzzy@example.com is chosen so its |
| +// derived display name (store.ensurePerson's local-part rule, |
| +// "zaphod-xyzzy") is its own needle. |
| func TestSignallingLogsCarryNoContent(t *testing.T) { |
| var buf bytes.Buffer |
| - x := newHarnessWithLogger(t, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com", "paul@rslw.com") |
| + x := newHarnessWithLogger(t, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com") |
| c := x.browser(t) |
| c.signIn("paul@example.com") |
| sqid, tok := createMeeting(t, c, "Secret title", "") |
| s := c.stream("/m/" + sqid + "/stream") |
| defer s.close() |
| peer, _ := s.hello() |
| - c.signal(sqid, peer, `{"type":"join"}`) |
| - b := newMemberBrowser(t, x, "paul@rslw.com") |
| - sb := b.stream("/m/" + sqid + "/stream") |
| - defer sb.close() |
| - pb, _ := sb.hello() |
| - b.signal(sqid, pb, `{"type":"join"}`) |
| - c.signal(sqid, peer, `{"type":"offer","to":"`+pb+`","seq":1,"gen":1,"payload":{"sdp":"v=0 SDPSECRET a=candidate 203.0.113.9"}}`) |
| - c.signal(sqid, peer, `{"type":"candidate","to":"`+pb+`","seq":2,"gen":1,"payload":{"candidate":"candidate:1 1 udp 2 203.0.113.9 5000 typ host"}}`) |
| - c.signal(sqid, peer, `{"type":"offer","to":"`+pb+`","seq":3,"gen":9,"payload":{"sdp":"STALESECRET"}}`) |
| - c.signal(sqid, peer, `{"type":"bogus"}`) |
| + if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("member join: %d", resp.StatusCode) |
| + } |
| + |
| + // A real guest, via the join link, not another member: its join is |
| + // the knock, and it stays out of the log entirely until admitted. |
| + guest := x.browser(t) |
| + guest.get("/j/" + tok) |
| + guest.signIn("zaphod-xyzzy@example.com") |
| + gs := guest.stream("/m/" + sqid + "/stream") |
| + defer gs.close() |
| + gpeer, _ := gs.hello() |
| + if resp, _ := guest.signal(sqid, gpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("guest join (the knock): %d", resp.StatusCode) |
| + } |
| + |
| + // The admit round: previously untested by this file at all. |
| + if resp, _ := c.signal(sqid, peer, `{"type":"admit","payload":{"peer":"`+gpeer+`"}}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("admit: %d", resp.StatusCode) |
| + } |
| + |
| + c.signal(sqid, peer, `{"type":"offer","to":"`+gpeer+`","seq":1,"gen":1,"payload":{"sdp":"v=0 SDPSECRET a=candidate 203.0.113.9"}}`) |
| + c.signal(sqid, peer, `{"type":"candidate","to":"`+gpeer+`","seq":2,"gen":1,"payload":{"candidate":"candidate:1 1 udp 2 203.0.113.9 5000 typ host"}}`) |
| + c.signal(sqid, peer, `{"type":"offer","to":"`+gpeer+`","seq":3,"gen":9,"payload":{"sdp":"STALESECRET"}}`) // outcome=stale-gen |
| + c.signal(sqid, peer, `{"type":"bogus"}`) // outcome=type |
| + |
| x.advance(time.Minute) |
| x.ctl.TickForTest() |
| + |
| + // Force the internal-error arm without breaking sign-in itself: |
| + // closing the whole database also breaks rastrillo's own |
| + // per-request session lookup, which refuses the request before it |
| + // ever reaches the signal handler. Dropping only the meetings table |
| + // leaves sessions and persons answering normally, so the request |
| + // reaches hub.Join, whose standing re-derivation reads meetings and |
| + // surfaces a genuine driver error — a sentinel this switch does not |
| + // already name — exercising the one log line no other case in this |
| + // test reaches (review minor 9). |
| + if err := x.db.G.Exec("DROP TABLE meetings").Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + c.signal(sqid, peer, `{"type":"join"}`) |
| + |
| logs := buf.String() |
| - for _, needle := range []string{"Secret title", "SDPSECRET", "203.0.113.9", "STALESECRET", tok, "paul@example.com"} { |
| - if strings.Contains(logs, needle) { |
| + for _, want := range []string{"outcome=stale-gen", "outcome=type", "outcome=internal"} { |
| + if !strings.Contains(logs, want) { |
| + t.Errorf("log missing %q:\n%s", want, logs) |
| + } |
| + } |
| + |
| + forbidden := []string{ |
| + "Secret title", "SDPSECRET", "203.0.113.9", "STALESECRET", tok, |
| + "paul@example.com", "zaphod-xyzzy@example.com", "zaphod-xyzzy", |
| + } |
| + for _, ck := range c.cookies { |
| + forbidden = append(forbidden, ck.Value) |
| + } |
| + for _, ck := range guest.cookies { |
| + forbidden = append(forbidden, ck.Value) |
| + } |
| + for _, needle := range forbidden { |
| + if needle != "" && strings.Contains(logs, needle) { |
| t.Errorf("log carries %q", needle) |
| } |
| } |
diff --git a/internal/meettest/sse_test.go b/internal/meettest/sse_test.go| index 3c7f0df..af43d30 100644 |
| --- a/internal/meettest/sse_test.go |
| +++ b/internal/meettest/sse_test.go |
| @@ -246,3 +246,31 @@ func (c *client) signal(sqid, peer, body string) (*http.Response, []byte) { |
| } |
| return resp, b |
| } |
| + |
| +// signalRaw posts body verbatim, unlike signal, which always injects a |
| +// well-formed "peer" — for the shapes signal cannot express at all: a |
| +// malformed document, or one with no peer field. |
| +func (c *client) signalRaw(sqid, body string) (*http.Response, []byte) { |
| + c.t.Helper() |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.x.server().URL+"/m/"+sqid+"/signal", strings.NewReader(body)) |
| + if err != nil { |
| + c.t.Fatal(err) |
| + } |
| + for _, ck := range c.cookies { |
| + req.AddCookie(ck) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Origin", testOrigin) |
| + resp, err := c.x.server().Client().Do(req) |
| + if err != nil { |
| + c.t.Fatalf("POST signal: %v", err) |
| + } |
| + b, err := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + if err != nil { |
| + c.t.Fatalf("POST signal: reading body: %v", err) |
| + } |
| + return resp, b |
| +} |