carlos / platform Public

edge: fix Task 4 review findings — HostPolicy bound, delegated-source cache

CRITICAL-1: Allowed() (autocert HostPolicy) reverts to the exact map
lookup instead of lookupUpstream's wildcard fallback. Widening it let
any stranger's SNI <label>.<catch-all domain> pass HostPolicy and drive
a fresh per-host ACME order whenever the wildcard cert wasn't yet
obtained (the normal state right after an attach) — unbounded,
attacker-chosen labels against the box's one shared ACME account/quota.
ServeHTTP keeps lookupUpstream; only request routing gets the wildcard
fallback, never cert issuance policy.

CRITICAL-2: TestHostPolicyExcludesWildcardFallback pins the split —
request routing for a wildcard-alias host still resolves, but Allowed()
refuses it until it has its own exact row.

IMPORTANT-1: newWildcardFromEnv's delegated closure now caches the last
successful ListWildcardDomains() result and returns it on a read error,
instead of returning nil. refresh() only runs at startup and on
OnReload (run()'s 12h ticker never re-invokes the delegated source), so
returning nil on a transient error emptied the delegated set until an
unrelated registry write next moved the fingerprint — unbounded on a
quiet box, and it also stopped certFor from serving an already-cached,
still-valid delegated wildcard. Extended TestDelegatedSourceReadsRegistry
to close the registry after a successful read and assert the closure
still returns the last-good list.

Also: mended registry.go's Fingerprint doc comment (a stray mid-sentence
line break from the wildcard_domains rewrap).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e925343af44c66d0cc5de5db5ea8fd17bb3807bc parent 17c3a2c
5 files changed, +126 −24
  • internal/edge/alias_test.go +60 −0
  • internal/edge/edge.go +26 −15
  • internal/edge/wildcard.go +20 −5
  • internal/edge/wildcard_test.go +17 −0
  • internal/registry/registry.go +3 −4
diff --git a/internal/edge/alias_test.go b/internal/edge/alias_test.go
index 07429a114..c872bf05e 100644
--- a/internal/edge/alias_test.go
+++ b/internal/edge/alias_test.go
@@ -372,3 +372,63 @@ func TestWildcardAliasRequestRouting(t *testing.T) {
t.Fatalf("lookupUpstream(paul.woodstar.app) route.Host = %q, want the EXACT alias's primary other.bab.oncarlos.com", up.route.Host)
}
}
+
+// TestHostPolicyExcludesWildcardFallback is the CRITICAL-1 review fix: the
+// autocert HostPolicy (Allowed) must stay on the EXACT map only, never
+// lookupUpstream's wildcard fallback. A catch-all alias ("*.<domain>") gets
+// its TLS coverage from the WILDCARD cert (wrapGetCertificate checks certFor
+// before ever reaching autocert) — Allowed only gates a NEW per-host ACME
+// order, which is unbounded (attacker-chosen labels) and shared across every
+// tenant on the box (one ACME account). Before this fix, a stranger's SNI
+// random.woodstar.app would pass Allowed and drive a fresh per-host order
+// for as long as the wildcard cert wasn't yet obtained — the normal state
+// right after an attach, before the customer's CNAME exists.
+//
+// Request-time routing (lookupUpstream, unaffected by this fix) must still
+// resolve the same one-label wildcard host — the policy split is HostPolicy
+// vs. serving, not a routing regression.
+func TestHostPolicyExcludesWildcardFallback(t *testing.T) {
+ backend, _ := hostEchoBackend(t, "primary")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{
+ Host: "woodstar-relay.bab.oncarlos.com", Kind: "service",
+ Addr: backend.Listener.Addr().String(), Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ upsertAlias(t, reg, "*.woodstar.app", "woodstar-relay.bab.oncarlos.com")
+
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ // (a) Request routing is unchanged: the wildcard-alias host still
+ // resolves at request time.
+ if _, ok := e.lookupUpstream("paul.woodstar.app"); !ok {
+ t.Fatalf("lookupUpstream(paul.woodstar.app) missed — routing must still resolve via the wildcard alias")
+ }
+
+ // (b) HostPolicy refuses ANY one-label host under the catch-all that
+ // isn't an exact route/alias row of its own — only exact hosts are
+ // certable via the per-host path.
+ if e.Allowed("random.woodstar.app") {
+ t.Fatalf("Allowed(random.woodstar.app) = true, want false — HostPolicy must not follow the wildcard fallback (unbounded per-host ACME orders from attacker-chosen SNI)")
+ }
+ if e.Allowed("paul.woodstar.app") {
+ t.Fatalf("Allowed(paul.woodstar.app) = true, want false — same rule, no exact row for this host either")
+ }
+
+ // An EXACT alias row is still certable, same as any other routed host.
+ upsertAlias(t, reg, "paul.woodstar.app", "woodstar-relay.bab.oncarlos.com")
+ if err := e.Reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ if !e.Allowed("paul.woodstar.app") {
+ t.Fatalf("Allowed(paul.woodstar.app) = false, want true once it has its OWN exact alias row")
+ }
+ // The still-wildcard-only host stays refused.
+ if e.Allowed("random.woodstar.app") {
+ t.Fatalf("Allowed(random.woodstar.app) = true, want false — still no exact row for it")
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index a64db5841..f173c8eed 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -543,24 +543,35 @@ func (e *Edge) Allowed(host string) bool {
if e.tunnels != nil && host == e.tunnels.Host() {
return true
}
- _, ok := e.lookupUpstream(host)
+ // Deliberately the EXACT map only — not lookupUpstream's wildcard
+ // fallback. A catch-all alias ("*.<domain>") is a bounded, registry-backed
+ // name; autocert's per-host HostPolicy gates a NEW per-host ACME order,
+ // which is unbounded (labels are attacker-chosen) and shared across every
+ // tenant on the box (one ACME account, one rate-limit bucket). Coverage
+ // for a one-label host under a catch-all comes from the WILDCARD cert
+ // (wrapGetCertificate calls certFor before ever reaching autocert); this
+ // exact check only decides per-host HTTP-01 fallback for ROUTED hosts —
+ // widening it to the wildcard fallback would let a stranger's SNI
+ // <anything>.<domain> drive a fresh order and exhaust the box's shared
+ // ACME quota for every tenant on it.
+ _, ok := e.current()[host]
return ok
}
-// lookupUpstream resolves host to its upstream entry — the single lookup
-// the serve path (and Allowed's autocert HostPolicy) uses. An exact match
-// (a real route, or a plain #151 alias landed in the same map) always wins;
-// on a miss, a one-label wildcard key folds in: "*.<parent>" is the shape
-// Task 3's reconcile pass derives from a claim's catch-all row, and it lands
-// in the SAME map as every other alias (up.alias = true, primary's route +
-// proxy verbatim) so this fallback resolves to the primary's own entry — the
-// identical map value the #151/#152 agreed rule requires, not a value that
-// merely behaves the same. A wildcard matches exactly one label, mirroring
-// the TLS cert (wildcard.go's certFor): host[i:] keeps the LEADING dot of
-// the FIRST label only, so a two-label host (a.b.woodstar.app) composes
-// "*.b.woodstar.app" and misses, and the apex itself (woodstar.app) composes
-// "*.app" — never its own "*.woodstar.app" — so it can never match its own
-// wildcard.
+// lookupUpstream resolves host to its upstream entry — the request-routing
+// lookup ServeHTTP uses (NOT Allowed's autocert HostPolicy — see the comment
+// there). An exact match (a real route, or a plain #151 alias landed in the
+// same map) always wins; on a miss, a one-label wildcard key folds in:
+// "*.<parent>" is the shape Task 3's reconcile pass derives from a claim's
+// catch-all row, and it lands in the SAME map as every other alias
+// (up.alias = true, primary's route + proxy verbatim) so this fallback
+// resolves to the primary's own entry — the identical map value the
+// #151/#152 agreed rule requires, not a value that merely behaves the same.
+// A wildcard matches exactly one label, mirroring the TLS cert: host[i:]
+// keeps the LEADING dot of the FIRST label only, so a two-label host
+// (a.b.woodstar.app) composes "*.b.woodstar.app" and misses, and the apex
+// itself (woodstar.app) composes "*.app" — never its own "*.woodstar.app" —
+// so it can never match its own wildcard.
func (e *Edge) lookupUpstream(host string) (upstream, bool) {
m := e.current()
if up, ok := m[host]; ok {
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go
index dcb09200f..1d013f2b6 100644
--- a/internal/edge/wildcard.go
+++ b/internal/edge/wildcard.go
@@ -625,20 +625,35 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
}
// Delegated source: the claim-derivation pass (Task 3, internal/domains)
// writes wildcard_domains; refresh() reads it fresh on every call, same
- // as e.currentHosts above. A read error leaves the delegated set empty
- // for this pass only — refresh() runs again on the next reload or 12h
- // tick, so a transient registry hiccup never wedges delegation off for
- // good, it just costs one pass of visibility (logged below).
+ // as e.currentHosts above. A read error returns the LAST successful
+ // list, not nil/empty: refresh() only runs on startup and on OnReload
+ // (run()'s 12h ticker never re-invokes the delegated source itself), so
+ // on a quiet box a transient error would otherwise empty the delegated
+ // set until some unrelated registry write next moves the fingerprint —
+ // unbounded, and it would also stop parentsFor from including a parent
+ // whose cert is already cached and valid (certFor iterates w.parents).
+ // Caching here is what makes the log line's "unchanged this pass"
+ // literally true.
+ var (
+ mu sync.Mutex
+ lastGood []string
+ )
delegated := func() []string {
wds, err := e.reg.ListWildcardDomains()
if err != nil {
+ mu.Lock()
+ out := lastGood
+ mu.Unlock()
log.Printf("edge: wildcard domains read failed: %v — delegated set unchanged this pass", err)
- return nil
+ return out
}
out := make([]string, 0, len(wds))
for _, w := range wds {
out = append(out, w.Host)
}
+ mu.Lock()
+ lastGood = out
+ mu.Unlock()
return out
}
return newWildcardManager(static, appsDomain, e.currentHosts, delegated, zone, cache, dns)
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go
index 9b8baeb64..eead55f42 100644
--- a/internal/edge/wildcard_test.go
+++ b/internal/edge/wildcard_test.go
@@ -585,6 +585,23 @@ func TestDelegatedSourceReadsRegistry(t *testing.T) {
t.Errorf("isDelegated(%s) = false, want true", host)
}
}
+
+ // IMPORTANT-1 (review fix): a read error after a successful read must
+ // return the LAST-GOOD list, not empty it out — refresh() only runs at
+ // startup and on OnReload (run()'s 12h ticker never re-invokes the
+ // delegated source), so on a quiet box an emptied set would stay empty
+ // until some unrelated write next moved the registry fingerprint, and
+ // certFor would stop serving an already-cached, still-valid wildcard for
+ // every delegated parent in the meantime.
+ if err := reg.Close(); err != nil {
+ t.Fatalf("close registry: %v", err)
+ }
+ got := w.delegated()
+ want := []string{"example.net", "woodstar.app"}
+ slices.Sort(got)
+ if !slices.Equal(got, want) {
+ t.Fatalf("delegated() after a read error = %v, want the last-good list %v", got, want)
+ }
}
// TestRefreshDropsOverlongChallengeNames pins sub-change 2: a delegated host
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index ade5e24a8..250ca1721 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -1323,10 +1323,9 @@ func (r *Registry) DeleteWildcardDomain(host string) error {
// Fingerprint summarizes the routes, fleets, fleet_boxes, route_aliases and
// wildcard_domains tables over a fresh connection, for the edge's cheap
-// change poll. It is
-// content-accurate: it hashes the actual VALUES of the reload-relevant
-// columns, not a
-// length-weighted aggregate, so two DIFFERENT values of the SAME length
+// change poll. It is content-accurate: it hashes the actual VALUES of the
+// reload-relevant columns, not a length-weighted aggregate, so two
+// DIFFERENT values of the SAME length
// (channel canary/1 -> canary/2, a git short-sha version bump, a rotated
// sha256 token hash, a failover restamp box-a -> box-b) change the result
// exactly like any other write. `grants` stays deliberately excluded