carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
18package gate
19
20import (
21 "fmt"
22 "net"
23 "net/http"
24 "net/url"
25 "strings"
26 "time"
27
28 "golang.org/x/crypto/bcrypt"
29)
30
31const (
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.
53func 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).
66func 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
77func 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.
83type 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.
90func 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.
110func (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
129func (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.
180func 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.
194func 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.
206func 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).
230func 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.
247func 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