Skip to content

Unsigned announce repoints a peer's http_url and inherits its reachable=true federation gate #270

Description

@beardthelion

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 = $3

The 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 END

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...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

  1. added
    crate:nodegitlawb-node — the serving node and REST API
    kind:securityVulnerability fix or hardening
    sev:criticalData loss, exploitable security, or crash loop
    subsystem:apiNode REST API request/response surface
    subsystem:peersPeer announce, discovery, and registry
    on Jul 29, 2026
  2. beardthelion commented on Jul 29, 2026

    @beardthelion
    CollaboratorAuthor

    Re-ran this against origin/main at 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:1451 is the only consumer of http_url that filters on last_ping_ok. Three others do not read the flag at all:

    • sync.rs:204 resolves a queued sync item's origin URL by DID and passes it to clone_repo/fetch_repo as the git remote.
    • repos.rs:1350 fans the post-receive announce out to every peer with a non-empty http_url.
    • resolve.rs:24 serves GET /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 = FALSE is enough.

    The sync path is content, not metadata

    clone_repo uses --mirror, which sets remote.origin.mirror=true and the refspec +refs/*:refs/* (confirmed locally). fetch_repo at sync.rs:646 then runs git remote set-url origin <peer url> followed by fetch --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_ok on a URL change closes repos.rs:1451 and 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 flipping require_signed_peer_writes, gated on this one.

    Separately, #272: notify_sync accepts an unvalidated repo string on the same unsigned route, and the sync worker hand-joins it into a filesystem path, so a//tmp/x resolves 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 via upsert_peer + mark_peer_ping(true):

    status=200 OK
    before=("https://honest-peer.example.com", true)
    after =("https://attacker.example.com",   true)
    
  3. added
    sev:highMajor break or real security/trust risk, no easy workaround
    and removed
    sev:criticalData loss, exploitable security, or crash loop
    on Jul 29, 2026
  4. added a commit that references this issue on Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    crate:nodegitlawb-node — the serving node and REST APIkind:securityVulnerability fix or hardeningsev:highMajor break or real security/trust risk, no easy workaroundsubsystem:apiNode REST API request/response surfacesubsystem:peersPeer announce, discovery, and registrysubsystem:replicationMirror, replica, and cross-node sync

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions