carlos / platform Public

fix: retired console origins join the domains denylist (#151)

deniedDomains() derived the origin host and the apps domain but not
CONSOLE_REDIRECT_HOSTS — the retired console origins this console still
answers on and 301s to Origin (tito's carlos.tito.io). NewServer refuses a
self-redirect, so those entries are guaranteed distinct from the origin
host and nothing else in the list covered them: a tenant could attach a
console address that operators, bookmarks and old CLI credentials still
reach. Route-wins ordering on a box is not the answer — it is per-box, and
stops being protection the moment a second box materializes the alias.

Each entry goes through the same normalizeHost (lowercase, port stripped)
that ServeHTTP's retired-host check uses, so the two readings of
CONSOLE_REDIRECT_HOSTS cannot disagree.

TestAPIDomainAttachValidation configures a retired origin carrying mixed
case and a port, and refuses both it and a host under it with the same 400
the origin host gets, writing no claim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3950833316eb83be31ca63e0ab22c93353905936 parent a60f309
2 files changed, +37 −16
  • internal/console/apidomains.go +31 −14
  • internal/console/apidomains_test.go +6 −2
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index c957f1fcd..ca4ad69d9 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -43,25 +43,37 @@ import (
)
// deniedDomains is the deny list every attach is checked against: the
-// operator's own CONSOLE_DENY_DOMAINS entries, plus the two names this
+// operator's own CONSOLE_DENY_DOMAINS entries, plus the three names this
// console can derive without being told (spec §5: "all derived from
// console/deployment config, not hardcoded").
//
-// The derived two are not optional and not the operator's to forget. The
-// ORIGIN host is the console itself — an alias there would point the door's
-// own hostname at a tenant's route. The APPS DOMAIN is the whole platform
-// apex namespace (<app>.<sqid>.<apps-domain>): every app's public URL lives
-// under it, and an alias claim there would be one tenant claiming another
-// tenant's address — the same sibling-apex hazard handleAPIInstanceCreate
-// refuses within an account, here refused across the deployment. Hosts under
-// the apps domain are what `carlos instances` exists for; this door is for
-// hostnames the caller's own DNS controls.
+// The derived three are not optional and not the operator's to forget:
//
-// Parsed per call rather than cached at boot: it is one url.Parse of a short
-// string on a door that then does bucket I/O, and it stays honest for an
-// embedder (or a test) that sets Origin after NewServer.
+// - the ORIGIN host is the console itself — an alias there would point the
+// door's own hostname at a tenant's route;
+// - the APPS DOMAIN is the whole platform apex namespace
+// (<app>.<sqid>.<apps-domain>): every app's public URL lives under it,
+// and a claim there would be one tenant claiming another tenant's
+// address — the sibling-apex hazard handleAPIInstanceCreate refuses
+// within an account, refused here across the deployment. Hosts under the
+// apps domain are what `carlos instances` exists for; this door is for
+// hostnames the caller's own DNS controls;
+// - the REDIRECT HOSTS are retired console origins (CONSOLE_REDIRECT_HOSTS
+// — tito's carlos.tito.io), which this console still answers on and
+// 301s to Origin. They are guaranteed distinct from the origin host
+// (NewServer refuses a self-redirect), so nothing else in this list
+// covers them, and a tenant claiming one would be claiming a console
+// address that operators, bookmarks and old CLI credentials still reach.
+// Route-wins ordering on a box is not the answer: it is per-box, and
+// stops being protection the moment a second box materializes the alias.
+//
+// Derived per call rather than cached at boot — one url.Parse and a short
+// copy on a door that then does bucket I/O — so it stays honest for an
+// embedder (or a test) that sets any of these after NewServer. The redirect
+// entries go through the same normalizeHost ServeHTTP's retired-host check
+// uses, so the two readings of CONSOLE_REDIRECT_HOSTS cannot disagree.
func (s *Server) deniedDomains() []string {
- deny := make([]string, 0, len(s.cfg.DenyDomains)+2)
+ deny := make([]string, 0, len(s.cfg.DenyDomains)+len(s.cfg.RedirectHosts)+2)
deny = append(deny, s.cfg.DenyDomains...)
if u, err := url.Parse(s.cfg.Origin); err == nil {
if h := strings.ToLower(u.Hostname()); h != "" {
@@ -71,6 +83,11 @@ func (s *Server) deniedDomains() []string {
if d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); d != "" {
deny = append(deny, d)
}
+ for _, h := range s.cfg.RedirectHosts {
+ if h = normalizeHost(h); h != "" {
+ deny = append(deny, h)
+ }
+ }
return deny
}
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
index 38316c041..b5462cc23 100644
--- a/internal/console/apidomains_test.go
+++ b/internal/console/apidomains_test.go
@@ -207,12 +207,14 @@ func TestAPIDomainAttachConflictNeutral(t *testing.T) {
// TestAPIDomainAttachValidation: every hostname this door refuses to take,
// each with nothing written behind it — malformed, bare-label, under a
-// configured deny domain, under the deployment's apps domain, and the
-// console's own origin host.
+// configured deny domain, under the deployment's apps domain, the console's
+// own origin host, and a RETIRED console origin (CONSOLE_REDIRECT_HOSTS,
+// which this console still answers on and 301s away — tito's carlos.tito.io).
func TestAPIDomainAttachValidation(t *testing.T) {
fx, member := instanceFixture(t, "code-dom-validate")
fx.srv.cfg.DenyDomains = []string{"", "denied.test"}
fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.RedirectHosts = []string{"", "Carlos.Tito.Test:8443"}
origin, err := url.Parse(fx.srv.cfg.Origin)
if err != nil {
t.Fatal(err)
@@ -224,6 +226,8 @@ func TestAPIDomainAttachValidation(t *testing.T) {
"denied.test", // the deny entry itself
"one.bab.oncarlos.test", // platform apex space (spec §5)
origin.Hostname(), // the console's own origin host
+ "carlos.tito.test", // a retired console origin, normalized as ServeHTTP normalizes it
+ "old.carlos.tito.test", // and anything under it
} {
code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
attachBody(host, "go.tito.io"))