carlos / platform Public

gate: review trivia — comment counts, the Secure cookie's cost, a stranger's 404

Nothing here changes what the gate does.

registry: routeCols says 37 names, which is what it now holds, and the
gate= line in the ON CONFLICT list gets its comma at the end of the
previous line like every one of its neighbours.

internal/gate: the package doc says the cookie is Secure and what that
costs — a plain-http dev edge cannot pass its own gate except on localhost
— so nobody rediscovers it as a bug. stripCookie says it re-serialises via
r.Cookies(), so a cookie Go's parser drops never reaches the app and a
quoted value arrives unquoted. The login page's label steps down to
0.75rem, so the type scale is 0.75 / 1 / 1.5 rather than an odd 0.8.

console: the gate door gets its non-member test. A browser signed up into
an account of its own POSTs a password at this account's app and gets the
family's 404 — byte for byte what a nonexistent account answers, so the
form cannot be used to learn that the account, the app or the host is real
— and the record is untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 133c77de195684daa51ac68c5db73d35aa6141c9 parent 328da01
4 files changed, +60 −4
  • internal/console/appsettings_gate_test.go +41 −0
  • internal/gate/gate.go +15 −0
  • internal/gate/login.html +1 −1
  • internal/registry/registry.go +3 −3

CI cancelled — superseded by a newer push — run details

diff --git a/internal/console/appsettings_gate_test.go b/internal/console/appsettings_gate_test.go
index 8b2bd2e89..b03ddcd34 100644
--- a/internal/console/appsettings_gate_test.go
+++ b/internal/console/appsettings_gate_test.go
@@ -79,6 +79,47 @@ func TestAppSettingsGateSetAndClear(t *testing.T) {
}
}
+// The gate door is not an oracle. A signed-in browser that belongs to no
+// account here gets the account family's 404 — byte for byte what an
+// account that does not exist answers, so a stranger cannot use the gate
+// form to learn that this account, this app or this host is real — and the
+// record is untouched. Worth pinning on THIS door in particular: it is the
+// one that takes a password, and a stranger who could set one would lock an
+// account out of its own site.
+func TestAppSettingsGateIsNotAnOracle(t *testing.T) {
+ fx, token := instanceFixture(t, "code-settings-gate-stranger")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+
+ stranger := newBrowser(t)
+ fx.setUser("eve@keymail.dev") // signs up into an account of her own
+ signUp(t, fx, stranger, "code-settings-gate-stranger-browser")
+ csrf := sessionToken(t, fx, stranger)
+
+ form := url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"eve was here"}}
+ ghost, _, missing := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/apps/titogo/gate", form)
+ if ghost != http.StatusNotFound {
+ t.Fatalf("the yardstick is not a 404: nonexistent account = %d", ghost)
+ }
+ status, loc, body := postForm(t, stranger, fx.u("/apps/titogo/gate"), form)
+ if status != http.StatusNotFound {
+ t.Fatalf("gate POST as a non-member = %d -> %q, want 404", status, loc)
+ }
+ if body != missing {
+ t.Fatalf("a non-member's 404 differs from a nonexistent account's:\n%q\nvs\n%q", body, missing)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Gate != "" {
+ t.Fatalf("a stranger set a gate: %q", rec.Gate)
+ }
+}
+
func TestAppSettingsGateNeedsCSRF(t *testing.T) {
fx, _ := instanceFixture(t, "code-settings-gate-csrf")
c := newBrowser(t)
diff --git a/internal/gate/gate.go b/internal/gate/gate.go
index 217b1c0f7..58cb90c40 100644
--- a/internal/gate/gate.go
+++ b/internal/gate/gate.go
@@ -8,6 +8,13 @@
// applies before hashing, the cookie's shape, the attempt limiter — is
// owned by this package so the two console doors and the edge cannot
// drift from one another.
+//
+// The cookie is Secure, always. A gate that a network can read the answer
+// to is not a curtain, and the platform terminates TLS at the edge for
+// every real host — so the one place this bites is a plain-http dev edge
+// (`carlos edge -dev`), which cannot pass its own gate except on
+// localhost, where browsers make an exception for Secure. Testing a gate
+// off-localhost means testing it over https.
package gate
import (
@@ -229,6 +236,14 @@ func clientKey(r *http.Request) string {
// stripCookie removes the gate cookie from the request so the app behind
// the curtain never sees it. Other cookies pass through in their order.
+//
+// It re-serialises from r.Cookies() rather than editing the header text,
+// which has two consequences worth knowing: a cookie Go's parser drops as
+// malformed does not reach the app either, and a quoted value arrives
+// unquoted, because that is what Cookie.Value holds. Both are narrower
+// than what the app would have received unproxied, and neither has ever
+// mattered for a real client — but an app that hand-parses exotic cookies
+// is looking at this function, not at a browser bug.
func stripCookie(r *http.Request) {
cookies := r.Cookies()
r.Header.Del("Cookie")
diff --git a/internal/gate/login.html b/internal/gate/login.html
index 8269b823d..2fa757543 100644
--- a/internal/gate/login.html
+++ b/internal/gate/login.html
@@ -26,7 +26,7 @@ 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; }
-label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 0.35rem; }
+label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.35rem; }
input[type=password] {
width: 100%; font: inherit; color: var(--ink); background: var(--bg);
border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.55rem 0.7rem; margin-bottom: 0.9rem;
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 48610ddd9..d75296270 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -686,7 +686,7 @@ func (r *Registry) Accounts() ([]Account, error) {
}
// routeCols is the column list every route read and the one route write
-// share — 36 names, in this exact order. scanRoute's destinations, Upsert's
+// share — 37 names, in this exact order. scanRoute's destinations, Upsert's
// placeholder row and Upsert's argument list all count from here: adding a
// column means adding it in FOUR places, and a miscount is a silent column
// shift rather than an error (TestUpsertRoundTripsAllColumns is what catches
@@ -919,8 +919,8 @@ ON CONFLICT(host) DO UPDATE SET
seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END,
unit=CASE WHEN excluded.unit = '' THEN routes.unit ELSE excluded.unit END,
fleet_box=CASE WHEN excluded.fleet_box = '' THEN routes.fleet_box ELSE excluded.fleet_box END,
- addrs=excluded.addrs, health=excluded.health, steering=excluded.steering
- , gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END`,
+ addrs=excluded.addrs, health=excluded.health, steering=excluded.steering,
+ gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END`,
t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel,
b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing,