Merge pull request #106 from carlosframework/worktree-console-carloku-host
console: CONSOLE_REDIRECT_HOSTS — retired hosts 301/308 to the canonical origin
3 files changed,
+283
−14
cmd/console/main.go+34 −13internal/console/server.go+84 −1internal/console/server_test.go+165 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index c91aabf00..467268d8d 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -33,10 +33,13 @@ |
| // and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the |
| // console; unset = read-only), CARLOS_SECRETS_PUBKEY (SERVED to CLI |
| // clients so `carlos secrets set` can seal with a login alone; never used |
| -// here) and PLATFORM_APPS_DOMAIN (the same variable the edge and agent |
| -// read; enables the CLI's member-facing `carlos instances enable`, whose |
| -// manifest domain is pinned to <sqid>.<this domain>). grant/revoke/owners |
| -// need only CONSOLE_DB. |
| +// here), PLATFORM_APPS_DOMAIN (the same variable the edge and agent read; |
| +// enables the CLI's member-facing `carlos instances enable`, whose manifest |
| +// domain is pinned to <sqid>.<this domain>), and CONSOLE_REDIRECT_HOSTS |
| +// (comma-separated retired hostnames this console 301/308s to CONSOLE_ORIGIN |
| +// — opt-in per host, so a host the API must keep answering on, like |
| +// platform.carlosframework.com for CLI tokens, is simply left off). |
| +// grant/revoke/owners need only CONSOLE_DB. |
| package main |
| import ( |
| @@ -223,15 +226,16 @@ func serve() { |
| } |
| st := openConsoleDB() |
| srv, err := console.NewServer(console.Config{ |
| - Origin: origin, |
| - Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| - Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| - Releases: openStore(), |
| - ReleasePub: pub, |
| - Key: key, |
| - EdgeIPs: edgeIPs(), |
| - SecretsPub: secretsPub(), |
| - AppsDomain: appsDomain(), |
| + Origin: origin, |
| + Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| + Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| + Releases: openStore(), |
| + ReleasePub: pub, |
| + Key: key, |
| + EdgeIPs: edgeIPs(), |
| + SecretsPub: secretsPub(), |
| + AppsDomain: appsDomain(), |
| + RedirectHosts: redirectHosts(), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -342,6 +346,23 @@ func edgeIPs() []string { |
| return out |
| } |
| +// redirectHosts parses CONSOLE_REDIRECT_HOSTS (comma-separated) — retired |
| +// hostnames this console answers with a permanent redirect to CONSOLE_ORIGIN |
| +// (server.go's ServeHTTP), rather than serving the app in place. Opt-in per |
| +// host on purpose: a host that must keep serving the API — CLI bearer |
| +// tokens are host-agnostic, and a POST redirected through an unexpected hop |
| +// is how clients break — is simply left off this list, unlike EdgeIPs above |
| +// there's no single deployment-wide default to fall back to. |
| +func redirectHosts() []string { |
| + var out []string |
| + for _, h := range strings.Split(os.Getenv("CONSOLE_REDIRECT_HOSTS"), ",") { |
| + if h = strings.TrimSpace(h); h != "" { |
| + out = append(out, h) |
| + } |
| + } |
| + return out |
| +} |
| + |
| func envOr(k, def string) string { |
| if v := os.Getenv(k); v != "" { |
| return v |
diff --git a/internal/console/server.go b/internal/console/server.go| index eac1fd1df..568ff349f 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -14,6 +14,7 @@ import ( |
| "fmt" |
| "html/template" |
| "log" |
| + "net" |
| "net/http" |
| "net/url" |
| "os" |
| @@ -178,6 +179,13 @@ type Config struct { |
| // does not exist, and the handler refuses with a pointer at the |
| // operator's store-mode `carlos instances enable`. |
| AppsDomain string |
| + // RedirectHosts are request hosts this console answers with a permanent |
| + // redirect to Origin (CONSOLE_REDIRECT_HOSTS, comma-separated) — retired |
| + // hostnames after a canonical-origin move. Opt-in per host: hostnames |
| + // that must keep serving the API in place (CLI bearer tokens are |
| + // host-agnostic, and POSTs through an unexpected redirect are how |
| + // clients break) simply aren't listed. |
| + RedirectHosts []string |
| } |
| // Server is the console's http.Handler. |
| @@ -189,6 +197,9 @@ type Server struct { |
| instances *instanceFetcher |
| secure bool // Secure cookies iff the origin is https |
| cli *cliLimits // rate limits on the CLI-identity endpoints (authcli.go) |
| + // redirects is cfg.RedirectHosts, normalized (lowercase, no port) into a |
| + // lookup set — ServeHTTP checks it before the mux sees the request. |
| + redirects map[string]bool |
| // cliNotified, when non-nil, is signalled after each CLI-token issuance |
| // notification attempt. Tests only — the send is off the request |
| // goroutine, and a test that asserted on it without this would be racing. |
| @@ -224,6 +235,33 @@ type Server struct { |
| // console's Keymail signing key. |
| func NewServer(cfg Config, st *Store) (*Server, error) { |
| cfg.Origin = strings.TrimSuffix(cfg.Origin, "/") |
| + // originHost is what a RedirectHosts entry must never equal: redirecting |
| + // the console's own origin to itself is a loop, not a retirement. |
| + originHost := "" |
| + if u, err := url.Parse(cfg.Origin); err == nil { |
| + originHost = normalizeHost(u.Host) |
| + } |
| + // An origin with no host in it is fatal ONCE there are hosts to retire, |
| + // never before: it is both the loop guard's only reference — an origin |
| + // that parsed to nothing silently permits the very entry the guard exists |
| + // to refuse — and the prefix every Location is built on, where "" would |
| + // hand back the request's own path (a redirect to the retired host) or a |
| + // scheme-relative "//host/…" whose destination the request chooses. |
| + // Deployments with nothing to retire keep whatever origin they had. |
| + if len(cfg.RedirectHosts) > 0 && originHost == "" { |
| + return nil, fmt.Errorf("console: CONSOLE_REDIRECT_HOSTS is set, but CONSOLE_ORIGIN %q has no host to redirect to — it needs a scheme, e.g. https://console.example.com", cfg.Origin) |
| + } |
| + redirects := make(map[string]bool, len(cfg.RedirectHosts)) |
| + for _, h := range cfg.RedirectHosts { |
| + h = normalizeHost(h) |
| + if h == "" { |
| + continue |
| + } |
| + if h == originHost { |
| + return nil, fmt.Errorf("console: CONSOLE_REDIRECT_HOSTS lists %q, which is this console's own origin host — that config would redirect-loop", h) |
| + } |
| + redirects[h] = true |
| + } |
| km := NewKeymail(cfg.Keymail, cfg.Origin) |
| key, err := st.SigningKey() |
| if err != nil { |
| @@ -238,6 +276,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme), |
| secure: strings.HasPrefix(cfg.Origin, "https://"), |
| cli: newCLILimits(), |
| + redirects: redirects, |
| // Two concurrent ships at a time: roomy for one operator's CI plus a |
| // human running `carlos ship` by hand, tight enough that a fleet of |
| // slow-loris uploads against a stolen or shared token can't pile up |
| @@ -333,7 +372,51 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| return s, nil |
| } |
| -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } |
| +// ServeHTTP checks the host-based redirect BEFORE the mux on purpose: it's |
| +// purely host-based, touches no session/account state, so it sits in front |
| +// of the console's security gates without weakening them (the gates |
| +// ordering rule concerns account-existence leaks; a host-level redirect |
| +// reveals nothing). |
| +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + if s.redirects[normalizeHost(r.Host)] { |
| + code := http.StatusMovedPermanently |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| + code = http.StatusPermanentRedirect // 308 keeps the method |
| + } |
| + http.Redirect(w, r, s.cfg.Origin+redirectTarget(r.URL), code) |
| + return |
| + } |
| + s.mux.ServeHTTP(w, r) |
| +} |
| + |
| +// redirectTarget is the path and query to hang off Origin: the request's own |
| +// RequestURI, but only where that is the rooted path a browser sends. A |
| +// request target may legally be rootless — `GET mailto:x@evil.com HTTP/1.1` |
| +// parses to an opaque URL whose RequestURI is "x@evil.com" — and pasting that |
| +// onto the origin spells https://console.example.com@evil.com, an open |
| +// redirect wearing the console's own hostname in the userinfo. The console |
| +// serves nothing at a rootless URL, so anything unrooted goes to the front |
| +// door instead. |
| +func redirectTarget(u *url.URL) string { |
| + if uri := u.RequestURI(); strings.HasPrefix(uri, "/") { |
| + return uri |
| + } |
| + return "/" |
| +} |
| + |
| +// normalizeHost reduces a request Host header (or a configured origin/ |
| +// redirect host) to the bare, lowercase hostname the RedirectHosts |
| +// comparison keys on. net.SplitHostPort errors when there's no port to |
| +// strip — a bare hostname, or an unbracketed IPv6 literal — and then the |
| +// only thing left to remove is the brackets it would have taken off with the |
| +// port, so that "[::1]" and "[::1]:8080" key the same. |
| +func normalizeHost(host string) string { |
| + host = strings.ToLower(strings.TrimSpace(host)) |
| + if h, _, err := net.SplitHostPort(host); err == nil { |
| + return h |
| + } |
| + return strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") |
| +} |
| // ---- pages ------------------------------------------------------------------ |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 7e2edace1..b069892d4 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1992,3 +1992,168 @@ func TestFleetMetricsRendersFiringAlertsWithUnits(t *testing.T) { |
| t.Errorf("lag alert value rendered as a percentage — lag is seconds:\n%s", page) |
| } |
| } |
| + |
| +// newRedirectFixture builds the minimal Server the CONSOLE_REDIRECT_HOSTS |
| +// tests need — same trimmed shape as newMetricsFixture (metricsview_test.go), |
| +// with none of consoleFixture's registry/keymail/HTTP-listener scaffolding, |
| +// but a caller-chosen Origin and RedirectHosts since that's what's under |
| +// test here. |
| +func newRedirectFixture(t *testing.T, origin string, redirectHosts []string) *Server { |
| + t.Helper() |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) |
| + srv, err := NewServer(Config{Releases: rel, Origin: origin, RedirectHosts: redirectHosts}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return srv |
| +} |
| + |
| +// serveRedirect drives srv.ServeHTTP directly with a chosen Host header — no |
| +// httptest.Server/listener needed, since the redirect check reads r.Host |
| +// before the mux (or anything network-shaped) is involved. |
| +func serveRedirect(t *testing.T, srv *Server, method, host, target string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(method, target, nil) |
| + req.Host = host |
| + rec := httptest.NewRecorder() |
| + srv.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +func TestRedirectHostsPermanentRedirect(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test", "/a/foo?x=1") |
| + if rec.Code != http.StatusMovedPermanently { |
| + t.Fatalf("GET on a redirect host = %d, want 301", rec.Code) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { |
| + t.Fatalf("Location = %q, want the canonical origin with the request untouched", loc) |
| + } |
| +} |
| + |
| +// TestRedirectHostsPreservesMethodWith308 pins the 308 choice for anything |
| +// other than GET/HEAD: a 301/302 lets browsers replay a POST as a GET and |
| +// drop its body, which is exactly the client breakage RedirectHosts must not |
| +// cause on a host still fielding real requests. |
| +func TestRedirectHostsPreservesMethodWith308(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + rec := serveRedirect(t, srv, http.MethodPost, "dashboard.example.test", "/a/foo?x=1") |
| + if rec.Code != http.StatusPermanentRedirect { |
| + t.Fatalf("POST on a redirect host = %d, want 308 (keeps the method and body on replay)", rec.Code) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { |
| + t.Fatalf("Location = %q, want the canonical origin", loc) |
| + } |
| +} |
| + |
| +func TestRedirectHostsMatchesHostWithPort(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test:443", "/a/foo?x=1") |
| + if rec.Code != http.StatusMovedPermanently { |
| + t.Fatalf("Host header carrying a port = %d, want 301 — the port must not defeat the match", rec.Code) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "https://console.example.test/a/foo?x=1" { |
| + t.Fatalf("Location = %q, want the canonical origin", loc) |
| + } |
| +} |
| + |
| +// TestRedirectHostsLeavesOtherHostsAlone pins the opt-in contract: a host |
| +// that was never listed (here, the console's own origin host) gets the |
| +// route it would always get, not a redirect — this is what keeps |
| +// platform.carlosframework.com serving CLI bearer tokens untouched. |
| +func TestRedirectHostsLeavesOtherHostsAlone(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + rec := serveRedirect(t, srv, http.MethodGet, "console.example.test", "/healthz") |
| + if rec.Code == http.StatusMovedPermanently || rec.Code == http.StatusPermanentRedirect { |
| + t.Fatalf("an unlisted host was redirected: %d -> %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "ok") { |
| + t.Fatalf("unlisted host = %d %q, want /healthz served normally", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +// TestNewServerRefusesRedirectingItsOwnOrigin pins the loop guard: a |
| +// RedirectHosts entry equal to Origin's own host would 301/308 a request |
| +// straight back to the host that just answered it. |
| +func TestNewServerRefusesRedirectingItsOwnOrigin(t *testing.T) { |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) |
| + _, err = NewServer(Config{ |
| + Releases: rel, |
| + Origin: "https://console.example.test", |
| + RedirectHosts: []string{"console.example.test"}, |
| + }, st) |
| + if err == nil { |
| + t.Fatal("NewServer accepted a RedirectHosts entry equal to its own origin host — that config would redirect-loop") |
| + } |
| +} |
| + |
| +// TestNewServerRefusesRedirectHostsWithoutAnOriginHost pins the other half of |
| +// the loop guard: an origin it cannot read a host out of (here the classic |
| +// operator slip, a CONSOLE_ORIGIN with no scheme) leaves the guard with |
| +// nothing to compare against AND leaves every Location without a host of its |
| +// own. Refuse at start rather than serve half a redirect. |
| +func TestNewServerRefusesRedirectHostsWithoutAnOriginHost(t *testing.T) { |
| + for _, origin := range []string{"", "console.example.test"} { |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket")) |
| + if _, err := NewServer(Config{ |
| + Releases: rel, |
| + Origin: origin, |
| + RedirectHosts: []string{"dashboard.example.test"}, |
| + }, st); err == nil { |
| + t.Errorf("NewServer accepted RedirectHosts with Origin %q — there is no host to redirect to", origin) |
| + } |
| + } |
| +} |
| + |
| +// TestRedirectHostsRootlessTargetStaysOnTheOrigin pins the Location against a |
| +// request target that isn't a path. Go parses `GET mailto:x@evil.com` into an |
| +// opaque URL, and origin+RequestURI would then read |
| +// https://console.example.test@evil.com — userinfo, not host — handing an |
| +// outsider a redirect off the deployment with the console's name on the |
| +// front of it. |
| +func TestRedirectHostsRootlessTargetStaysOnTheOrigin(t *testing.T) { |
| + srv := newRedirectFixture(t, "https://console.example.test", []string{"dashboard.example.test"}) |
| + |
| + for _, target := range []string{"mailto:x@evil.com", "https:evil.com"} { |
| + rec := serveRedirect(t, srv, http.MethodGet, "dashboard.example.test", target) |
| + loc := rec.Header().Get("Location") |
| + if loc != "https://console.example.test/" { |
| + t.Errorf("target %q redirected to %q, want the origin's front door", target, loc) |
| + } |
| + } |
| +} |
| + |
| +func TestNormalizeHost(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {"dashboard.example.test", "dashboard.example.test"}, |
| + {"DASHBOARD.Example.Test:443", "dashboard.example.test"}, |
| + {" dashboard.example.test ", "dashboard.example.test"}, |
| + // An IPv6 literal keys the same with or without its port, which is |
| + // only true if the portless form loses its brackets too. |
| + {"[::1]", "::1"}, |
| + {"[::1]:8080", "::1"}, |
| + } { |
| + if got := normalizeHost(tc.in); got != tc.want { |
| + t.Errorf("normalizeHost(%q) = %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| +} |