| index 2bfd2edb1..dcbf19128 100644 |
| --- a/internal/edge/homemap_test.go |
| +++ b/internal/edge/homemap_test.go |
| @@ -749,6 +749,61 @@ func TestBackhaulRefusesToDialWithoutAMeshKey(t *testing.T) { |
| } |
| } |
| |
| +// A stranger forging the hop vocabulary for a remote-homed host is backhauled |
| +// like any other client, and gains exactly nothing by having tried. |
| +// |
| +// Two halves have to hold at once and they meet only here. acceptHop deletes |
| +// both hop headers and — verification having failed — strips every forwarding |
| +// claim the forger made, so the request reaching this point is an ordinary |
| +// one; backhaul then mints a FRESH signature under the deployment's own key, |
| +// so the home edge sees a hop it can verify and an X-Forwarded-For naming the |
| +// forger. What the forger must NOT get is the stale-map 503 (which would tell |
| +// them the host lives elsewhere), a chain of their own choosing, or their |
| +// garbage signature carried onward under this edge's name. |
| +func TestForgedHopForARemoteHostIsBackhauledAsAnOrdinaryClient(t *testing.T) { |
| + e, m, key := hopEdge(t, "annie", "local.test") |
| + rec, eip := fakeHomeEdge(t, nil) |
| + homedOn(t, m, "betty", eip, "example.com") |
| + |
| + const garbage = "0:deadbeef" |
| + resp := hopRequest(t, e, "example.com", map[string]string{ |
| + hopHeader: "1", |
| + hopAuthHeader: garbage, |
| + "X-Forwarded-For": "203.0.113.9", |
| + }) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200 — a forged hop is an ordinary client, not a refusal", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Retry-After"); got != "" { |
| + t.Fatalf("Retry-After = %q — the stale-map answer must stay unreachable without the mesh key", got) |
| + } |
| + if body := readBody(t, resp); body != homeEdgeBody { |
| + t.Fatalf("body = %q, want the home edge's answer %q", body, homeEdgeBody) |
| + } |
| + seen := rec.get() |
| + if seen.n != 1 { |
| + t.Fatalf("home edge saw %d request(s), want exactly one", seen.n) |
| + } |
| + if seen.auth == garbage { |
| + t.Fatalf("%s = %q — the forger's signature must never be forwarded under this edge's name", hopAuthHeader, seen.auth) |
| + } |
| + if seen.hop != "1" || !verifyHopEitherKey(key, nil, "example.com", seen.auth, time.Now()) { |
| + t.Fatalf("hop=%q auth=%q — the hop must carry a FRESH signature this edge minted", seen.hop, seen.auth) |
| + } |
| + // The forger's claimed chain is gone and the one that arrives names the |
| + // forger's own address, once. Anything else would let a stranger choose |
| + // what the app (and the console's rate limiter) believes about who called. |
| + if len(seen.xff) != 1 || strings.Contains(seen.xff[0], ",") { |
| + t.Fatalf("X-Forwarded-For = %q, want a single-element chain", seen.xff) |
| + } |
| + if strings.Contains(seen.xff[0], "203.0.113.9") { |
| + t.Fatalf("X-Forwarded-For = %q — a forged chain must be discarded, not carried", seen.xff) |
| + } |
| + if ip := net.ParseIP(seen.xff[0]); ip == nil || !ip.IsLoopback() { |
| + t.Fatalf("X-Forwarded-For = %q, want the forger's own (loopback) address", seen.xff) |
| + } |
| +} |
| + |
| // A catch-all row in another box's edges file resolves through the same |
| // one-label wildcard rule lookupUpstream uses — but the signature must be over |
| // the CONCRETE request host, because that is what the home edge verifies |