| 1 | // Package gate is the password curtain the edge holds in front of a gated |
| 2 | // host: one shared password per hostname, a neutral page that asks for it, |
| 3 | // and a cookie that remembers the answer for a week. Spec: |
| 4 | // docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| 5 | // |
| 6 | // The edge calls Admit once per request to a route whose registry row |
| 7 | // carries a gate hash. Everything else here — the rules the console |
| 8 | // applies before hashing, the cookie's shape, the attempt limiter — is |
| 9 | // owned by this package so the two console doors and the edge cannot |
| 10 | // drift from one another. |
| 11 | // |
| 12 | // The cookie is Secure, always. A gate that a network can read the answer |
| 13 | // to is not a curtain, and the platform terminates TLS at the edge for |
| 14 | // every real host — so the one place this bites is a plain-http dev edge |
| 15 | // (`carlos edge -dev`), which cannot pass its own gate except on |
| 16 | // localhost, where browsers make an exception for Secure. Testing a gate |
| 17 | // off-localhost means testing it over https. |
| 18 | package gate |
| 19 | |
| 20 | import ( |
| 21 | "fmt" |
| 22 | "net" |
| 23 | "net/http" |
| 24 | "net/url" |
| 25 | "strings" |
| 26 | "time" |
| 27 | |
| 28 | "golang.org/x/crypto/bcrypt" |
| 29 | ) |
| 30 | |
| 31 | const ( |
| 32 | // CookieName is the browser cookie a passed gate sets; stripped from |
| 33 | // the request before it reaches the app. |
| 34 | CookieName = "carlos_gate" |
| 35 | // LoginPath is the form's action on a gated host. Ordinary on every |
| 36 | // other host and for every other method. |
| 37 | LoginPath = "/.carlos/gate" |
| 38 | // Lifetime is how long a cookie stays valid; the expiry is inside the |
| 39 | // value, so a stolen cookie is bounded even if the browser keeps it. |
| 40 | Lifetime = 7 * 24 * time.Hour |
| 41 | |
| 42 | // MaxFormBody bounds the login POST's body. Exported because the edge |
| 43 | // parses that form too — it routes a login POST by the `next` field |
| 44 | // (spec §4.2) — and the two must agree on one number. |
| 45 | MaxFormBody = 4 << 10 |
| 46 | |
| 47 | bcryptCost = 10 |
| 48 | minPassword = 8 |
| 49 | maxPassword = 72 // bcrypt's input limit; longer is refused, never truncated |
| 50 | ) |
| 51 | |
| 52 | // CheckPassword is the door's rule, applied by the console before hashing. |
| 53 | func CheckPassword(p string) error { |
| 54 | if len(p) < minPassword { |
| 55 | return fmt.Errorf("password must be at least %d characters", minPassword) |
| 56 | } |
| 57 | if len(p) > maxPassword { |
| 58 | return fmt.Errorf("password must be at most %d bytes", maxPassword) |
| 59 | } |
| 60 | return nil |
| 61 | } |
| 62 | |
| 63 | // Hash is bcrypt at cost 10. The console calls it once per set; the edge |
| 64 | // never does (it compares on the login POST and verifies an HMAC cookie |
| 65 | // on every other request). |
| 66 | func Hash(p string) (string, error) { |
| 67 | if err := CheckPassword(p); err != nil { |
| 68 | return "", err |
| 69 | } |
| 70 | b, err := bcrypt.GenerateFromPassword([]byte(p), bcryptCost) |
| 71 | if err != nil { |
| 72 | return "", err |
| 73 | } |
| 74 | return string(b), nil |
| 75 | } |
| 76 | |
| 77 | func comparePassword(hash, password string) bool { |
| 78 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil |
| 79 | } |
| 80 | |
| 81 | // Gate is the per-edge instance: one limiter shared by every gated host on |
| 82 | // the box (keys carry the host), a clock and a comparer for tests. |
| 83 | type Gate struct { |
| 84 | limiter *Limiter |
| 85 | now func() time.Time |
| 86 | compare func(hash, password string) bool |
| 87 | } |
| 88 | |
| 89 | // New is the edge's constructor. There is no configuration. |
| 90 | func New() *Gate { |
| 91 | g := &Gate{now: time.Now, compare: comparePassword} |
| 92 | g.limiter = NewLimiter(func() time.Time { return g.now() }) |
| 93 | return g |
| 94 | } |
| 95 | |
| 96 | // Admit decides one request to a gated host. host is the route's own |
| 97 | // hostname (the row's, not the request's — an alias and a path-routed |
| 98 | // prefix must agree on which key signs), hash the row's gate. |
| 99 | // |
| 100 | // true: the request carries a valid cookie, now stripped from the Cookie |
| 101 | // header; the caller proxies it exactly as it would have. false: Admit has |
| 102 | // written the whole response — the login page (401), the redirect after a |
| 103 | // successful POST (303), the cross-origin refusal (403), or the |
| 104 | // too-many-attempts page (429). |
| 105 | // |
| 106 | // Do not call Admit for a row with an empty hash. An empty hash fails |
| 107 | // closed, not open — no password can match it and no cookie verifies |
| 108 | // against it, so the host would become unreachable rather than public. |
| 109 | // The edge gates on up.route.Gate != "" before it gets here. |
| 110 | func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool { |
| 111 | if r.Method == http.MethodPost && r.URL.Path == LoginPath { |
| 112 | g.login(w, r, host, hash) |
| 113 | return false |
| 114 | } |
| 115 | // Any cookie by this name will do. Tenant hosts share a wildcard apex, |
| 116 | // so a sibling host — or an XSS on one — can plant a second |
| 117 | // carlos_gate for .apex that sorts ahead of the real one; taking only |
| 118 | // the first would let that lock the visitor out for good. |
| 119 | for _, c := range r.Cookies() { |
| 120 | if c.Name == CookieName && Verify(c.Value, host, hash, g.now()) { |
| 121 | stripCookie(r) |
| 122 | return true |
| 123 | } |
| 124 | } |
| 125 | writePage(w, r, host, http.StatusUnauthorized, "", SafeNext(r.URL.RequestURI())) |
| 126 | return false |
| 127 | } |
| 128 | |
| 129 | func (g *Gate) login(w http.ResponseWriter, r *http.Request, host, hash string) { |
| 130 | // The edge may already have parsed this form — it routes a login POST |
| 131 | // by the `next` field (spec §4.2) — in which case the body is spent |
| 132 | // and was bounded there. ParseForm itself is idempotent: with PostForm |
| 133 | // already set it returns the cached values. |
| 134 | if r.PostForm == nil { |
| 135 | r.Body = http.MaxBytesReader(w, r.Body, MaxFormBody) |
| 136 | } |
| 137 | if err := r.ParseForm(); err != nil { |
| 138 | writePage(w, r, host, http.StatusUnauthorized, msgWrong, "/") |
| 139 | return |
| 140 | } |
| 141 | next := SafeNext(r.PostForm.Get("next")) |
| 142 | if !sameOrigin(r) { |
| 143 | // The POST needs no cookie, so SameSite does not cover it: a hidden |
| 144 | // form on an attacker's page could auto-submit eleven guesses and |
| 145 | // park a visitor at 429 on a site they can otherwise reach. Refuse |
| 146 | // before the bucket is touched and before bcrypt runs, and say |
| 147 | // nothing about the password — none was judged. |
| 148 | writePage(w, r, host, http.StatusForbidden, "", next) |
| 149 | return |
| 150 | } |
| 151 | if !g.limiter.Allow(clientKey(r) + "\x1f" + host) { |
| 152 | w.Header().Set("Retry-After", "60") |
| 153 | writePage(w, r, host, http.StatusTooManyRequests, msgTooMany, next) |
| 154 | return |
| 155 | } |
| 156 | if !g.compare(hash, r.PostForm.Get("password")) { |
| 157 | writePage(w, r, host, http.StatusUnauthorized, msgWrong, next) |
| 158 | return |
| 159 | } |
| 160 | http.SetCookie(w, &http.Cookie{ |
| 161 | Name: CookieName, |
| 162 | Value: Mint(host, hash, g.now().Add(Lifetime)), |
| 163 | Path: "/", |
| 164 | MaxAge: int(Lifetime / time.Second), |
| 165 | HttpOnly: true, |
| 166 | Secure: true, |
| 167 | SameSite: http.SameSiteLaxMode, |
| 168 | }) |
| 169 | w.Header().Set("Cache-Control", "no-store") |
| 170 | w.Header().Set("X-Robots-Tag", "noindex, nofollow") |
| 171 | http.Redirect(w, r, next, http.StatusSeeOther) |
| 172 | } |
| 173 | |
| 174 | // sameOrigin reports whether the POST came from the host it is posting to. |
| 175 | // An absent Origin is allowed — curl and older clients send none — but a |
| 176 | // present one must match, and "null" (a sandboxed frame, a file:// page) |
| 177 | // never does. The comparison is against r.Host, not the route's host: an |
| 178 | // alias posts from its own name, and the route's primary is a different |
| 179 | // string. |
| 180 | func sameOrigin(r *http.Request) bool { |
| 181 | o := r.Header.Get("Origin") |
| 182 | if o == "" { |
| 183 | return true |
| 184 | } |
| 185 | u, err := url.Parse(o) |
| 186 | if err != nil || u.Host == "" { |
| 187 | return false |
| 188 | } |
| 189 | return strings.EqualFold(hostOnly(u.Host), hostOnly(r.Host)) |
| 190 | } |
| 191 | |
| 192 | // hostOnly drops the port, if there is one, so :443 and an implicit 443 |
| 193 | // are the same origin here. |
| 194 | func hostOnly(h string) string { |
| 195 | if x, _, err := net.SplitHostPort(h); err == nil { |
| 196 | return x |
| 197 | } |
| 198 | return h |
| 199 | } |
| 200 | |
| 201 | // SafeNext is the entire open-redirect surface: a path on this host, or |
| 202 | // "/". Exported because the edge sanitises the login POST's `next` with it |
| 203 | // before resolving the upstream (spec §4.2) — one sanitiser, so the path |
| 204 | // the edge routes a login POST by and the path it redirects to cannot |
| 205 | // differ. |
| 206 | func SafeNext(next string) string { |
| 207 | // A control byte — a tab above all — survives net/http's header writer, |
| 208 | // which rewrites only CR and LF, and browsers strip it before they |
| 209 | // resolve the URL: "/\t/evil.example/" would leave here intact and land |
| 210 | // as "//evil.example/". Refuse the lot before the shape is judged. |
| 211 | if strings.IndexFunc(next, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { |
| 212 | return "/" |
| 213 | } |
| 214 | if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") { |
| 215 | return "/" |
| 216 | } |
| 217 | // Bouncing back to the login path lands on a page the app behind the |
| 218 | // curtain does not serve; root is the honest destination. |
| 219 | p, _, _ := strings.Cut(next, "?") |
| 220 | p, _, _ = strings.Cut(p, "#") |
| 221 | if p == LoginPath { |
| 222 | return "/" |
| 223 | } |
| 224 | return next |
| 225 | } |
| 226 | |
| 227 | // clientKey is the remote address without its port. A backhauled request's |
| 228 | // address is the sibling edge's, which folds every hop from that edge into |
| 229 | // one bucket — stricter, never looser, and named in the spec (§4.2). |
| 230 | func clientKey(r *http.Request) string { |
| 231 | if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { |
| 232 | return h |
| 233 | } |
| 234 | return r.RemoteAddr |
| 235 | } |
| 236 | |
| 237 | // stripCookie removes the gate cookie from the request so the app behind |
| 238 | // the curtain never sees it. Other cookies pass through in their order. |
| 239 | // |
| 240 | // It re-serialises from r.Cookies() rather than editing the header text, |
| 241 | // which has two consequences worth knowing: a cookie Go's parser drops as |
| 242 | // malformed does not reach the app either, and a quoted value arrives |
| 243 | // unquoted, because that is what Cookie.Value holds. Both are narrower |
| 244 | // than what the app would have received unproxied, and neither has ever |
| 245 | // mattered for a real client — but an app that hand-parses exotic cookies |
| 246 | // is looking at this function, not at a browser bug. |
| 247 | func stripCookie(r *http.Request) { |
| 248 | cookies := r.Cookies() |
| 249 | r.Header.Del("Cookie") |
| 250 | parts := make([]string, 0, len(cookies)) |
| 251 | for _, c := range cookies { |
| 252 | if c.Name != CookieName { |
| 253 | parts = append(parts, c.Name+"="+c.Value) |
| 254 | } |
| 255 | } |
| 256 | if len(parts) > 0 { |
| 257 | r.Header.Set("Cookie", strings.Join(parts, "; ")) |
| 258 | } |
| 259 | } |
| 260 | |