| index 5934656b5..7e830e3c1 100644 |
| --- a/internal/edge/certsync_test.go |
| +++ b/internal/edge/certsync_test.go |
| @@ -890,7 +890,14 @@ const mirrorHost = "app.bab.oncarlos.com" |
| // pass on the other. |
| func gatedChain(t *testing.T, m *certMirror, local autocert.Cache, hosts ...string) (func(*tls.ClientHelloInfo) (*tls.Certificate, error), *countingCache) { |
| t.Helper() |
| - e := testIssuerEdge(hosts...) |
| + return gatedChainFor(t, testIssuerEdge(hosts...), m, local) |
| +} |
| + |
| +// gatedChainFor is gatedChain over an edge the caller built — the form a test |
| +// needs when the edge carries something beyond its route table, such as the |
| +// mirrored home map the cert gates now consult (deploymentHost, #277). |
| +func gatedChainFor(t *testing.T, e *Edge, m *certMirror, local autocert.Cache) (func(*tls.ClientHelloInfo) (*tls.Certificate, error), *countingCache) { |
| + t.Helper() |
| e.issuer = func() (context.Context, bool) { return nil, false } // never the issuer |
| counting := &countingCache{Cache: local, gets: map[string]int{}} |
| mgr := offlineManager(t, counting, e.issuerHostPolicy()) |
| @@ -1059,15 +1066,17 @@ func TestMirrorAnswersAnotherEdgesALPNValidation(t *testing.T) { |
| } |
| } |
| |
| -// The SNI here is attacker-chosen, so the live lookup is bounded to hosts |
| -// this box actually serves. |
| -func TestMirrorALPNReadThroughOnlyForServedHosts(t *testing.T) { |
| +// The SNI here is attacker-chosen, so the live lookup is bounded to the names |
| +// this DEPLOYMENT serves — this box's own routes, or another box's |
| +// exactly-named ones (#277). A stranger is refused before any bucket read, as |
| +// it always was. |
| +func TestMirrorALPNReadThroughOnlyForServedOrRemoteHomedHosts(t *testing.T) { |
| s := &bucketGetCounter{Store: release.NewDirStore(t.TempDir())} |
| m := newCertMirror(s, nil) |
| get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost) |
| m.allowed = func(string) bool { return false } |
| if _, err := get(alpnHello("stranger.example")); err == nil { |
| - t.Fatal("there is no token cert for a host this box does not serve") |
| + t.Fatal("there is no token cert for a host this deployment does not serve") |
| } |
| if n := s.gets(); n != 0 { |
| t.Fatalf("bucket gets = %d, want 0 for an unserved host", n) |
| @@ -1124,17 +1133,20 @@ const mirrorTestToken = "0123456789abcdefghij0123456789abcdefghijkl" |
| |
| func challengeRequest(t *testing.T, h http.Handler, token string) *httptest.ResponseRecorder { |
| t.Helper() |
| - r := httptest.NewRequest("GET", "http://"+mirrorHost+"/.well-known/acme-challenge/"+token, nil) |
| - w := httptest.NewRecorder() |
| - h.ServeHTTP(w, r) |
| - return w |
| + return challengeRequestHost(t, h, mirrorHost, token) |
| } |
| |
| // gatedHTTPHandler is the :80 chain of a non-issuer: the mirror's handler in |
| // front of autocert's, which 403s every challenge on this box. |
| func gatedHTTPHandler(t *testing.T, m *certMirror) http.Handler { |
| t.Helper() |
| - e := testIssuerEdge(mirrorHost) |
| + return gatedHTTPHandlerFor(t, testIssuerEdge(mirrorHost), m) |
| +} |
| + |
| +// gatedHTTPHandlerFor is gatedHTTPHandler over an edge the caller built, for |
| +// the same reason gatedChainFor exists. |
| +func gatedHTTPHandlerFor(t *testing.T, e *Edge, m *certMirror) http.Handler { |
| + t.Helper() |
| e.issuer = func() (context.Context, bool) { return nil, false } |
| mgr := offlineManager(t, autocert.DirCache(t.TempDir()), e.issuerHostPolicy()) |
| fallback := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| @@ -1669,3 +1681,173 @@ func TestChallengeFloodDoesNotStarveALPNRescue(t *testing.T) { |
| t.Fatalf("a :80 flood starved the 443 validation path: %v", err) |
| } |
| } |
| + |
| +// ---- #277: the three gates admit the deployment's exactly-named hosts ------- |
| +// |
| +// DNS for a home-boxed route can point at ANY edge. The edge it points at |
| +// backhauls to the home box with the real SNI, the home box (as issuer) |
| +// orders — and the CA then validates against the box DNS names, which is the |
| +// one whose registry does not carry the host. Bounding the gates by Allowed |
| +// meant every such validation failed, five failures burned the name's Let's |
| +// Encrypt per-hostname budget in minutes, and the route was down throughout. |
| +// |
| +// The gates now ask deploymentHost: this box's routes, OR an exact key of the |
| +// mirrored home map. Exact is the whole discipline — every test below that |
| +// says "wildcard label" is PR #180's rule standing on the new surface. |
| + |
| +// remoteHomedEdge is one box of a two-box deployment: it serves served from |
| +// its own registry, and mirrors an edges file in which box "betty" homes |
| +// homed (pass "*.<parent>" to mirror a catch-all row, which Task 1 publishes |
| +// verbatim). The handle it issues and mirrors through COUNTS bucket Gets, so |
| +// "did this gate spend a read-through?" is observable; the home map polls the |
| +// same directory through its own handle, so the map's own reads never show up |
| +// in that count. |
| +func remoteHomedEdge(t *testing.T, served []string, homed ...string) (*Edge, *bucketGetCounter, release.Store) { |
| + t.Helper() |
| + base := release.NewDirStore(t.TempDir()) |
| + counting := &bucketGetCounter{Store: base} |
| + e := testIssuerEdge(served...) |
| + e.staticStore = counting |
| + m := newHomeMap(base, "annie") |
| + homedOn(t, m, "betty", "2.2.2.2", homed...) |
| + e.homes.Store(m) |
| + return e, counting, base |
| +} |
| + |
| +const ( |
| + // remoteHomedHost is homed on the OTHER box: absent from this box's |
| + // registry, exactly named in its home map. |
| + remoteHomedHost = "app.remote.test" |
| + // remoteCatchAll is the catch-all row that box publishes, and |
| + // remoteWildcardLabel is a label under it — dialable through Home()'s |
| + // one-label rule, and admissible by none of the three cert gates. |
| + remoteCatchAll = "*.remote.test" |
| + remoteWildcardLabel = "invented.remote.test" |
| +) |
| + |
| +// HTTP-01, the failing half of #277: the CA arrives at a box that does not |
| +// serve the name, and the token is in the bucket. It is answered. |
| +func TestChallengeReadThroughAdmitsRemoteHomedHost(t *testing.T) { |
| + e, counting, base := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost) |
| + putBucket(t, base, mirrorTestToken+"+http-01", []byte("keyauth")) |
| + m := e.newCertMirror(nil) // production wiring: m.allowed = e.deploymentHost |
| + if m == nil { |
| + t.Fatal("a store-backed edge must have a mirror") |
| + } |
| + if e.Allowed(remoteHomedHost) { |
| + t.Fatal("test bug: the host is supposed to be absent from THIS box's registry") |
| + } |
| + got := challengeRequestHost(t, gatedHTTPHandlerFor(t, e, m), remoteHomedHost, mirrorTestToken) |
| + if got.Code != 200 || got.Body.String() != "keyauth" { |
| + t.Fatalf("challenge for a remote-homed host = %d %q, want 200 \"keyauth\"", got.Code, got.Body.String()) |
| + } |
| + if n := counting.gets(); n != 1 { |
| + t.Fatalf("bucket gets = %d, want exactly 1 — the live read-through", n) |
| + } |
| +} |
| + |
| +// ...and the anti-stranger bound the widening had to survive: a name in |
| +// neither the registry nor the map spends nothing at all, and neither does an |
| +// invented label under a MIRRORED catch-all (#180). |
| +func TestChallengeReadThroughStillRefusesStrangers(t *testing.T) { |
| + for _, host := range []string{"stranger.example", remoteWildcardLabel, remoteCatchAll} { |
| + t.Run(host, func(t *testing.T) { |
| + e, counting, base := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost, remoteCatchAll) |
| + putBucket(t, base, mirrorTestToken+"+http-01", []byte("keyauth")) |
| + m := e.newCertMirror(nil) |
| + if got := challengeRequestHost(t, gatedHTTPHandlerFor(t, e, m), host, mirrorTestToken); got.Code == 200 { |
| + t.Fatalf("%s was answered a live token body", host) |
| + } |
| + if n := counting.gets(); n != 0 { |
| + t.Fatalf("bucket gets = %d, want 0 — %s must never drive a bucket read", n, host) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// tls-alpn-01, the half autocert tries FIRST: the validation cert for another |
| +// edge's order is served for a remote-homed name. |
| +func TestMirrorALPNReadThroughAdmitsRemoteHomedHost(t *testing.T) { |
| + e, _, base := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost) |
| + putBucket(t, base, remoteHomedHost+"+token", hostCertPEM(t, remoteHomedHost, time.Now().Add(24*time.Hour))) |
| + m := e.newCertMirror(nil) |
| + get, _ := gatedChainFor(t, e, m, autocert.DirCache(t.TempDir())) |
| + // Deliberately NOT refreshed: a validation is live, and the poll is 30s. |
| + if _, err := get(alpnHello(remoteHomedHost)); err != nil { |
| + t.Fatalf("an edge DNS points at must answer the home box's tls-alpn-01 validation: %v", err) |
| + } |
| +} |
| + |
| +// The #180 pin on the 443 surface: SNI is attacker-chosen, and a mirrored |
| +// catch-all must not turn an invented label into a bucket read. |
| +func TestALPNReadThroughRefusesWildcardLabelUnderRemoteCatchAll(t *testing.T) { |
| + e, counting, base := remoteHomedEdge(t, []string{mirrorHost}, remoteCatchAll) |
| + putBucket(t, base, remoteWildcardLabel+"+token", hostCertPEM(t, remoteWildcardLabel, time.Now().Add(24*time.Hour))) |
| + if _, _, ok := e.homes.Load().Home(remoteWildcardLabel); !ok { |
| + t.Fatal("test bug: the label is supposed to be DIALABLE through the one-label rule") |
| + } |
| + m := e.newCertMirror(nil) |
| + get, _ := gatedChainFor(t, e, m, autocert.DirCache(t.TempDir())) |
| + if _, err := get(alpnHello(remoteWildcardLabel)); err == nil { |
| + t.Fatal("a label under a mirrored catch-all must not be served a token cert") |
| + } |
| + if n := counting.gets(); n != 0 { |
| + t.Fatalf("bucket gets = %d, want 0 — the label must not spend the ALPN budget", n) |
| + } |
| +} |
| + |
| +// The order gate: the issuer mints for any exactly-named route in the |
| +// deployment, whichever box homes it. Without this the home-boxed route can |
| +// only ever mint while the housekeeper lease sits on its own box. |
| +func TestIssuerOrdersForRemoteHomedHost(t *testing.T) { |
| + e, _, _ := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost) |
| + e.issuer = func() (context.Context, bool) { return context.Background(), true } |
| + if err := e.issuerHostPolicy()(context.Background(), remoteHomedHost); err != nil { |
| + t.Fatalf("HostPolicy for a remote-homed host = %v, want nil", err) |
| + } |
| + if err := e.issuerHostPolicy()(context.Background(), mirrorHost); err != nil { |
| + t.Fatalf("HostPolicy for this box's own route = %v, want nil", err) |
| + } |
| +} |
| + |
| +// The #180 pin on the order gate, and the one that matters most: an |
| +// attacker-chosen label must never drive a fresh per-host ACME order against |
| +// the deployment's single shared account and rate-limit bucket. |
| +func TestHostPolicyRefusesWildcardLabelUnderRemoteCatchAll(t *testing.T) { |
| + e, _, _ := remoteHomedEdge(t, []string{mirrorHost}, remoteCatchAll) |
| + e.issuer = func() (context.Context, bool) { return context.Background(), true } |
| + for _, host := range []string{remoteWildcardLabel, remoteCatchAll, "stranger.example"} { |
| + err := e.issuerHostPolicy()(context.Background(), host) |
| + if err == nil { |
| + t.Fatalf("%s: HostPolicy allowed an order", host) |
| + } |
| + if !strings.Contains(err.Error(), "not in registry") { |
| + t.Fatalf("%s: HostPolicy = %v, want the unchanged not-in-registry refusal", host, err) |
| + } |
| + } |
| +} |
| + |
| +// Widening the NAMES a policy admits does not widen the BOXES that may order: |
| +// a non-issuer still refuses every name, remote-homed ones included. |
| +func TestHostPolicyStillGatesNonIssuer(t *testing.T) { |
| + e, _, _ := remoteHomedEdge(t, []string{mirrorHost}, remoteHomedHost) |
| + e.issuer = func() (context.Context, bool) { return nil, false } |
| + for _, host := range []string{remoteHomedHost, mirrorHost} { |
| + if err := e.issuerHostPolicy()(context.Background(), host); !errors.Is(err, errNotIssuer) { |
| + t.Fatalf("%s on a non-issuer = %v, want errNotIssuer", host, err) |
| + } |
| + } |
| +} |
| + |
| +// Single-box deployments are byte-for-byte unchanged: no home map armed (or an |
| +// empty one), so deploymentHost reduces to Allowed. |
| +func TestDeploymentHostIsAllowedOnASingleBoxDeployment(t *testing.T) { |
| + e := testIssuerEdge(mirrorHost) // e.homes never stored: nil pointer |
| + if !e.deploymentHost(mirrorHost) || e.deploymentHost("stranger.example") { |
| + t.Fatal("with no home map at all, deploymentHost must be exactly Allowed") |
| + } |
| + e2, _, _ := remoteHomedEdge(t, []string{mirrorHost}) // map armed, folds to nothing |
| + if !e2.deploymentHost(mirrorHost) || e2.deploymentHost("stranger.example") { |
| + t.Fatal("with an empty home map, deploymentHost must be exactly Allowed") |
| + } |
| +} |