Repository navigation
Unsigned announce repoints a peer's http_url and inherits its reachable=true federation gate #270
Description
Activity
- addedcrate:nodegitlawb-node — the serving node and REST APIgitlawb-node — the serving node and REST APIkind:securityVulnerability fix or hardeningVulnerability fix or hardeningsev:criticalData loss, exploitable security, or crash loopData loss, exploitable security, or crash loopsubsystem:apiNode REST API request/response surfaceNode REST API request/response surfacesubsystem:peersPeer announce, discovery, and registryPeer announce, discovery, and registrysubsystem:replicationMirror, replica, and cross-node syncMirror, replica, and cross-node sync
on Jul 29, 2026 Re-ran this against
origin/mainat 111cff7 and the mechanism reproduces, but the blast radius above is understated and the proposed fix does not cover it. Splitting accordingly: this issue is now scoped to the contained mitigation, with #272 and #273 carrying the rest.The bound is narrower than stated
repos.rs:1451is the only consumer ofhttp_urlthat filters onlast_ping_ok. Three others do not read the flag at all:sync.rs:204resolves a queued sync item's origin URL by DID and passes it toclone_repo/fetch_repoas the git remote.repos.rs:1350fans the post-receive announce out to every peer with a non-emptyhttp_url.resolve.rs:24servesGET /api/v1/resolve/{did}from the peer table, returning the stored URL as the answer for that DID. Public route,server.rs:381.
So "the probe-free version requires hijacking an existing reachable DID" holds only for the federated fan-out. For the other three a fresh DID inserted with
last_ping_ok = FALSEis enough.The sync path is content, not metadata
clone_repouses--mirror, which setsremote.origin.mirror=trueand the refspec+refs/*:refs/*(confirmed locally).fetch_repoatsync.rs:646then runsgit remote set-url origin <peer url>followed byfetch --prune. A repointed URL therefore force-overwrites every ref in the mirrored repo and prunes the ones the new origin does not serve.What that means for the fix
Resetting
last_ping_okon a URL change closesrepos.rs:1451and leaves the other three untouched, since none of them consult the flag. It is still worth landing as a stopgap, but the closing question in the description is the actual fix. That is #273, which lays out first-writer-wins against flippingrequire_signed_peer_writes, gated on this one.Separately, #272:
notify_syncaccepts an unvalidatedrepostring on the same unsigned route, and the sync worker hand-joins it into a filesystem path, soa//tmp/xresolves to the absolute/tmp/x.git. That one needs no hijack at all.Probe
A throwaway
#[sqlx::test]drove the mounted router with no auth extension, seeding the victim viaupsert_peer+mark_peer_ping(true):status=200 OK before=("https://honest-peer.example.com", true) after =("https://attacker.example.com", true)- addedsev:highMajor break or real security/trust risk, no easy workaroundMajor break or real security/trust risk, no easy workaroundand removedsev:criticalData loss, exploitable security, or crash loopData loss, exploitable security, or crash loop
on Jul 29, 2026 - added a commit that references this issue
on Jul 30, 2026 - added 3 commits that reference this issue
on Jul 30, 2026
An unauthenticated caller can repoint an existing, currently-reachable peer's
http_urlto a host they control and inherit that peer'sreachable=truefederation gate, with no probe in between.Mechanism
upsert_peer(crates/gitlawb-node/src/db/mod.rs:2093-2095):The conflict branch rewrites
http_urland leaveslast_ping_okalone. Reaching it needs no signature:require_signed_peer_writesdefaults false (config.rs:48), and the announce handler's unsigned branch only logs a warning (api/peers.rs:196-201). There is no lookup of the existing row, no stored-key check, and no first-writer-wins rule, so one party can rewrite another's row.Verified by execution
A throwaway
#[sqlx::test]seeded a peer withlast_ping_ok = TRUEandhttp_url = https://honest-peer.example.com, then drove the mountedannouncehandler with no auth extension at all and a body naming the same DID withhttp_url = https://attacker.example.com. Result: 2xx, the URL was rewritten, andlast_ping_okwas stillTRUE.Why it matters
api/repos.rs:1451filters the federated fan-out on that flag, and the response handling atapi/repos.rs:1464-1476takes the peer's JSON body verbatim and stamps each entry withnode_didset to the hijacked peer's DID. No signature check, no content addressing. So the attacker injects arbitrary repo entries attributed to a legitimate peer.Bound on the attack
A new DID inserts with
last_ping_okFALSE (db/mod.rs:2094) and stays out of the fan-out until a gossip round probes it. So the instant, probe-free version requires hijacking an existing reachable DID. An attacker announcing their own DID with their own live host still enters the fan-out, just after a probe and without stolen attribution.Fix direction
Reset the gate whenever the URL actually changes, so a repointed peer has to re-earn reachability:
That leaves a plain liveness re-announce alone while closing the carry-over on a swap. It is a direction, not something I have compiled and run; the deeper question is whether announce should bind a DID to its first-seen key regardless of the signing default, which would close the rewrite rather than just its blast radius.
Scope
Pre-existing and untouched by #248:
git diff 111cff7e...32e45787does not includedb/mod.rs, and thepeers.rshunks touch onlyping_peerand tests. #248 does widen the window slightly in passing, since its two-consecutive-failure hysteresis means a hijacked-then-dead URL holds the gate for two gossip rounds instead of one.