| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/edgeproof" |
| 13 | "github.com/carlosframework/platform/internal/registry" |
| 14 | ) |
| 15 | |
| 16 | // proofBackend records the forwarded chain and proof of the last request. |
| 17 | type proofBackend struct { |
| 18 | mu sync.Mutex |
| 19 | xff, proof string |
| 20 | } |
| 21 | |
| 22 | func (b *proofBackend) get() (string, string) { |
| 23 | b.mu.Lock() |
| 24 | defer b.mu.Unlock() |
| 25 | return b.xff, b.proof |
| 26 | } |
| 27 | |
| 28 | // proofEdge serves app.test from a recording backend. With armed, the edge |
| 29 | // has a home map carrying a minted mesh key; without, it has no store at all. |
| 30 | func proofEdge(t *testing.T, armed bool) (*Edge, *proofBackend, []byte) { |
| 31 | t.Helper() |
| 32 | b := &proofBackend{} |
| 33 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 34 | b.mu.Lock() |
| 35 | b.xff = strings.Join(r.Header.Values("X-Forwarded-For"), ", ") |
| 36 | b.proof = r.Header.Get(edgeproof.Header) |
| 37 | b.mu.Unlock() |
| 38 | })) |
| 39 | t.Cleanup(ts.Close) |
| 40 | reg := openReg(t) |
| 41 | if err := reg.Upsert(registry.Route{Host: "app.test", Kind: "service", Addr: ts.Listener.Addr().String(), Status: "ready"}); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | e, err := New(reg, nil) |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | t.Cleanup(e.probers.Stop) |
| 49 | if !armed { |
| 50 | return e, b, nil |
| 51 | } |
| 52 | s := meshTestStore(t) |
| 53 | if err := mintMeshKey(context.Background(), s); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | m := newHomeMap(s, "annie") |
| 57 | refreshHomeMap(t, m) |
| 58 | e.homes.Store(m) |
| 59 | key, _ := m.meshKeys() |
| 60 | return e, b, key |
| 61 | } |
| 62 | |
| 63 | // TestEdgeSignsTheForwardedChain: what the edge proxies carries its signature |
| 64 | // over the X-Forwarded-For it sends, so the console on this box can tell the |
| 65 | // edge from a local process writing the same header (discussion 214, item |
| 66 | // 5). A proof the client sent is replaced, never passed on. |
| 67 | func TestEdgeSignsTheForwardedChain(t *testing.T) { |
| 68 | e, b, key := proofEdge(t, true) |
| 69 | forged := edgeproof.Sign(key, "app.test", "6.6.6.6", time.Now()) |
| 70 | resp := hopRequest(t, e, "app.test", map[string]string{ |
| 71 | "X-Forwarded-For": "6.6.6.6", |
| 72 | edgeproof.Header: forged, |
| 73 | }) |
| 74 | if resp.StatusCode != http.StatusOK { |
| 75 | t.Fatalf("status = %d, want 200", resp.StatusCode) |
| 76 | } |
| 77 | xff, proof := b.get() |
| 78 | if strings.Contains(xff, "6.6.6.6") { |
| 79 | t.Fatalf("X-Forwarded-For = %q: an ordinary client's chain reached the app", xff) |
| 80 | } |
| 81 | if proof == "" || proof == forged { |
| 82 | t.Fatalf("proof = %q, want the edge's own signature (client sent %q)", proof, forged) |
| 83 | } |
| 84 | if !edgeproof.Verify(key, nil, "app.test", xff, proof, time.Now()) { |
| 85 | t.Fatalf("proof %q does not verify over the chain sent (%q)", proof, xff) |
| 86 | } |
| 87 | |
| 88 | // A verified hop's chain is the one this edge sends on, and it is signed |
| 89 | // as sent. |
| 90 | resp = hopRequest(t, e, "app.test", map[string]string{ |
| 91 | hopHeader: "1", |
| 92 | hopAuthHeader: signHop(key, "app.test", time.Now()), |
| 93 | "X-Forwarded-For": "203.0.113.9", |
| 94 | }) |
| 95 | if resp.StatusCode != http.StatusOK { |
| 96 | t.Fatalf("hop status = %d, want 200", resp.StatusCode) |
| 97 | } |
| 98 | xff, proof = b.get() |
| 99 | if xff != "203.0.113.9" || !edgeproof.Verify(key, nil, "app.test", xff, proof, time.Now()) { |
| 100 | t.Fatalf("hop forwarded xff=%q proof=%q, want the hop's chain, signed", xff, proof) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // TestEdgeWithoutAKeyStripsTheProof: no mesh key, no signature — and a proof |
| 105 | // header the client sent still never reaches the app. |
| 106 | func TestEdgeWithoutAKeyStripsTheProof(t *testing.T) { |
| 107 | e, b, _ := proofEdge(t, false) |
| 108 | resp := hopRequest(t, e, "app.test", map[string]string{ |
| 109 | edgeproof.Header: edgeproof.Sign([]byte("k"), "app.test", "1.2.3.4", time.Now()), |
| 110 | }) |
| 111 | if resp.StatusCode != http.StatusOK { |
| 112 | t.Fatalf("status = %d, want 200", resp.StatusCode) |
| 113 | } |
| 114 | if _, proof := b.get(); proof != "" { |
| 115 | t.Fatalf("proof = %q, want none from an edge with no key", proof) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // The proof MAC must never double as a hop MAC: both are keyed by the mesh |
| 120 | // key, and a tenant app sees the proof on its own traffic. |
| 121 | func TestForwardProofIsNotAHopAuth(t *testing.T) { |
| 122 | key := []byte("0123456789abcdef0123456789abcdef") |
| 123 | now := time.Now() |
| 124 | if verifyHop(key, "app.test", edgeproof.Sign(key, "app.test", "", now), now) { |
| 125 | t.Fatal("a forwarded-for proof verified as a hop signature") |
| 126 | } |
| 127 | if edgeproof.Verify(key, nil, "app.test", "", signHop(key, "app.test", now), now) { |
| 128 | t.Fatal("a hop signature verified as a forwarded-for proof") |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // TestBackhaulCarriesNoForwardProof: the proof is for the upstream an edge |
| 133 | // dials, so a backhauled request leaves without one, whatever the client |
| 134 | // sent. An older home edge would otherwise pass the far edge's proof to the |
| 135 | // console untouched, and the console would start requiring proofs its own |
| 136 | // edge does not yet send. |
| 137 | func TestBackhaulCarriesNoForwardProof(t *testing.T) { |
| 138 | e, m, key := hopEdge(t, "annie", "local.test") |
| 139 | rec, eip := fakeHomeEdge(t, nil) |
| 140 | homedOn(t, m, "betty", eip, "example.com") |
| 141 | resp := hopRequest(t, e, "example.com", map[string]string{ |
| 142 | edgeproof.Header: edgeproof.Sign(key, "example.com", "1.2.3.4", time.Now()), |
| 143 | }) |
| 144 | if resp.StatusCode != http.StatusOK { |
| 145 | t.Fatalf("status = %d, want 200", resp.StatusCode) |
| 146 | } |
| 147 | if got := rec.get(); got.n != 1 || got.proof != "" { |
| 148 | t.Fatalf("home edge saw %d request(s) with proof %q, want one with none", got.n, got.proof) |
| 149 | } |
| 150 | } |
| 151 | |