domains: reserving platform domains is opt-in (CONSOLE_RESERVE_PLATFORM_DOMAINS)
The /domains door always refused everything under PLATFORM_APPS_DOMAIN and under the console's own hostnames. That rule protects tenants on a deployment that hosts apps for other people (the flagship, carloku). On a self-hosted deployment, one person owns the whole zone, and the rule refused ordinary names such as blog.<console-domain>. Almost every self-hoster meets it. The subtree rules now apply only with CONSOLE_RESERVE_PLATFORM_DOMAINS=true. Every deployment still refuses: - the console's origin host and its retired redirect hosts, by exact name (an alias there points the console's own address at an app); - CONSOLE_DENY_DOMAINS and the tunnel host, with their subtrees; - a hostname another app's route already holds (deploymentRouteTaken). A value that is not a boolean stops the console at boot. ROLLOUT: a shared-platform console must set CONSOLE_RESERVE_PLATFORM_DOMAINS=true BEFORE it runs this build. Without it, tenants can claim unused names under the apps domain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 files changed,
+208
−61
README.md+7 −0cmd/console/main.go+25 −4docs/site/cli.md+8 −0internal/console/apidomains.go+74 −38internal/console/apidomains_test.go+57 −13internal/console/domainattach.go+2 −2internal/console/domainattach_test.go+23 −0internal/console/server.go+12 −4
CI failed — run details
diff --git a/README.md b/README.md| index e58ae5774..fb49dafa2 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -340,6 +340,13 @@ publish an inflated instance count. |
| **Unset = the whole feature is inert**: no minting, no manifests read, |
| the enable door answers 503 — the same litestream convention as |
| everything else here. |
| +- `CONSOLE_RESERVE_PLATFORM_DOMAINS` — set to `true` on a deployment that |
| + hosts apps for other people (the flagship, carloku). `carlos domains |
| + attach` then refuses every name under `PLATFORM_APPS_DOMAIN` and under the |
| + console's own hostname. **Unset = false**: a self-hosted deployment can |
| + attach any name except the console's own hostname and |
| + `CONSOLE_DENY_DOMAINS`. A value that is not a boolean stops the console at |
| + boot. |
| - `PLATFORM_EDGE_IPS` — comma-separated edge IPs the DNS health checks |
| compare against, read by both the agent (checks) and the console |
| (guidance text), so the two can never name different addresses. Default: |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 0205bf4eb..f1616bfcf 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -281,6 +281,7 @@ func serve() { |
| SteeringHost: steeringHost(), |
| RedirectHosts: redirectHosts(), |
| DenyDomains: denyDomains(), |
| + ReservePlatformDomains: reservePlatformDomains(), |
| StoreProvisioner: storeProvisioner, |
| StoreScanner: storeScanner, |
| ScanSeedFromDeprecatedEnv: seedAntivirus, |
| @@ -801,14 +802,34 @@ func redirectHosts() []string { |
| return out |
| } |
| +// reservePlatformDomains parses CONSOLE_RESERVE_PLATFORM_DOMAINS, the opt-in |
| +// for a deployment that hosts apps for other people (the flagship, carloku). |
| +// When it is set, `carlos domains attach` also refuses everything under |
| +// PLATFORM_APPS_DOMAIN and under the console's own hostnames |
| +// (console.Config.ReservePlatformDomains). Unset means false. A value that |
| +// strconv.ParseBool cannot read stops the boot. A typo in a security setting |
| +// must not run the console as if the setting were off. |
| +func reservePlatformDomains() bool { |
| + v := strings.TrimSpace(os.Getenv("CONSOLE_RESERVE_PLATFORM_DOMAINS")) |
| + if v == "" { |
| + return false |
| + } |
| + b, err := strconv.ParseBool(v) |
| + if err != nil { |
| + log.Fatalf("console: CONSOLE_RESERVE_PLATFORM_DOMAINS %q is not a boolean (use true or false)", v) |
| + } |
| + return b |
| +} |
| + |
| // denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and |
| // everything under them, that `carlos domains attach` refuses (issue #151). |
| // This is the operator's list of names the console cannot derive on its own: |
| // retired console origins, any other platform-managed domain. The edge's |
| -// reserved tunnel hostname is folded in below from CARLOS_TUNNEL_HOST; the |
| -// console's own origin host and PLATFORM_APPS_DOMAIN are added by the door |
| -// itself (deniedDomains, internal/console/apidomains.go), so an unset |
| -// variable is a legal — and still safe — deployment. Blank entries are dropped: an empty string in the |
| +// reserved tunnel hostname is folded in below from CARLOS_TUNNEL_HOST. The |
| +// door itself adds the console's own hostnames, and, with |
| +// CONSOLE_RESERVE_PLATFORM_DOMAINS, PLATFORM_APPS_DOMAIN (hostDenied, |
| +// internal/console/apidomains.go). An unset variable is therefore a legal |
| +// deployment. Blank entries are dropped: an empty string in the |
| // list would be a deny entry no hostname could avoid matching. |
| func denyDomains() []string { |
| var out []string |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 0963b364d..a76238c8e 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -551,6 +551,14 @@ that route at its next domains pass, certificate and all. The `list` verb |
| joins the claims to the fleet's own readings sweep, so DNS state, certificate |
| expiry and delegation are readable without leaving the terminal. |
| +The console always refuses its own hostname. By default it accepts every |
| +other name, including names under the console's domain and under the apps |
| +domain. That fits a self-hosted deployment, where one person owns the whole |
| +zone. A deployment that hosts apps for other people sets |
| +`CONSOLE_RESERVE_PLATFORM_DOMAINS=true` on the console. Then the console also |
| +refuses every name under the apps domain and under its own hostname, because |
| +those names belong to the operator and not to a tenant. |
| + |
| ### carlos features |
| Sets or lists an app's own feature flags — switches the app defines, the |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 36a809882..2d9a48fab 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -41,6 +41,7 @@ import ( |
| "net/http" |
| "net/url" |
| "os" |
| + "slices" |
| "sort" |
| "strconv" |
| "strings" |
| @@ -54,54 +55,89 @@ import ( |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| -// deniedDomains is the deny list every attach is checked against: the |
| -// operator's CONSOLE_DENY_DOMAINS entries (cmd/console folds the tunnel |
| -// hostname into those), plus the names this console can derive without |
| -// being told (spec §5: "all derived from |
| -// console/deployment config, not hardcoded"). |
| +// hostDenied reports whether an attach of host is refused as |
| +// platform-managed. Two lists feed it, and they match differently: |
| // |
| -// The derived three are not optional and not the operator's to forget: |
| +// - deniedDomains: each entry AND every host under it; |
| +// - consoleHosts: the entry itself only. |
| // |
| -// - 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. |
| +// Both /domains doors (the CLI API door and the console form's |
| +// attachDomain) ask this one question, so the two cannot drift. |
| +func (s *Server) hostDenied(host string) bool { |
| + return domains.HostDenied(host, s.deniedDomains()) || slices.Contains(s.consoleHosts(), host) |
| +} |
| + |
| +// consoleHosts are the hostnames this console itself answers on: the ORIGIN |
| +// host, plus the REDIRECT HOSTS (CONSOLE_REDIRECT_HOSTS: retired console |
| +// origins such as tito's carlos.tito.io, which this console still answers on |
| +// and 301s to Origin). Every deployment refuses these whether or not it |
| +// reserves platform domains. The reason is self-harm, not tenant isolation: |
| +// an alias on one of these names points the console's own address at an |
| +// app. Route-wins ordering on a box does not prevent that. It is per-box, |
| +// and it stops protecting the name when 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)+len(s.cfg.RedirectHosts)+2) |
| - deny = append(deny, s.cfg.DenyDomains...) |
| +// Only the names themselves are refused here. Everything under them is |
| +// refused only with ReservePlatformDomains (see deniedDomains). A |
| +// self-hosted console at carlos.example.com whose apps live at |
| +// <app>.carlos.example.com must be able to attach blog.carlos.example.com. |
| +// |
| +// The redirect entries go through the same normalizeHost that ServeHTTP's |
| +// retired-host check uses, so the two readings of CONSOLE_REDIRECT_HOSTS |
| +// cannot disagree. NewServer refuses a redirect host equal to the origin. |
| +func (s *Server) consoleHosts() []string { |
| + hosts := make([]string, 0, len(s.cfg.RedirectHosts)+1) |
| if u, err := url.Parse(s.cfg.Origin); err == nil { |
| if h := strings.ToLower(u.Hostname()); h != "" { |
| - deny = append(deny, h) |
| + hosts = append(hosts, h) |
| } |
| } |
| - 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) |
| + hosts = append(hosts, h) |
| } |
| } |
| - return deny |
| + return hosts |
| +} |
| + |
| +// deniedDomains are the entries whose whole subtree the /domains door |
| +// refuses. The list always holds the operator's CONSOLE_DENY_DOMAINS entries |
| +// (cmd/console adds the tunnel hostname to those). An operator who lists a |
| +// name has chosen to refuse it, so the list applies on every deployment. |
| +// |
| +// With ReservePlatformDomains set (CONSOLE_RESERVE_PLATFORM_DOMAINS) the |
| +// console also adds the names it can derive from its own config (spec §5: |
| +// "all derived from console/deployment config, not hardcoded"). These are |
| +// the rules for a platform that hosts apps for OTHER people, such as the |
| +// flagship or carloku: |
| +// |
| +// - the APPS DOMAIN is the whole platform namespace |
| +// (<app>.<sqid>.<apps-domain>). Every app's public URL is under it, so a |
| +// claim there is one tenant taking another tenant's address. This is the |
| +// sibling-apex hazard that handleAPIInstanceCreate refuses within an |
| +// account, refused here across the deployment. Hosts under the apps |
| +// domain are what `carlos instances` is for; |
| +// - everything UNDER the console's own hostnames (consoleHosts). On a |
| +// shared platform those names belong to the operator, not to a tenant. |
| +// |
| +// Without the setting, none of these are added. That is the default because |
| +// a self-hosted deployment usually puts its console, its apps domain and |
| +// its custom domains in ONE zone that ONE person owns, and the reserved |
| +// rules refused ordinary names there. Existing routes are still safe |
| +// without it: deploymentRouteTaken refuses a hostname that another app's |
| +// route already uses. |
| +// |
| +// Derived per call rather than cached at boot: one url.Parse and a short |
| +// copy, on a door that then does bucket I/O. This keeps it correct for an |
| +// embedder (or a test) that changes the config after NewServer. |
| +func (s *Server) deniedDomains() []string { |
| + deny := slices.Clone(s.cfg.DenyDomains) |
| + if !s.cfg.ReservePlatformDomains { |
| + return deny |
| + } |
| + if d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); d != "" { |
| + deny = append(deny, d) |
| + } |
| + return append(deny, s.consoleHosts()...) |
| } |
| // resolveAliasRoute decides which of the app's routes a claim points at. |
| @@ -276,7 +312,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| "invalid hostname — attach a full hostname like tickets.example.com") |
| return |
| } |
| - if domains.HostDenied(host, s.deniedDomains()) { |
| + if s.hostDenied(host) { |
| jsonError(w, http.StatusBadRequest, "that hostname is platform-managed") |
| return |
| } |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index 909d1f34b..a8088c186 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -248,29 +248,34 @@ 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, 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). |
| +// TestAPIDomainAttachValidation: every hostname this door refuses on a |
| +// deployment that reserves platform domains (the flagship, carloku), each |
| +// with nothing written behind it: malformed, bare-label, under a configured |
| +// deny domain, under the deployment's apps domain, the console's own origin |
| +// host and a name under it, and a RETIRED console origin |
| +// (CONSOLE_REDIRECT_HOSTS, which this console still answers on and 301s |
| +// away, such as 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"} |
| + fx.srv.cfg.ReservePlatformDomains = true |
| origin, err := url.Parse(fx.srv.cfg.Origin) |
| if err != nil { |
| t.Fatal(err) |
| } |
| for _, host := range []string{ |
| - "Bad_Host!", // not a hostname at all |
| - "nodots", // a bare label is never a public hostname |
| - "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry |
| - "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 |
| + "Bad_Host!", // not a hostname at all |
| + "nodots", // a bare label is never a public hostname |
| + "x.denied.test", // under a CONSOLE_DENY_DOMAINS entry |
| + "denied.test", // the deny entry itself |
| + "one.bab.oncarlos.test", // platform apex space (spec §5) |
| + "oncarlos.test", // the apps domain itself |
| + origin.Hostname(), // the console's own origin host |
| + "x." + origin.Hostname(), // and anything under it |
| + "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")) |
| @@ -286,6 +291,45 @@ func TestAPIDomainAttachValidation(t *testing.T) { |
| } |
| } |
| +// TestAPIDomainAttachUnreservedDefault: ReservePlatformDomains is off by |
| +// default, which is the self-hosted shape. There the console, the apps |
| +// domain and the custom domains usually share one zone that one person |
| +// owns. The console's own hostnames and the operator's CONSOLE_DENY_DOMAINS |
| +// are still refused. The apps domain, and names under the console's |
| +// hostnames, are not. |
| +func TestAPIDomainAttachUnreservedDefault(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-unreserved") |
| + origin, err := url.Parse(fx.srv.cfg.Origin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.DenyDomains = []string{"denied.test"} |
| + fx.srv.cfg.AppsDomain = origin.Hostname() // the console sits at the apps-domain apex |
| + fx.srv.cfg.RedirectHosts = []string{"Carlos.Tito.Test:8443"} |
| + |
| + for _, host := range []string{ |
| + "x.denied.test", // the operator's own list still applies |
| + origin.Hostname(), // the console's own origin host |
| + "carlos.tito.test", // a retired console origin |
| + } { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| + attachBody(host, "go.tito.io")) |
| + if code != http.StatusBadRequest { |
| + t.Errorf("attach %q = %d %s, want 400", host, code, out) |
| + } |
| + claimAbsent(t, fx, host) |
| + } |
| + for _, host := range []string{ |
| + "blog." + origin.Hostname(), // under the console host AND the apps domain |
| + "one.bab." + origin.Hostname(), // platform-shaped, but no route holds it |
| + "old.carlos.tito.test", // under a retired console origin |
| + } { |
| + if got := attachDomain(t, fx, member, host, "go.tito.io"); !got.Created { |
| + t.Errorf("attach %q unreserved = %+v, want created true", host, got) |
| + } |
| + } |
| +} |
| + |
| // TestAPIDomainAttachRouteDefault: the route may be omitted when there is |
| // exactly one thing it could mean. Two instance records make it ambiguous, |
| // and ambiguity names the flag that resolves it rather than guessing. |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index d71f53f2f..5532080fd 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -104,14 +104,14 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin |
| } |
| // Policy first: shape and platform space are decided before any write, |
| // so a refused host never reaches a bucket key. Both checks are #151's |
| - // exported validation surface (internal/domains + deniedDomains), the |
| + // exported validation surface (internal/domains + hostDenied), the |
| // same pair the CLI API door runs — one validation spot, two doors. |
| host, ok := domains.NormalizeAliasHost(host) |
| if !ok { |
| return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| "not a valid hostname — attach a full hostname like tickets.example.com"} |
| } |
| - if domains.HostDenied(host, s.deniedDomains()) { |
| + if s.hostDenied(host) { |
| return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| "that hostname is platform-managed"} |
| } |
diff --git a/internal/console/domainattach_test.go b/internal/console/domainattach_test.go| index 246924e41..85ad9b044 100644 |
| --- a/internal/console/domainattach_test.go |
| +++ b/internal/console/domainattach_test.go |
| @@ -16,6 +16,7 @@ package console |
| import ( |
| "context" |
| "errors" |
| + "net/url" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -613,6 +614,7 @@ func TestAttachHeldHostIsNeutral409(t *testing.T) { |
| func TestAttachPlatformSpaceRefused(t *testing.T) { |
| fx, acct := attachFixture(t) |
| + fx.srv.cfg.ReservePlatformDomains = true |
| shipVersion(t, fx, "story", "v1") |
| seedChannel(t, fx, "story", "canary/live", "v1") |
| @@ -632,6 +634,27 @@ func TestAttachPlatformSpaceRefused(t *testing.T) { |
| } |
| } |
| +// TestAttachPlatformSpaceOpenByDefault: without ReservePlatformDomains, the |
| +// form door (attachDomain) accepts a name under the apps domain. This is |
| +// the self-hosted case. Only the console's own hostname is still refused. |
| +func TestAttachPlatformSpaceOpenByDefault(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + |
| + const host = "blog.oncarlos.com" |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil { |
| + t.Fatalf("attach %q under the apps domain, unreserved: refused %+v", host, ref) |
| + } |
| + origin, err := url.Parse(fx.srv.cfg.Origin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", origin.Hostname(), "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 { |
| + t.Fatalf("attach the console's own origin host: refusal = %+v, want 422", ref) |
| + } |
| +} |
| + |
| func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T) { |
| fx, acct := attachFixture(t) |
| shipVersion(t, fx, "story", "v1") |
diff --git a/internal/console/server.go b/internal/console/server.go| index c3afb458c..decaafe86 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -525,11 +525,19 @@ type Config struct { |
| // door refuses to attach — CONSOLE_DENY_DOMAINS, comma-separated. The |
| // deployment's own names, in other words: the edge's reserved tunnel |
| // hostname, retired console origins, anything platform-managed that is |
| - // not already derivable from this config. The two the console DOES |
| - // derive itself — Origin's hostname and AppsDomain — are added by |
| - // deniedDomains() (apidomains.go), so a deployment cannot forget them. |
| - // Empty is legal and means "only the derived two". |
| + // not already derivable from this config. Empty is legal. The console's |
| + // own hostnames are refused regardless (consoleHosts, apidomains.go). |
| DenyDomains []string |
| + // ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) is the |
| + // opt-in for a deployment that hosts apps for other people, such as the |
| + // flagship or carloku. With it set, the /domains door also refuses |
| + // everything under AppsDomain and under the console's own hostnames, |
| + // because on a shared platform those names belong to the operator and |
| + // not to a tenant. It is off by default. A self-hosted deployment |
| + // usually keeps its console, its apps and its custom domains in one |
| + // zone that one person owns, and the reserved rules refused ordinary |
| + // names there. See deniedDomains. |
| + ReservePlatformDomains bool |
| // StoreProvisioner is the backend the object-storage doors provision |
| // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an |
| // ordinary deployment shape, not a broken one: members may still DECLARE |