carlos / platform Public

edge: enforce the password gate ahead of the upgrade branch, the cache and the activator

ServeHTTP gains one step, after the usage-meter wrap and before the
upgrade branch: a route whose row carries a gate hash goes through
gate.Admit, which either strips the cookie and lets the request proceed
untouched or writes the whole response itself.

Each neighbour of that placement is load-bearing (spec §4.1): after the
meter so the login page's bytes bill to the route that caused them;
before the upgrade branch so a WebSocket handshake is gated; before the
cache check so a cache hit is only ever served to a request that already
passed; before the activator so a stranger never wakes a sleeping
instance.

Also drops the p code font-size from the login page, a design-lint item
deferred from the gate package.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d595c1fb87d779a318c5be3ceaa0c83d33da2091 parent 8bfd2a9
3 files changed, +248 −2
  • internal/edge/edge.go +20 −1
  • internal/edge/gate_test.go +227 −0
  • internal/gate/login.html +1 −1
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index a58f17548..1795d43f8 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -49,6 +49,7 @@ import (
"golang.org/x/crypto/acme/autocert"
"github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -92,6 +93,11 @@ type Edge struct {
// processes woken per request. Nil keeps the proxy-only behavior; with it
// set, unflagged routes are still proxied straight (the flag is per host).
act Activator
+ // gate is the password curtain (internal/gate) for routes whose row
+ // carries a gate hash. Always constructed: the check is one string
+ // compare on up.route.Gate for every ungated host, and the limiter it
+ // carries is shared across the box's gated hosts.
+ gate *gate.Gate
// fallbackURL, when set, is where requests for unknown hosts are redirected
// (e.g. the apex marketing site) instead of a bare 404 — a wildcard DNS
// record then makes typo'd or retired subdomains land somewhere useful. Note
@@ -272,7 +278,7 @@ type upstream struct {
// New builds an Edge over reg and performs the startup reload. act may be nil
// (no hibernation).
func New(reg *registry.Registry, act Activator) (*Edge, error) {
- e := &Edge{reg: reg, act: act, probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv()}
+ e := &Edge{reg: reg, act: act, gate: gate.New(), probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv()}
if err := e.Reload(); err != nil {
return nil, err
}
@@ -1244,6 +1250,19 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() { m.Record(up.route, 1, bytes, hopped) }()
}
+ // The password gate (internal/gate; spec 2026-09-07-password-gate-design
+ // §4.1). Placed HERE and the neighbours are load-bearing: after the
+ // meter, so the login page's bytes bill to the route that caused them;
+ // before the upgrade branch, so a WebSocket handshake is gated; before
+ // the cache check, so a cache hit is only ever served to a request that
+ // already passed (the LRU keys on host and path, not on cookies); and
+ // before the activator, so a stranger — a crawler, a scanner, a guessed
+ // hostname — never wakes a sleeping instance. Admit strips the cookie
+ // on the way through, so the app never sees it.
+ if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.Gate) {
+ return
+ }
+
// A Connection: Upgrade request (a WebSocket handshake, typically GET)
// must reach the activator/proxy with the real ResponseWriter, completely
// untouched by caching: the stdlib reverse proxy's upgrade path type-
diff --git a/internal/edge/gate_test.go b/internal/edge/gate_test.go
new file mode 100644
index 000000000..24645886b
--- /dev/null
+++ b/internal/edge/gate_test.go
@@ -0,0 +1,227 @@
+package edge
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/gate"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) {
+ t.Helper()
+ backend, seenCookie := cookieEchoBackend(t, "secret page")
+ h, err := gate.Hash("correct horse")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(),
+ Status: "ready", Hibernate: hibernate, Gate: h}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.custom.example", Host: "gated.example.com",
+ Account: "acct", App: "app", CreatedAt: 1}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+ return e, srv, h, seenCookie
+}
+
+// cookieEchoBackend records the Cookie header it saw.
+func cookieEchoBackend(t *testing.T, body string) (*httptest.Server, *string) {
+ t.Helper()
+ seen := new(string)
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *seen = r.Header.Get("Cookie")
+ io.WriteString(w, body)
+ }))
+ t.Cleanup(ts.Close)
+ return ts, seen
+}
+
+func doHost(t *testing.T, srv *httptest.Server, method, host, path, cookie string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest(method, srv.URL+path, nil)
+ req.Host = host
+ if cookie != "" {
+ req.Header.Set("Cookie", cookie)
+ }
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func TestGatedRouteWithoutCookieNeverWakesOrProxies(t *testing.T) {
+ _, srv, _, seen := gatedEdge(t, refusingActivator{t: t}, true)
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "")
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", resp.StatusCode)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(b), "Password required") {
+ t.Fatalf("body: %s", b)
+ }
+ if resp.Header.Get("Cache-Control") != "no-store" {
+ t.Fatalf("Cache-Control = %q", resp.Header.Get("Cache-Control"))
+ }
+ if *seen != "" || strings.Contains(string(b), "secret page") {
+ t.Fatal("upstream was reached without a cookie")
+ }
+}
+
+func TestGatedRouteCookieAdmitsAndIsStripped(t *testing.T) {
+ _, srv, h, seen := gatedEdge(t, &fakeActivator{}, false)
+ cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "a=1; "+cookie)
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 || string(b) != "secret page" {
+ t.Fatalf("got %d %q", resp.StatusCode, b)
+ }
+ if *seen != "a=1" {
+ t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", *seen)
+ }
+}
+
+func TestGatedAliasIsGatedToo(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", ""); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("alias without cookie = %d, want 401", resp.StatusCode)
+ }
+ // The MAC is bound to the ROUTE host, so a cookie minted for the primary
+ // verifies when presented on the alias.
+ cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))
+ if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", cookie); resp.StatusCode != 200 {
+ t.Fatalf("alias with cookie = %d, want 200", resp.StatusCode)
+ }
+}
+
+func TestGatedLoginPostSetsCookie(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
+ req.Host = "gated.example.com"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" {
+ t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
+ }
+ var got string
+ for _, k := range resp.Cookies() {
+ if k.Name == gate.CookieName {
+ got = k.Value
+ }
+ }
+ if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
+ t.Fatalf("cookie %q does not verify", got)
+ }
+}
+
+// TestGatedLoginPostOnAliasKeepsTheInboundHost is the pin on what the edge
+// hands Admit: the same-origin check compares Origin against the REQUEST's
+// Host, so a login posted to the attached hostname must arrive with that
+// hostname intact — while the cookie it mints is signed for the ROUTE's
+// host, the one every alias of the route shares. Rewriting r.Host to the
+// route before the gate step would 403 every alias login.
+func TestGatedLoginPostOnAliasKeepsTheInboundHost(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
+ req.Host = "www.custom.example"
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "http://www.custom.example")
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" {
+ t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
+ }
+ var got string
+ for _, k := range resp.Cookies() {
+ if k.Name == gate.CookieName {
+ got = k.Value
+ }
+ }
+ if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
+ t.Fatalf("cookie %q does not verify for the route host", got)
+ }
+}
+
+func TestGatedRouteCacheIsNeverConsultedBeforeTheGate(t *testing.T) {
+ e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false)
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ e.cache.Put(CacheKey{Host: "gated.example.com", Method: "GET", Path: "/"}, "", &CachedResponse{
+ StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: []byte("cached secret"),
+ StoredAt: time.Now(), TTL: time.Minute,
+ })
+ resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "")
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusUnauthorized || strings.Contains(string(b), "cached secret") {
+ t.Fatalf("cache served past the gate: %d %s", resp.StatusCode, b)
+ }
+}
+
+func TestGatedWebSocketHandshakeIsGated(t *testing.T) {
+ _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil)
+ req.Host = "gated.example.com"
+ req.Header.Set("Connection", "Upgrade")
+ req.Header.Set("Upgrade", "websocket")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("upgrade without cookie = %d, want 401", resp.StatusCode)
+ }
+ req.Header.Set("Cookie", gate.CookieName+"="+gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)))
+ resp, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode == http.StatusUnauthorized {
+ t.Fatal("upgrade with cookie was refused")
+ }
+}
+
+func TestUngatedRouteUnchanged(t *testing.T) {
+ backend, seen := cookieEchoBackend(t, "open page")
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, &fakeActivator{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+ resp := doHost(t, srv, http.MethodGet, "open.example.com", "/", gate.CookieName+"=whatever; a=1")
+ if resp.StatusCode != 200 || *seen != gate.CookieName+"=whatever; a=1" {
+ t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen)
+ }
+}
diff --git a/internal/gate/login.html b/internal/gate/login.html
index 8c379db9d..8269b823d 100644
--- a/internal/gate/login.html
+++ b/internal/gate/login.html
@@ -25,7 +25,7 @@ body {
main { width: 100%; max-width: 22rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: var(--muted); margin-bottom: 1.25rem; }
-p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.95em; }
+p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 0.35rem; }
input[type=password] {
width: 100%; font: inherit; color: var(--ink); background: var(--bg);