carlos / platform Public

console: retired hosts redirect permanently to the canonical origin

CONSOLE_REDIRECT_HOSTS (comma-separated) names hostnames the console
answers with 301 (GET/HEAD) or 308 (method-preserving) to CONSOLE_ORIGIN,
path and query intact. Opt-in per host on purpose: a host that must keep
serving the API in place — CLI bearer tokens are host-agnostic, and Go
clients strip Authorization across hosts — is simply left off the list.
First user: dashboard.carloku.com retiring in favour of
console.carloku.com.

The check runs ahead of the mux and the ordered security gates: it reads
only the Host header, so the response is byte-identical for every caller.
NewServer refuses a config that lists the origin's own host (a loop) or
sets redirect hosts against an origin with no parseable host (which
would emit scheme-relative Locations). Rootless request targets
(`GET mailto:x@evil.com`) redirect to the front door rather than being
pasted onto the origin, where they would spell an open redirect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su1MdJFZfcyM8Q5n7qDtvB
Paul Campbell 8cdab5e77b38edb9bf191744c0a9f1ec6530303e parent dc6a6c3
3 files changed, +283 −14
  • cmd/console/main.go +34 −13
  • internal/console/server.go +84 −1
  • internal/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)
+ }
+ }
+}