Skip to content

hub: five audit fixes — four of them a guard missing from its sibling - #3

Merged
thatSFguy merged 1 commit into
masterfrom
fix/audit-group5
Aug 31, 2026
Merged

thatSFguy merged 1 commit into
masterfrom
fix/audit-group5

Conversation

@thatSFguy

Copy link
Copy Markdown
Owner

A full security review of the hub. Five findings, each fixed with tests; every test was verified to fail against the pre-fix code by reverting that fix alone.

Four of the five are the same shape: a check that exists somewhere in this codebase and was missing from its sibling.

A21 — /topic set had no membership or ban check (high)

Commands are dispatched before any room gate (handleMsg), so a peer banned from a room still reached cmdTopic, where only +t was checked. Any connected identity could retitle any non-+t room it knew the name of — persisted to rooms.toml when registered, and broadcast to every member.

The view path a few lines above already required membership for +p. Only the set path was missing it. Now membership (or server-op) is required, for the reason /history states outright: joining already cleared this room's ban, key and invite gates.

A22 — a command body escaped max_msg_body_bytes (high, compounds A21)

handleMsg dispatches a slash command before the body-size check, so every command handler received an argument string bounded only by the 8 KiB link frame. Demonstrated: a 400-byte message refused, then a 7000-byte topic accepted on the same hub.

Bounded once in handleCommand so a new command cannot reintroduce it. The topic — the part that reaches disk and every future joiner — carries its own max_topic_bytes as well.

max_topic_bytes defaults to 256, not 350, so a maximum-length topic still fits inside the /topic <room> <text> that sets it. TestAMaximumLengthTopicFitsInsideTheCommandThatSetsIt asserts that against the shipped defaults — a limit that advertises more than it can accept is its own bug, and at 350 it reported a 422-byte command, over the 350-byte body limit — the last 72 bytes of max_topic_bytes are unreachable.

A23 — peer eviction destroyed the mentions it exists to protect (high)

A peer with queued mentions is by definition one not seen lately — that is what "away" means and why the mentions are queued. Oldest-last-seen eviction therefore selected exactly those rows, silently.

max_known_peers strangers connecting in sequence was enough (MaxSessions bounds only concurrent links), so ordinary churn on a public hub flushed every held mention. That defeats the one delivery guarantee the RRC fallback makes: "held here and handed over when you next connect."

Eviction now prefers a peer owed nothing, and logs when every peer is owed something rather than dropping mail unremarked.

A24 — a member could not /who their own +p room (low)

private && !serverOp, missing the && !hasMember its sibling in cmdTopic's view path has. +p still hides the room from outside — there is a test for that too.

A25 — configured identity hashes were never validated (low)

isServerOp compares against the hex of a full 16-byte identity hash, so a malformed trusted_identities entry is not a weaker rule but one that can never fire — and the operator's only evidence was a permanent, unexplained "not authorized".

The likeliest cause is not a typo but pasting a destination hash where an identity hash belongs, which is the same length and also hex. Entries now go through parseHexHash — audit A14's validator, which the config path never got — and anything dropped is logged with the field name.

Also

§6.6 LINKIDENTIFY → §6.7.6 in the two comments touched here, per CLAUDE.md's standing rule.

Verified sound during the review (not changed)

  • No path traversal via room names — history.go hex-encodes the name as the directory; dirs 0700, files 0600.
  • No TOML key injection — round-tripped names containing ., ", [, newline, tab, backslash and emoji; BurntSushi quotes and escapes every one.
  • Inbound LXMF is signature-verified before OnMessage, so the auto-reply cannot be aimed at a third party.
  • Resource-delivered NOTICEs do clear the ban/+n/+m gates (A20); rate limiting runs before CBOR decode (A19); identity and ban are rechecked per frame (A1/A2).

Checks

gofmt, go vet, go test ./..., and the -race subset all pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RRgC769HyZWx1YaGoosp9t

A21. /topic set had no membership or ban check. Commands are dispatched
before any room gate (handleMsg), so a peer BANNED from a room still
reached cmdTopic, and only +t was checked — meaning any connected
identity could retitle any non-+t room it knew the name of, persisted
to rooms.toml when registered and broadcast to every member. The view
path a few lines above already required membership for +p; only the set
path was missing it. Now membership (or server-op) is required, for the
reason /history states outright: joining already cleared this room's
ban, key and invite gates.

A22. A command body escaped max_msg_body_bytes. handleMsg dispatches a
slash command BEFORE the body-size check, so every command handler was
receiving an argument string bounded only by the 8 KiB link frame.
Bounded once in handleCommand so a new command cannot reintroduce it,
and the topic — the part that reaches disk and every future joiner —
carries its own max_topic_bytes as well. The default is 256, not 350,
so a maximum-length topic still fits inside the "/topic <room> <text>"
that sets it; a test asserts the two limits against the shipped
defaults, because a limit that advertises more than it can accept is
its own bug.

A23. Peer eviction destroyed the mentions it exists to protect. A peer
with queued mentions is by definition one not seen lately — that is
what "away" means — so oldest-last-seen eviction selected exactly those
rows, silently. max_known_peers strangers connecting in SEQUENCE was
enough (MaxSessions bounds only concurrent links), which defeats the
one guarantee the RRC fallback makes. Eviction now prefers a peer owed
nothing, and logs when it cannot find one.

A24. A member could not /who their own +p room: the check was
`private && !serverOp`, missing the `&& !hasMember` its sibling in
cmdTopic's view path has. +p still hides the room from outside.

A25. trusted_identities / banned_identities were never validated.
isServerOp compares against the hex of a full 16-byte identity hash, so
a malformed entry is not a weaker rule but one that can never fire —
and the operator's only evidence was a permanent, unexplained "not
authorized". The likeliest cause is not a typo but pasting a
DESTINATION hash where an IDENTITY hash belongs, which is the same
length and also hex. Entries are now parsed with parseHexHash (audit
A14's validator, which the config path never got) and anything dropped
is logged with the field name.

Also: §6.6 LINKIDENTIFY -> §6.7.6 in the two comments touched here.

Each of the twelve new tests in security_group5_test.go was verified to
FAIL against the pre-fix code by reverting that fix alone, and the
positive cases (a member sets a topic, a server-op sets one without
joining, eviction still evicts, a valid server-op still authorizes) are
there because four of these fixes tighten an authorization check and
the cheap way to get one wrong is to lock out the people it is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRgC769HyZWx1YaGoosp9t
@thatSFguy
thatSFguy merged commit dcd16a4 into master Aug 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant