// Package gate is the password curtain the edge holds in front of a gated // host: one shared password per hostname, a neutral page that asks for it, // and a cookie that remembers the answer for a week. Spec: // docs/superpowers/specs/2026-09-07-password-gate-design.md. // // The edge calls Admit once per request to a route whose registry row // carries a gate hash. Everything else here — the rules the console // 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 ( "fmt" "net" "net/http" "net/url" "strings" "time" "golang.org/x/crypto/bcrypt" ) const ( // CookieName is the browser cookie a passed gate sets; stripped from // the request before it reaches the app. CookieName = "carlos_gate" // LoginPath is the form's action on a gated host. Ordinary on every // other host and for every other method. LoginPath = "/.carlos/gate" // Lifetime is how long a cookie stays valid; the expiry is inside the // value, so a stolen cookie is bounded even if the browser keeps it. Lifetime = 7 * 24 * time.Hour // MaxFormBody bounds the login POST's body. Exported because the edge // parses that form too — it routes a login POST by the `next` field // (spec §4.2) — and the two must agree on one number. MaxFormBody = 4 << 10 bcryptCost = 10 minPassword = 8 maxPassword = 72 // bcrypt's input limit; longer is refused, never truncated ) // CheckPassword is the door's rule, applied by the console before hashing. func CheckPassword(p string) error { if len(p) < minPassword { return fmt.Errorf("password must be at least %d characters", minPassword) } if len(p) > maxPassword { return fmt.Errorf("password must be at most %d bytes", maxPassword) } return nil } // Hash is bcrypt at cost 10. The console calls it once per set; the edge // never does (it compares on the login POST and verifies an HMAC cookie // on every other request). func Hash(p string) (string, error) { if err := CheckPassword(p); err != nil { return "", err } b, err := bcrypt.GenerateFromPassword([]byte(p), bcryptCost) if err != nil { return "", err } return string(b), nil } func comparePassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } // Gate is the per-edge instance: one limiter shared by every gated host on // the box (keys carry the host), a clock and a comparer for tests. type Gate struct { limiter *Limiter now func() time.Time compare func(hash, password string) bool } // New is the edge's constructor. There is no configuration. func New() *Gate { g := &Gate{now: time.Now, compare: comparePassword} g.limiter = NewLimiter(func() time.Time { return g.now() }) return g } // Admit decides one request to a gated host. host is the route's own // hostname (the row's, not the request's — an alias and a path-routed // prefix must agree on which key signs), hash the row's gate. // // true: the request carries a valid cookie, now stripped from the Cookie // header; the caller proxies it exactly as it would have. false: Admit has // written the whole response — the login page (401), the redirect after a // successful POST (303), the cross-origin refusal (403), or the // too-many-attempts page (429). // // Do not call Admit for a row with an empty hash. An empty hash fails // closed, not open — no password can match it and no cookie verifies // against it, so the host would become unreachable rather than public. // The edge gates on up.route.Gate != "" before it gets here. func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool { if r.Method == http.MethodPost && r.URL.Path == LoginPath { g.login(w, r, host, hash) return false } // Any cookie by this name will do. Tenant hosts share a wildcard apex, // so a sibling host — or an XSS on one — can plant a second // carlos_gate for .apex that sorts ahead of the real one; taking only // the first would let that lock the visitor out for good. for _, c := range r.Cookies() { if c.Name == CookieName && Verify(c.Value, host, hash, g.now()) { stripCookie(r) return true } } writePage(w, r, host, http.StatusUnauthorized, "", SafeNext(r.URL.RequestURI())) return false } func (g *Gate) login(w http.ResponseWriter, r *http.Request, host, hash string) { // The edge may already have parsed this form — it routes a login POST // by the `next` field (spec §4.2) — in which case the body is spent // and was bounded there. ParseForm itself is idempotent: with PostForm // already set it returns the cached values. if r.PostForm == nil { r.Body = http.MaxBytesReader(w, r.Body, MaxFormBody) } if err := r.ParseForm(); err != nil { writePage(w, r, host, http.StatusUnauthorized, msgWrong, "/") return } next := SafeNext(r.PostForm.Get("next")) if !sameOrigin(r) { // The POST needs no cookie, so SameSite does not cover it: a hidden // form on an attacker's page could auto-submit eleven guesses and // park a visitor at 429 on a site they can otherwise reach. Refuse // before the bucket is touched and before bcrypt runs, and say // nothing about the password — none was judged. writePage(w, r, host, http.StatusForbidden, "", next) return } if !g.limiter.Allow(clientKey(r) + "\x1f" + host) { w.Header().Set("Retry-After", "60") writePage(w, r, host, http.StatusTooManyRequests, msgTooMany, next) return } if !g.compare(hash, r.PostForm.Get("password")) { writePage(w, r, host, http.StatusUnauthorized, msgWrong, next) return } http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: Mint(host, hash, g.now().Add(Lifetime)), Path: "/", MaxAge: int(Lifetime / time.Second), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Robots-Tag", "noindex, nofollow") http.Redirect(w, r, next, http.StatusSeeOther) } // sameOrigin reports whether the POST came from the host it is posting to. // An absent Origin is allowed — curl and older clients send none — but a // present one must match, and "null" (a sandboxed frame, a file:// page) // never does. The comparison is against r.Host, not the route's host: an // alias posts from its own name, and the route's primary is a different // string. func sameOrigin(r *http.Request) bool { o := r.Header.Get("Origin") if o == "" { return true } u, err := url.Parse(o) if err != nil || u.Host == "" { return false } return strings.EqualFold(hostOnly(u.Host), hostOnly(r.Host)) } // hostOnly drops the port, if there is one, so :443 and an implicit 443 // are the same origin here. func hostOnly(h string) string { if x, _, err := net.SplitHostPort(h); err == nil { return x } return h } // SafeNext is the entire open-redirect surface: a path on this host, or // "/". Exported because the edge sanitises the login POST's `next` with it // before resolving the upstream (spec §4.2) — one sanitiser, so the path // the edge routes a login POST by and the path it redirects to cannot // differ. func SafeNext(next string) string { // A control byte — a tab above all — survives net/http's header writer, // which rewrites only CR and LF, and browsers strip it before they // resolve the URL: "/\t/evil.example/" would leave here intact and land // as "//evil.example/". Refuse the lot before the shape is judged. if strings.IndexFunc(next, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { return "/" } if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") { return "/" } // Bouncing back to the login path lands on a page the app behind the // curtain does not serve; root is the honest destination. p, _, _ := strings.Cut(next, "?") p, _, _ = strings.Cut(p, "#") if p == LoginPath { return "/" } return next } // clientKey is the remote address without its port. A backhauled request's // address is the sibling edge's, which folds every hop from that edge into // one bucket — stricter, never looser, and named in the spec (§4.2). func clientKey(r *http.Request) string { if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return h } return r.RemoteAddr } // 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") parts := make([]string, 0, len(cookies)) for _, c := range cookies { if c.Name != CookieName { parts = append(parts, c.Name+"="+c.Value) } } if len(parts) > 0 { r.Header.Set("Cookie", strings.Join(parts, "; ")) } }