package calls import ( "bytes" "encoding/json" "errors" "io" "net/http" "regexp" "strconv" "unicode/utf8" "github.com/go-chi/chi/v5" "amadan.net/oficina/calls/internal/calls/hub" "amadan.net/oficina/calls/protocol" ) // maxSignalBody is spec §4.4's 64 KB cap, refused before decoding. const maxSignalBody = 64 * 1024 // maxChatBytes is §4.4's 2 KB, counted in bytes of the text, not // characters: the limit protects the other peers' streams, which carry // bytes. const maxChatBytes = 2048 // validateFanout checks the two untargeted payload shapes without // keeping anything from them: chat is exactly {"text": string} with // 1..2048 bytes of valid UTF-8; share is exactly {"on": bool}. It // returns the canonical payload to forward, plus a status and an // outcome word for the refusal log, or 0 and the payload. // // The raw byte length is capped first, before anything is decoded, not // just the decoded text's length: a payload of {"text":"<64 KB>", // "text":"x"} decodes to a harmless one-byte text while the raw bytes — // the huge first value included — are what the sender put on the wire; // a \uXXXX escape can likewise inflate the raw size to roughly six // times what it decodes to. +32 is JSON's own structural overhead for // either shape ({"text":"…"} or {"on":…}), never budget for the // payload's content. // // UTF-8 validity is checked the same way and for the same reason: // encoding/json's decoder replaces an invalid byte with the Unicode // replacement character on its way into a string field, so a decoded // *p.Text is always valid UTF-8 by the time it can be inspected — // checking it there would report clean on raw bytes containing // anything at all. Checking raw itself, before decoding, is what // judges what was actually sent. // // Then the keys, byte-exactly (Codex S4-1). encoding/json matches an // object's keys to struct fields case-insensitively, and // DisallowUnknownFields does not change that: {"text":"<2049 a>", // "Text":"x"} decoded to a one-byte text — inside the cap — while the // raw bytes carried both keys and every recipient reads the lowercase // one. exactKey walks the token stream and insists on one member, // spelled exactly, appearing exactly once; a map would have collapsed // the duplicate and said nothing. // // And then, last, the payload is re-encoded from the value that was // just validated (Codex S4-2) rather than forwarded raw. The bytes on // every recipient's stream are then the canonical form of what these // checks judged — an escape delivered decoded, an angle bracket // delivered as itself, the size on the wire the size that was // measured — and no spelling the sender chose can make the two // disagree. SetEscapeHTML(false) because SSE data is not HTML, and the // escaping is what turned 2048 literal `<` into 12 KB. func validateFanout(typ string, raw json.RawMessage) (json.RawMessage, int, string) { if len(raw) > maxChatBytes+32 { return nil, http.StatusRequestEntityTooLarge, "size" } if !utf8.Valid(raw) { return nil, http.StatusBadRequest, "shape" } switch typ { case hub.TypeChat: if !exactKey(raw, "text") { return nil, http.StatusBadRequest, "shape" } var p struct { Text *string `json:"text"` } if err := json.Unmarshal(raw, &p); err != nil || p.Text == nil { return nil, http.StatusBadRequest, "shape" } if len(*p.Text) == 0 { return nil, http.StatusBadRequest, "shape" } canon, ok := canonicalJSON(struct { Text string `json:"text"` }{Text: *p.Text}) if !ok { return nil, http.StatusBadRequest, "shape" } // §4.4's 2 KB, counted on the escaped text — the bytes this // payload actually puts on every recipient's stream, which is // what the limit is there to bound. Counting the decoded string // instead measured something nobody sends: a quote, a backslash // or a newline is one byte decoded and two on the wire, so a // 2048-byte text with twenty-three quotes in it passed the // client's own counter and came back 413 from a rule the client // could not apply (whole-branch review, W4-1). chat.js counts // the same thing, with JSON.stringify. if len(canon)-len(`{"text":""}`) > maxChatBytes { return nil, http.StatusRequestEntityTooLarge, "size" } return canon, 0, "" case hub.TypeShare: if !exactKey(raw, "on") { return nil, http.StatusBadRequest, "shape" } var p struct { On *bool `json:"on"` } if err := json.Unmarshal(raw, &p); err != nil || p.On == nil { return nil, http.StatusBadRequest, "shape" } canon, ok := canonicalJSON(struct { On bool `json:"on"` }{On: *p.On}) if !ok { return nil, http.StatusBadRequest, "shape" } return canon, 0, "" } // Not a fan-out type at all: the dispatch below never calls this // with one, and the arm that does calls it is the only caller. return raw, 0, "" } // exactKey reports whether raw is a JSON object with exactly one // member, whose name is byte-for-byte want and appears exactly once, // and nothing after the object's closing brace. // // It walks the token stream rather than unmarshalling into a // map[string]json.RawMessage on purpose: a map collapses a duplicated // key to its last value and cannot tell {"text":"a","text":"b"} from // {"text":"b"}, and the first of those is a payload whose raw bytes say // one thing and whose decoded value says another. The walk counts every // member it passes, so it can refuse both. func exactKey(raw json.RawMessage, want string) bool { return exactKeys(raw, want) } // exactKeys is exactKey for an object with more than one member: every // name in want, each spelled byte-for-byte and appearing exactly once, // in any order, and no other member at all. func exactKeys(raw json.RawMessage, want ...string) bool { seen := make(map[string]bool, len(want)) allowed := make(map[string]bool, len(want)) for _, w := range want { allowed[w] = true } dec := json.NewDecoder(bytes.NewReader(raw)) open, err := dec.Token() if err != nil { return false } if d, ok := open.(json.Delim); !ok || d != '{' { return false } members := 0 for dec.More() { key, err := dec.Token() if err != nil { return false } name, ok := key.(string) if !ok || !allowed[name] || seen[name] { return false } seen[name] = true members++ // The value, whatever shape it takes: read past it so the next // token is the next key or the closing brace. Its type is the // business of the decode above, not of this walk. var value json.RawMessage if err := dec.Decode(&value); err != nil { return false } } // The closing brace, and then nothing: a second value after the // object is a payload with something else in it. if _, err := dec.Token(); err != nil { return false } return members == len(want) && !dec.More() } // maxMediaBytes bounds §15.19's payload before it is decoded. The // longest honest one — both booleans false, audioBlocked, and full // noSoundFrom, screenStalled and hearing lists of fifteen 32-character // peer ids each (§15.20, §15.21, §15.24) — is a little over 1,700 bytes; // the rest is room // for whitespace a client's encoder might add, and nothing a peer can use // to put more on anyone's stream: what goes out is re-encoded from the // validated values. const maxMediaBytes = 3072 // validateMedia is §15.19's shape as §15.20, §15.21 and §15.24 extend it: // "muted" and "cameraOff", both booleans and both required; "audioBlocked", // a boolean, and "noSoundFrom", "screenStalled" and "hearing", each an // array of at most fifteen distinct peer ids, all optional. An empty // "hearing" is kept as empty rather than absent: it is a client saying it // hears nobody yet, where an absent one says nothing (§15.24). Keys byte-exact and each // once, no other member, judged on the raw bytes the same way // validateFanout judges chat and share (Codex S4-1). It returns the // decoded state, or a status and an outcome word for the refusal log. // // A listed id is checked for shape and not for presence: it is the // sender's claim about peers it was told of, the recipients ignore an id // they do not hold a tile for, and checking it against the room would // only race the roster. func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) { if len(raw) > maxMediaBytes { return protocol.MediaState{}, http.StatusRequestEntityTooLarge, "size" } if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom", "screenStalled", "hearing"}) { return protocol.MediaState{}, http.StatusBadRequest, "shape" } var p struct { Muted *bool `json:"muted"` CameraOff *bool `json:"cameraOff"` AudioBlocked json.RawMessage `json:"audioBlocked"` NoSoundFrom json.RawMessage `json:"noSoundFrom"` ScreenStalled json.RawMessage `json:"screenStalled"` Hearing json.RawMessage `json:"hearing"` } if err := json.Unmarshal(raw, &p); err != nil || p.Muted == nil || p.CameraOff == nil { return protocol.MediaState{}, http.StatusBadRequest, "shape" } m := protocol.MediaState{Muted: *p.Muted, CameraOff: *p.CameraOff} // Present means a value of the right type: a null in an optional // member is not "absent", it is a payload nobody's client writes. if p.AudioBlocked != nil { if err := json.Unmarshal(p.AudioBlocked, &m.AudioBlocked); err != nil || bytes.Equal(p.AudioBlocked, []byte("null")) { return protocol.MediaState{}, http.StatusBadRequest, "shape" } } var ok bool if m.NoSoundFrom, ok = peerList(p.NoSoundFrom, protocol.MaxNoSoundFrom); !ok { return protocol.MediaState{}, http.StatusBadRequest, "shape" } if m.ScreenStalled, ok = peerList(p.ScreenStalled, protocol.MaxScreenStalled); !ok { return protocol.MediaState{}, http.StatusBadRequest, "shape" } if m.Hearing, ok = peerList(p.Hearing, protocol.MaxHearing); !ok { return protocol.MediaState{}, http.StatusBadRequest, "shape" } if p.Hearing != nil && m.Hearing == nil { m.Hearing = []hub.PeerID{} } return m, 0, "" } // peerList is one of the media report's optional lists of peer ids: // absent is nil, and present is an array (never null) of at most max // well-formed ids with no repeats. Empty is the ordinary report, and is // kept as nil so that it compares equal to one that left the key out. func peerList(raw json.RawMessage, max int) ([]hub.PeerID, bool) { if raw == nil { return nil, true } var ids []hub.PeerID if err := json.Unmarshal(raw, &ids); err != nil || ids == nil || len(ids) > max { return nil, false } seen := make(map[hub.PeerID]bool, len(ids)) for _, id := range ids { if !validPeerID(id) || seen[id] { return nil, false } seen[id] = true } if len(ids) == 0 { return nil, true } return ids, true } // memberKeys is exactKeys with optional members: every name in required // exactly once, any name in optional at most once, each byte-exact, and // nothing else. func memberKeys(raw json.RawMessage, required, optional []string) bool { allowed := make(map[string]bool, len(required)+len(optional)) for _, w := range required { allowed[w] = true } for _, w := range optional { allowed[w] = true } seen := make(map[string]bool, len(allowed)) dec := json.NewDecoder(bytes.NewReader(raw)) open, err := dec.Token() if err != nil { return false } if d, ok := open.(json.Delim); !ok || d != '{' { return false } for dec.More() { key, err := dec.Token() if err != nil { return false } name, ok := key.(string) if !ok || !allowed[name] || seen[name] { return false } seen[name] = true var value json.RawMessage if err := dec.Decode(&value); err != nil { return false } } if _, err := dec.Token(); err != nil { return false } for _, w := range required { if !seen[w] { return false } } return !dec.More() } // canonicalJSON encodes one validated value the way it goes out on the // wire: no HTML escaping (SSE data is not HTML) and no trailing newline // from the Encoder's own line framing. func canonicalJSON(v any) (json.RawMessage, bool) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return nil, false } return json.RawMessage(bytes.TrimRight(buf.Bytes(), "\n")), true } // fanoutError names the JSON error the two ways validateFanout can // refuse a fan-out message. func fanoutError(status int) string { if status == http.StatusRequestEntityTooLarge { return "too-large" } return "bad-request" } // 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, hub.TypeMedia: 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 // what validates the whole body, including payload, before any of it is // trusted; nothing here is built from unvalidated bytes by hand. type signalBody = protocol.Signal // signal is POST /m/{sqid}/signal: the upstream half of spec §4.1. The // hub is the only authority on whether the named peer belongs to this // room and session — every dispatch below hands it session and peer and // lets its own lookup enforce the binding, rather than this handler // pre-checking a peer id that came from the request body. `from` and // `name` are never taken from the body; the hub rebuilds the envelope // from the participation it finds. func (a *app) signal(w http.ResponseWriter, r *http.Request) { sqid := chi.URLParam(r, "sqid") session, ok := a.sessionKey(r) if !ok { a.logger.Error("calls: signal with no session subject", "path", r.URL.Path) streamJSON(w, http.StatusInternalServerError, nil) return } // sqid is checked before it can reach anything else: the limiter's // key, a log line, or a hub lookup. chi hands back whatever the // request line contained, unbounded — a kilobyte of it would // otherwise mint a rate-limit bucket and a log line of its own on // every request (review Task 5 item 1). if !validSqid(sqid) { a.refuseBadRoom(w) return } // A session with no live participation in this exact room is // refused the identical not-found answer before the limiter is // touched at all: nothing is spent and nothing is minted for a room // this session has never opened a stream in, which is what closes // off room-id rotation as a way to mint a fresh bucket per request // (review Task 5 item 1). livePeer is the hub's own record of what // this session holds — server-derived, never the request's own // claim — so it is safe to log from here on (review item 2). livePeer, live := a.hub.LiveForSession(sqid, session) if !live { a.refuseSignal(w, sqid, "", "", http.StatusNotFound, "unbound", a.notFoundBody()) 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, the room half is now // gated by LiveForSession above) 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, livePeer, "", 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). r.Body = http.MaxBytesReader(w, r.Body, maxSignalBody) raw, err := io.ReadAll(r.Body) if err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { a.refuseSignal(w, sqid, "", "", http.StatusRequestEntityTooLarge, "too-large", map[string]any{"error": "too-large"}) return } a.refuseSignal(w, sqid, "", "", http.StatusBadRequest, "read", map[string]any{"error": "bad-request"}) return } var body signalBody 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: herr = a.hub.Join(r.Context(), sqid, session, body.Peer) case hub.TypeAdmit: var p struct { Peer hub.PeerID `json:"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 } herr = a.hub.Admit(r.Context(), sqid, session, body.Peer, p.Peer) case hub.TypeLeave: herr = a.hub.Leave(sqid, session, body.Peer) case hub.TypeOffer, hub.TypeAnswer, hub.TypeCandidate: herr = a.hub.Relay(r.Context(), sqid, session, body.Peer, hub.Message{ Type: body.Type, To: body.To, Seq: body.Seq, Gen: body.Gen, Payload: body.Payload, }) case hub.TypeReset: herr = a.hub.Reset(r.Context(), sqid, session, body.Peer, body.To) case hub.TypeChat, hub.TypeShare: if body.To != "" { a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) return } // The canonical payload, not the request's own bytes: what goes // out is a re-encoding of exactly what was validated (Codex // S4-1, S4-2), so the size and the shape on every recipient's // stream are the size and the shape these checks judged. payload, status, outcome := validateFanout(body.Type, body.Payload) if status != 0 { a.refuseSignal(w, sqid, body.Peer, body.Type, status, outcome, map[string]any{"error": fanoutError(status)}) return } herr = a.hub.Fanout(r.Context(), sqid, session, body.Peer, hub.Message{Type: body.Type, Payload: payload}) case hub.TypeMedia: // §15.19: state about the sender, never targeted, and never // carrying a seq or a generation — none of them means anything // for a message the hub records rather than relays. if body.To != "" || body.Seq != 0 || body.Gen != 0 { a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) return } m, status, outcome := validateMedia(body.Payload) if status != 0 { a.refuseSignal(w, sqid, body.Peer, body.Type, status, outcome, map[string]any{"error": fanoutError(status)}) return } herr = a.hub.SetMedia(r.Context(), sqid, session, body.Peer, m) default: a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "type", map[string]any{"error": "bad-request"}) return } switch { case herr == nil, errors.Is(herr, hub.ErrMeshFull), errors.Is(herr, hub.ErrTooManyWaiting): // A full mesh or a full waiting queue is not a failed request: // the peer has already been sent `full` and may retry with // `join`, so the answer is 2xx and the event carries the news // (review ruling 1). w.WriteHeader(http.StatusOK) case errors.Is(herr, hub.ErrStaleGen): a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusConflict, "stale-gen", map[string]any{"error": "stale"}) 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), errors.Is(herr, hub.ErrNotFanout): // 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). ErrNotFanout is the // same defence for Fanout, which this dispatch only ever calls // for chat/share — already fan-out types. 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, // down to the boot id stream.go's own 404s carry. a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusNotFound, "binding", a.notFoundBody()) default: // 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("calls: signal refused", "room", sqid, "peer", string(body.Peer), "type", safeSignalType(body.Type), "outcome", "internal", "err", herr) streamJSON(w, http.StatusInternalServerError, nil) } } // notFoundBody is the one not-found shape this endpoint ever answers: // stream.go's own precedent, so a peer that is not yours, a peer that // never existed, and a room this session holds nothing in are all one // indistinguishable body (spec §2.5). func (a *app) notFoundBody() map[string]any { return map[string]any{"error": "not-found", "boot": a.hub.Boot()} } // refuseBadRoom answers a room id that is not the shape newSqid ever // produces, with the identical not-found body every other refusal // here carries — logged without the room id itself, unlike every other // sqid this handler logs: this one has not been validated as bounded // or printable, and chi hands back whatever the request line contained // (review Task 5 item 1). func (a *app) refuseBadRoom(w http.ResponseWriter) { a.logger.Warn("calls: signal refused", "room", "invalid", "peer", "", "type", "unknown", "outcome", "bad-room") streamJSON(w, http.StatusNotFound, a.notFoundBody()) } // refuseSignal is every refusal this endpoint makes: it logs the type, // room, peer and outcome and nothing else (spec §4.4, §0.1), then // answers JSON, following stream.go's streamJSON precedent rather than // ErrorPage — this endpoint's client is a script, not a page. func (a *app) refuseSignal(w http.ResponseWriter, sqid string, peer hub.PeerID, typ string, status int, outcome string, body map[string]any) { a.logRefusal(sqid, peer, typ, outcome) streamJSON(w, status, body) } // 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("calls: signal refused", "room", sqid, "peer", string(peer), "type", safeSignalType(typ), "outcome", outcome) }