Repository navigation
Unsigned announce repoints a peer's http_url and inherits its reachable=true federation gate #270
Description
An unauthenticated caller can repoint an existing, currently-reachable peer's http_url to a host they control and inherit that peer's reachable=true federation gate, with no probe in between.
Mechanism
upsert_peer (crates/gitlawb-node/src/db/mod.rs:2093-2095):
INSERT INTO peers (did, http_url, last_seen, last_ping_ok, announced_at)
VALUES ($1, $2, $3, FALSE, $3)
ON CONFLICT(did) DO UPDATE SET http_url = $2, last_seen = $3The conflict branch rewrites http_url and leaves last_ping_ok alone. Reaching it needs no signature: require_signed_peer_writes defaults 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 with last_ping_ok = TRUE and http_url = https://honest-peer.example.com, then drove the mounted announce handler with no auth extension at all and a body naming the same DID with http_url = https://attacker.example.com. Result: 2xx, the URL was rewritten, and last_ping_ok was still TRUE.
Why it matters
api/repos.rs:1451 filters the federated fan-out on that flag, and the response handling at api/repos.rs:1464-1476 takes the peer's JSON body verbatim and stamps each entry with node_did set 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_ok FALSE (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:
ON CONFLICT(did) DO UPDATE SET
http_url = $2,
last_seen = $3,
last_ping_ok = CASE WHEN peers.http_url IS DISTINCT FROM $2 THEN FALSE ELSE peers.last_ping_ok ENDThat 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...32e45787 does not include db/mod.rs, and the peers.rs hunks touch only ping_peer and 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.
Activity
beardthelion commentedon 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):Merge pull request #280 from Gitlawb/fix/270-reset-peer-reachability-…
test(node): pin peer reachability across a URL change, a repeat, and …
fix(node): reset a peer's reachability gate when an announce changes …
fix(review): correct two comment claims and guard two more wrong shap…
fix(node): bind a peer row to its DID at the upsert_peer boundary (#273)
fix(node): bind a peer row to its DID at the upsert_peer boundary (#273)