vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
6c84d387
Keenan Nemetz
16d ago
|
1 | package web |
| 2 | ||
| 3 | // Browser authentication: passkey ceremonies, sessions and CSRF. | |
| 4 | // | |
| 5 | // The browser is a second credential for an account that already exists; it is | |
|
62576fa8
Keenan Nemetz
14d ago
|
6 | // not a second account. A passkey assertion resolves to the same account name an |
| 7 | // SSH key does, and every write still goes through that name. | |
|
6c84d387
Keenan Nemetz
16d ago
|
8 | |
| 9 | import ( | |
| 10 | "context" | |
| 11 | "crypto/rand" | |
| 12 | "crypto/subtle" | |
| 13 | "encoding/base64" | |
| 14 | "encoding/json" | |
| 15 | "errors" | |
| 16 | "fmt" | |
|
f09ef39e
Keenan Nemetz
15d ago
|
17 | "net" |
|
6c84d387
Keenan Nemetz
16d ago
|
18 | "net/http" |
| 19 | "net/url" | |
| 20 | "strings" | |
| 21 | "sync" | |
| 22 | "time" | |
| 23 | ||
| 24 | "github.com/go-webauthn/webauthn/protocol" | |
| 25 | "github.com/go-webauthn/webauthn/webauthn" | |
| 26 | ||
|
d37d301f
Keenan Nemetz
19h ago
|
27 | "rodent.software/vole/forge/access" |
| 28 | "rodent.software/vole/forge/config" | |
| 29 | "rodent.software/vole/forge/identity" | |
| 30 | "rodent.software/vole/forge/session" | |
| 31 | "rodent.software/vole/internal/compress" | |
|
6c84d387
Keenan Nemetz
16d ago
|
32 | ) |
| 33 | ||
| 34 | const ( | |
| 35 | sessionCookieName = "__Host-vole_session" | |
| 36 | ceremonyCookieName = "__Host-vole_ceremony" | |
| 37 | ceremonyTTL = 5 * time.Minute | |
|
f09ef39e
Keenan Nemetz
15d ago
|
38 | |
| 39 | // Development login uses its own cookie because __Host- requires Secure, | |
| 40 | // and a loopback HTTP preview cannot set a Secure cookie. The name is | |
| 41 | // deliberately not a __Host- name: the browser must be willing to store it. | |
| 42 | devSessionCookieName = "vole_dev_session" | |
| 43 | devOffCookieName = "vole_dev_login_off" | |
|
6c84d387
Keenan Nemetz
16d ago
|
44 | ) |
| 45 | ||
| 46 | type Auth struct { | |
| 47 | WA *webauthn.WebAuthn | |
| 48 | RPID string | |
| 49 | Origin string | |
| 50 | Disabled string | |
| 51 | } | |
| 52 | ||
| 53 | func (a *Auth) Enabled() bool { return a != nil && a.WA != nil } | |
| 54 | ||
| 55 | // newAuth enables passkeys only when the operator has told Vole the canonical | |
| 56 | // HTTPS origin. WebAuthn is bound to that origin; guessing it from Host would | |
| 57 | // make a misconfigured proxy an authentication bypass. | |
| 58 | func newAuth(cfg *config.Config) (*Auth, error) { | |
| 59 | if strings.TrimSpace(cfg.PublicURL) == "" { | |
|
ab326d3a
Keenan Nemetz
14d ago
|
60 | return &Auth{Disabled: "set public_url to an https URL to enable browser sign-in"}, nil |
|
6c84d387
Keenan Nemetz
16d ago
|
61 | } |
| 62 | u, err := url.Parse(cfg.PublicURL) | |
| 63 | if err != nil || u.Scheme != "https" || u.Host == "" { | |
|
ab326d3a
Keenan Nemetz
14d ago
|
64 | return &Auth{Disabled: "browser sign-in needs public_url to be an https URL"}, nil |
|
6c84d387
Keenan Nemetz
16d ago
|
65 | } |
| 66 | rpID := strings.ToLower(u.Hostname()) | |
| 67 | if rpID == "" { | |
| 68 | return &Auth{Disabled: "public_url has no hostname"}, nil | |
| 69 | } | |
| 70 | origin := u.Scheme + "://" + u.Host | |
| 71 | wa, err := webauthn.New(&webauthn.Config{ | |
| 72 | RPID: rpID, | |
| 73 | RPDisplayName: "Vole", | |
| 74 | RPOrigins: []string{origin}, | |
| 75 | AttestationPreference: protocol.PreferNoAttestation, | |
| 76 | AuthenticatorSelection: protocol.AuthenticatorSelection{ | |
| 77 | UserVerification: protocol.VerificationRequired, | |
|
ad79b7ab
Keenan Nemetz
3d ago
|
78 | ResidentKey: protocol.ResidentKeyRequirementRequired, |
|
6c84d387
Keenan Nemetz
16d ago
|
79 | }, |
| 80 | }) | |
| 81 | if err != nil { | |
| 82 | return nil, fmt.Errorf("webauthn: %w", err) | |
| 83 | } | |
| 84 | return &Auth{WA: wa, RPID: rpID, Origin: origin}, nil | |
| 85 | } | |
| 86 | ||
| 87 | type principalKey struct{} | |
| 88 | ||
| 89 | type principal struct { | |
| 90 | account *identity.Account | |
| 91 | session *session.Session | |
| 92 | } | |
| 93 | ||
| 94 | func (w *Web) principal(r *http.Request) *principal { | |
| 95 | p, _ := r.Context().Value(principalKey{}).(*principal) | |
| 96 | return p | |
| 97 | } | |
| 98 | ||
|
e84a66f5
Keenan Nemetz
15d ago
|
99 | // statusWriter records whether the response has started. A recover can still |
| 100 | // answer with the styled 500 page only while the header is unsent; once bytes | |
| 101 | // are on the wire the connection is the only thing left to close. | |
| 102 | type statusWriter struct { | |
| 103 | http.ResponseWriter | |
| 104 | wroteHeader bool | |
|
4506a4d3
Keenan Nemetz
14d ago
|
105 | status int |
|
e84a66f5
Keenan Nemetz
15d ago
|
106 | } |
| 107 | ||
| 108 | func (s *statusWriter) WriteHeader(code int) { | |
|
4506a4d3
Keenan Nemetz
14d ago
|
109 | if !s.wroteHeader { |
| 110 | s.status = code | |
| 111 | } | |
|
e84a66f5
Keenan Nemetz
15d ago
|
112 | s.wroteHeader = true |
| 113 | s.ResponseWriter.WriteHeader(code) | |
| 114 | } | |
| 115 | ||
| 116 | func (s *statusWriter) Write(b []byte) (int, error) { | |
|
4506a4d3
Keenan Nemetz
14d ago
|
117 | if !s.wroteHeader { |
| 118 | s.wroteHeader = true | |
| 119 | s.status = http.StatusOK | |
| 120 | } | |
|
e84a66f5
Keenan Nemetz
15d ago
|
121 | return s.ResponseWriter.Write(b) |
| 122 | } | |
| 123 | ||
| 124 | // Flush keeps streaming handlers (git's side-band progress) working through | |
| 125 | // the wrapper. | |
| 126 | func (s *statusWriter) Flush() { | |
| 127 | if f, ok := s.ResponseWriter.(http.Flusher); ok { | |
| 128 | f.Flush() | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | func (s *statusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter } | |
| 133 | ||
|
6c84d387
Keenan Nemetz
16d ago
|
134 | // Middleware resolves the session cookie once per request and sets the browser |
| 135 | // security headers every response carries. An invalid or expired cookie is | |
| 136 | // cleared rather than left to be retried on every page. | |
| 137 | func (w *Web) Middleware(next http.Handler) http.Handler { | |
| 138 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
139 | w.securityHeaders(rw, r) |
|
5d440e37
Keenan Nemetz
10d ago
|
140 | r = withMemo(r) |
|
6c84d387
Keenan Nemetz
16d ago
|
141 | if w.Sessions != nil { |
|
f09ef39e
Keenan Nemetz
15d ago
|
142 | sess, cookieName := w.sessionFromCookie(r) |
| 143 | if sess != nil { | |
|
62576fa8
Keenan Nemetz
14d ago
|
144 | if acct, err := w.Ident.Find(sess.Name); err == nil && acct.Active() { |
|
f09ef39e
Keenan Nemetz
15d ago
|
145 | r = w.withPrincipal(r, acct, sess) |
|
6c84d387
Keenan Nemetz
16d ago
|
146 | } else { |
|
f09ef39e
Keenan Nemetz
15d ago
|
147 | w.clearCookieFor(rw, cookieName, cookieName != devSessionCookieName) |
| 148 | } | |
| 149 | } else if cookieName != "" { | |
| 150 | w.clearCookieFor(rw, cookieName, cookieName != devSessionCookieName) | |
| 151 | } | |
| 152 | // Development login: only when configured, only from loopback, and | |
| 153 | // only after an explicit logout has not opted out for this browser. | |
| 154 | if w.principal(r) == nil && w.devLoginRequest(r) { | |
| 155 | if sess := w.ensureDevSession(); sess != nil { | |
|
62576fa8
Keenan Nemetz
14d ago
|
156 | if acct, err := w.Ident.Find(sess.Name); err == nil && acct.Active() { |
|
f09ef39e
Keenan Nemetz
15d ago
|
157 | w.setDevSessionCookie(rw, sess) |
| 158 | r = w.withPrincipal(r, acct, sess) | |
| 159 | } | |
|
6c84d387
Keenan Nemetz
16d ago
|
160 | } |
| 161 | } | |
| 162 | } | |
|
e84a66f5
Keenan Nemetz
15d ago
|
163 | sw := &statusWriter{ResponseWriter: rw} |
|
a76b2f38
Keenan Nemetz
12d ago
|
164 | // gzip is per request: the status recorder under it is what records the |
| 165 | // status and the page-view metric, and the compressor only shapes bytes. | |
| 166 | gw := compress.New(sw, r) | |
|
e84a66f5
Keenan Nemetz
15d ago
|
167 | defer func() { |
| 168 | v := recover() | |
| 169 | if v == nil { | |
| 170 | return | |
| 171 | } | |
| 172 | if w.Logger != nil { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
173 | w.Logger("panic %s %s: %v", r.Method, loggablePath(r.URL.Path), v) |
|
e84a66f5
Keenan Nemetz
15d ago
|
174 | } |
| 175 | if sw.wroteHeader { | |
| 176 | // The reply is already partial; let net/http close the connection | |
| 177 | // rather than append a page to an archive or a git stream. | |
| 178 | panic(v) | |
| 179 | } | |
| 180 | w.serverError(sw, r, "") | |
| 181 | }() | |
|
306f36fc
Keenan Nemetz
12d ago
|
182 | next.ServeHTTP(gw, r) |
| 183 | gw.Close() | |
|
4506a4d3
Keenan Nemetz
14d ago
|
184 | w.recordPageView(r, sw) |
|
6c84d387
Keenan Nemetz
16d ago
|
185 | }) |
| 186 | } | |
| 187 | ||
|
4506a4d3
Keenan Nemetz
14d ago
|
188 | // recordPageView counts a successfully rendered HTML page. Static files, Git |
|
43e681d8
Keenan Nemetz
4d ago
|
189 | // traffic, redirects, HTMX fragments and the admin surface itself are excluded, so |
|
4506a4d3
Keenan Nemetz
14d ago
|
190 | // the metric follows reading rather than polling or asset loads. It is |
| 191 | // best-effort: analytics must never turn a page into an error. | |
| 192 | func (w *Web) recordPageView(r *http.Request, sw *statusWriter) { | |
| 193 | if w.PageViews == nil || r.Method != http.MethodGet || sw.status != http.StatusOK { | |
| 194 | return | |
| 195 | } | |
|
43e681d8
Keenan Nemetz
4d ago
|
196 | if r.Header.Get("HX-Request") == "true" || adminPath(r.URL.Path) { |
|
4506a4d3
Keenan Nemetz
14d ago
|
197 | return |
| 198 | } | |
| 199 | if !strings.HasPrefix(sw.Header().Get("Content-Type"), "text/html") { | |
| 200 | return | |
| 201 | } | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
202 | // A module discovery probe is answered with HTML but is not a page. |
| 203 | if r.URL.Query().Get("go-get") != "" { | |
| 204 | return | |
| 205 | } | |
|
4506a4d3
Keenan Nemetz
14d ago
|
206 | account := "" |
| 207 | if pr := w.principal(r); pr != nil { | |
| 208 | account = pr.account.Name | |
| 209 | } | |
| 210 | if err := w.PageViews.Record(account, time.Now().UTC()); err != nil && w.Logger != nil { | |
| 211 | w.Logger("page view: %v", err) | |
| 212 | } | |
| 213 | } | |
| 214 | ||
|
43e681d8
Keenan Nemetz
4d ago
|
215 | // adminPath reports whether a path belongs to the instance-admin surface. Those |
| 216 | // pages are read by the same few people watching the instance, so counting them | |
| 217 | // would measure administration rather than readership. | |
| 218 | func adminPath(path string) bool { | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
219 | return path == "/-/admin" || path == "/-/status" || path == "/-/users" || strings.HasPrefix(path, "/-/users/") |
|
43e681d8
Keenan Nemetz
4d ago
|
220 | } |
| 221 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
222 | // sessionFromCookie returns the first valid session cookie and the name it came |
| 223 | // from. The dev cookie is only considered when dev login is enabled, so a stray | |
| 224 | // cookie on a normal instance is never a credential. | |
| 225 | func (w *Web) sessionFromCookie(r *http.Request) (*session.Session, string) { | |
| 226 | names := []string{sessionCookieName} | |
| 227 | if w.devLoginRequest(r) { | |
| 228 | names = append(names, devSessionCookieName) | |
| 229 | } | |
| 230 | for _, name := range names { | |
| 231 | c, err := r.Cookie(name) | |
| 232 | if err != nil || c.Value == "" { | |
| 233 | continue | |
| 234 | } | |
| 235 | sess, err := w.Sessions.Lookup(c.Value) | |
| 236 | if err == nil { | |
| 237 | return sess, name | |
| 238 | } | |
| 239 | return nil, name | |
| 240 | } | |
| 241 | return nil, "" | |
| 242 | } | |
| 243 | ||
| 244 | func (w *Web) withPrincipal(r *http.Request, acct *identity.Account, sess *session.Session) *http.Request { | |
| 245 | return r.WithContext(context.WithValue(r.Context(), principalKey{}, &principal{account: acct, session: sess})) | |
| 246 | } | |
| 247 | ||
| 248 | // enableDevLogin resolves the configured account once. It refuses to start when | |
| 249 | // the listener is not loopback: a typo must not turn a shared instance into one | |
|
ed181212
Keenan Nemetz
13d ago
|
250 | // that signs everybody in as the same person. Config.Validate makes the same |
| 251 | // checks; this one keeps the Web honest when it is built directly. | |
|
f09ef39e
Keenan Nemetz
15d ago
|
252 | func (w *Web) enableDevLogin(cfg *config.Config) error { |
| 253 | name := strings.TrimSpace(cfg.DevLogin) | |
| 254 | if name == "" { | |
| 255 | return nil | |
| 256 | } | |
| 257 | if !loopbackListen(cfg.HTTPAddr) { | |
| 258 | return fmt.Errorf("dev_login requires http_addr on a loopback address, got %q", cfg.HTTPAddr) | |
| 259 | } | |
|
ed181212
Keenan Nemetz
13d ago
|
260 | if strings.TrimSpace(cfg.HTTPSAddr) != "" { |
| 261 | return fmt.Errorf("dev_login cannot be combined with https_addr") | |
| 262 | } | |
| 263 | if public, err := config.ParsePublicURL(cfg.PublicURL); err == nil && public != nil && !loopbackHost(public.Hostname()) { | |
| 264 | return fmt.Errorf("dev_login cannot be combined with a public_url that is not loopback (%q)", cfg.PublicURL) | |
| 265 | } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
266 | acct, err := w.Ident.Find(name) |
| 267 | if err != nil { | |
| 268 | return fmt.Errorf("dev_login %q: %w", name, err) | |
| 269 | } | |
| 270 | if !acct.Active() { | |
| 271 | return fmt.Errorf("dev_login %q: account is not active", name) | |
| 272 | } | |
| 273 | w.devAccount = acct | |
| 274 | return nil | |
| 275 | } | |
| 276 | ||
| 277 | func (w *Web) devLoginRequest(r *http.Request) bool { | |
|
ed181212
Keenan Nemetz
13d ago
|
278 | if w.devAccount == nil || !w.loopbackBrowser(r) { |
|
f09ef39e
Keenan Nemetz
15d ago
|
279 | return false |
| 280 | } | |
| 281 | if c, err := r.Cookie(devOffCookieName); err == nil && c.Value != "" { | |
| 282 | return false | |
| 283 | } | |
| 284 | return true | |
| 285 | } | |
| 286 | ||
| 287 | func (w *Web) ensureDevSession() *session.Session { | |
| 288 | w.devMu.Lock() | |
| 289 | defer w.devMu.Unlock() | |
| 290 | if w.devSess != nil { | |
| 291 | if sess, err := w.Sessions.Lookup(w.devSess.Token); err == nil { | |
| 292 | return sess | |
| 293 | } | |
| 294 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
295 | sess, err := w.Sessions.Create(w.devAccount.Name, nil, "dev-login") |
|
f09ef39e
Keenan Nemetz
15d ago
|
296 | if err != nil { |
| 297 | w.Logger("dev login: create session: %v", err) | |
| 298 | return nil | |
| 299 | } | |
| 300 | w.devSess = sess | |
| 301 | return sess | |
| 302 | } | |
| 303 | ||
| 304 | func loopbackListen(addr string) bool { | |
| 305 | host, _, err := net.SplitHostPort(addr) | |
| 306 | return err == nil && loopbackHost(host) | |
| 307 | } | |
| 308 | ||
| 309 | func loopbackHost(host string) bool { | |
| 310 | if strings.EqualFold(host, "localhost") { | |
| 311 | return true | |
| 312 | } | |
| 313 | ip := net.ParseIP(host) | |
| 314 | return ip != nil && ip.IsLoopback() | |
| 315 | } | |
| 316 | ||
|
ed181212
Keenan Nemetz
13d ago
|
317 | // loopbackBrowser checks both ends. A loopback listener behind a reverse proxy |
|
f09ef39e
Keenan Nemetz
15d ago
|
318 | // sees loopback as RemoteAddr, so the Host check is what stops a public request |
| 319 | // from inheriting a development session. | |
|
ed181212
Keenan Nemetz
13d ago
|
320 | // |
| 321 | // The Host header alone is not enough: a proxy is free to rewrite it to | |
| 322 | // 127.0.0.1, which is exactly the request that must not sign anybody in. So the | |
| 323 | // host has to equal the configured listener address, and a request carrying a | |
| 324 | // forwarding header is refused outright — a proxy that does not set one is | |
| 325 | // unusual, and this check errs toward no session. | |
| 326 | func (w *Web) loopbackBrowser(r *http.Request) bool { | |
|
f09ef39e
Keenan Nemetz
15d ago
|
327 | remote, _, err := net.SplitHostPort(r.RemoteAddr) |
| 328 | if err != nil || !loopbackHost(remote) { | |
| 329 | return false | |
| 330 | } | |
|
ed181212
Keenan Nemetz
13d ago
|
331 | if w.Cfg == nil { |
| 332 | return false // no configured address to compare against | |
| 333 | } | |
| 334 | for _, h := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "X-Real-Ip"} { | |
| 335 | if r.Header.Get(h) != "" { | |
| 336 | return false | |
| 337 | } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
338 | } |
|
ed181212
Keenan Nemetz
13d ago
|
339 | wantHost, wantPort, err := net.SplitHostPort(strings.TrimSpace(w.Cfg.HTTPAddr)) |
| 340 | if err != nil || wantHost == "" { | |
| 341 | return false | |
| 342 | } | |
| 343 | host, port := splitHostPort(r.Host) | |
| 344 | if port != wantPort { | |
| 345 | return false | |
| 346 | } | |
| 347 | // A development browser may type localhost where the config says 127.0.0.1. | |
| 348 | // A proxy rewriting Host to a loopback name still has to guess the exact port, | |
| 349 | // and it would also have to strip every forwarding header it normally adds. | |
| 350 | return strings.EqualFold(host, wantHost) || (loopbackHost(host) && loopbackHost(wantHost)) | |
| 351 | } | |
| 352 | ||
| 353 | func splitHostPort(addr string) (host, port string) { | |
| 354 | if h, p, err := net.SplitHostPort(addr); err == nil { | |
| 355 | return h, p | |
|
f09ef39e
Keenan Nemetz
15d ago
|
356 | } |
|
ed181212
Keenan Nemetz
13d ago
|
357 | return addr, "" |
|
f09ef39e
Keenan Nemetz
15d ago
|
358 | } |
| 359 | ||
|
edf9db6e
Keenan Nemetz
13d ago
|
360 | func (w *Web) securityHeaders(rw http.ResponseWriter, r *http.Request) { |
|
6c84d387
Keenan Nemetz
16d ago
|
361 | rw.Header().Set("X-Content-Type-Options", "nosniff") |
| 362 | rw.Header().Set("X-Frame-Options", "DENY") | |
| 363 | rw.Header().Set("Referrer-Policy", "same-origin") | |
|
701232cf
Keenan Nemetz
12d ago
|
364 | // There is no inline style in any template and none is generated, so the |
| 365 | // stylesheet is the only style source. object-src is closed because nothing here | |
|
306f36fc
Keenan Nemetz
12d ago
|
366 | // embeds a plugin, and a page that never loads one cannot be made to. The |
| 367 | // syntax highlighter runs in a worker of its own, hence worker-src. | |
|
6c84d387
Keenan Nemetz
16d ago
|
368 | rw.Header().Set("Content-Security-Policy", |
|
306f36fc
Keenan Nemetz
12d ago
|
369 | "default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'") |
|
701232cf
Keenan Nemetz
12d ago
|
370 | // Nothing on this page needs a device, a payment API or an interest cohort, and |
| 371 | // the site runs beside its own pages on a pages subdomain: COOP/CORP keep a | |
| 372 | // document from being opened or read as somebody else's resource. | |
| 373 | rw.Header().Set("Permissions-Policy", | |
| 374 | "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()") | |
| 375 | rw.Header().Set("Cross-Origin-Opener-Policy", "same-origin") | |
| 376 | rw.Header().Set("Cross-Origin-Resource-Policy", "same-origin") | |
|
edf9db6e
Keenan Nemetz
13d ago
|
377 | // HSTS belongs to the transport, not to passkeys: an instance behind a TLS |
| 378 | // proxy with no browser sign-in is just as much an HTTPS instance, and a | |
| 379 | // browser that learns that here cannot be downgraded later. | |
| 380 | if w.servesHTTPS(r) { | |
|
6c84d387
Keenan Nemetz
16d ago
|
381 | rw.Header().Set("Strict-Transport-Security", "max-age=31536000") |
| 382 | } | |
| 383 | } | |
| 384 | ||
|
edf9db6e
Keenan Nemetz
13d ago
|
385 | // servesHTTPS reports whether browsers reach this instance over TLS: either this |
| 386 | // request arrived over it, or the canonical URL says a proxy terminates it. The | |
| 387 | // configured https_addr is deliberately not enough on its own, because the same | |
| 388 | // process may be answering ACME challenges on plain HTTP. | |
| 389 | func (w *Web) servesHTTPS(r *http.Request) bool { | |
| 390 | if r.TLS != nil { | |
| 391 | return true | |
| 392 | } | |
| 393 | if w.Cfg == nil { | |
| 394 | return false | |
| 395 | } | |
| 396 | if u, err := config.ParsePublicURL(w.Cfg.PublicURL); err == nil && u != nil { | |
| 397 | return u.Scheme == "https" | |
| 398 | } | |
| 399 | return false | |
| 400 | } | |
| 401 | ||
| 402 | // noStore marks a page that must not be kept anywhere: an enrollment link, a | |
| 403 | // sign-in form, an account page. The referrer is dropped as well, because the | |
| 404 | // page's URL may itself be a secret a click would carry to somebody else. | |
| 405 | func (w *Web) noStore(rw http.ResponseWriter) { | |
| 406 | rw.Header().Set("Cache-Control", "no-store") | |
| 407 | rw.Header().Set("Pragma", "no-cache") | |
| 408 | rw.Header().Set("Referrer-Policy", "no-referrer") | |
| 409 | } | |
| 410 | ||
|
6c84d387
Keenan Nemetz
16d ago
|
411 | func (w *Web) enabled() bool { return w.Auth != nil && w.Auth.Enabled() } |
| 412 | ||
|
f920a9f1
Keenan Nemetz
16d ago
|
413 | // registrationEnabled is a policy switch on top of passkey availability: an |
|
ab326d3a
Keenan Nemetz
14d ago
|
414 | // instance can have browser sign-in without accepting new accounts. |
|
f920a9f1
Keenan Nemetz
16d ago
|
415 | func (w *Web) registrationEnabled() bool { |
| 416 | return w.enabled() && w.Cfg != nil && w.Cfg.OpenRegistration | |
| 417 | } | |
| 418 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
419 | func (w *Web) accountName(r *http.Request) string { |
|
6c84d387
Keenan Nemetz
16d ago
|
420 | if p := w.principal(r); p != nil { |
|
62576fa8
Keenan Nemetz
14d ago
|
421 | return p.account.Name |
|
6c84d387
Keenan Nemetz
16d ago
|
422 | } |
| 423 | return "" | |
| 424 | } | |
| 425 | ||
| 426 | func (w *Web) canDiscuss(r *http.Request, acl *access.ACL) bool { | |
|
c717d4eb
Keenan Nemetz
13d ago
|
427 | return canDiscuss(acl, w.accountName(r)) |
|
6c84d387
Keenan Nemetz
16d ago
|
428 | } |
| 429 | ||
| 430 | func (w *Web) page(r *http.Request, title string) Page { | |
|
3d707ea7
Keenan Nemetz
21h ago
|
431 | p := Page{Title: title, Section: pageSection(r.URL.Path), memo: memoOf(r), InstanceName: w.instanceName()} |
|
6c84d387
Keenan Nemetz
16d ago
|
432 | if pr := w.principal(r); pr != nil { |
| 433 | p.LoggedIn = true | |
|
62576fa8
Keenan Nemetz
14d ago
|
434 | p.User = pr.account.Name |
| 435 | p.AccountName = pr.account.Name | |
|
6c84d387
Keenan Nemetz
16d ago
|
436 | p.CSRF = pr.session.CSRF |
| 437 | } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
438 | if w.devAccount != nil { |
| 439 | p.DevLogin = w.devAccount.Name | |
| 440 | } | |
|
2973f727
Keenan Nemetz
15d ago
|
441 | if p.LoggedIn && w.Ident != nil { |
|
62576fa8
Keenan Nemetz
14d ago
|
442 | p.InstanceAdmin = w.Ident.IsAdmin(p.AccountName) |
|
2973f727
Keenan Nemetz
15d ago
|
443 | } |
|
6ce635a8
Keenan Nemetz
1d ago
|
444 | if w.InstanceRepo != nil { |
| 445 | if snap := w.InstanceRepo.Current(); snap != nil { | |
| 446 | v := snap.Commit | |
| 447 | if len(v) > 12 { | |
| 448 | v = v[:12] | |
| 449 | } | |
| 450 | if len(snap.CSS) > 0 { | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
451 | p.InstanceCSS = "/-/static/custom/profile/custom.css?v=" + v |
|
6ce635a8
Keenan Nemetz
1d ago
|
452 | } |
| 453 | if len(snap.Favicon) > 0 { | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
454 | p.InstanceFavicon = "/-/static/custom/profile/favicon.svg?v=" + v |
|
6ce635a8
Keenan Nemetz
1d ago
|
455 | } |
|
f7a34d87
Keenan Nemetz
1d ago
|
456 | for _, t := range snap.Themes { |
| 457 | p.InstanceThemes = append(p.InstanceThemes, instanceThemeView{ | |
| 458 | ID: t.ID, | |
| 459 | Name: t.Name, | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
460 | URL: "/-/static/custom/themes/" + t.ID + ".css?v=" + v, |
|
f7a34d87
Keenan Nemetz
1d ago
|
461 | }) |
| 462 | } | |
|
6ce635a8
Keenan Nemetz
1d ago
|
463 | } |
| 464 | } | |
|
816cc543
Keenan Nemetz
11d ago
|
465 | if w.Repos != nil { |
|
ed03b3d1
Keenan Nemetz
6h ago
|
466 | // A path outside /-/ names a repository in its first segment. A profile |
| 467 | // (/-/u/{name}) and the admin account page (/-/users/{name}) share the | |
| 468 | // `{name}` wildcard but are the forge's own pages, not repositories. | |
| 469 | if name := r.PathValue("name"); name != "" && !strings.HasPrefix(r.URL.Path, "/-/") { | |
|
5d440e37
Keenan Nemetz
10d ago
|
470 | p.RepoName = name |
| 471 | if m, err := w.repo(p.memo, name); err == nil { | |
|
816cc543
Keenan Nemetz
11d ago
|
472 | p.CloneURL = w.cloneURL(r, name) |
|
268b9368
Keenan Nemetz
21h ago
|
473 | p.SSHURL = w.sshURL(name) |
|
816cc543
Keenan Nemetz
11d ago
|
474 | if p.LoggedIn || (w.Cfg != nil && strings.TrimSpace(w.Cfg.PagesDomain) != "") { |
| 475 | if acl, err := m.ACL(); err == nil { | |
| 476 | if p.LoggedIn { | |
| 477 | // Instance admins get the Settings tab for its access section, | |
| 478 | // even when the repository has no role for them. | |
| 479 | p.CanAdmin = p.InstanceAdmin || acl.Can(p.AccountName, access.Admin) | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
480 | // A maintainer sees the tab for the repository's secrets, |
| 481 | // which are the credentials the jobs they approve will use. | |
| 482 | p.CanMaintain = acl.CanTriage(p.AccountName) | |
|
816cc543
Keenan Nemetz
11d ago
|
483 | } |
| 484 | if w.siteDeployed(m, acl) { | |
| 485 | p.Site = w.siteURL(r, name) | |
| 486 | } | |
| 487 | } | |
|
1948358f
Keenan Nemetz
15d ago
|
488 | } |
| 489 | } | |
| 490 | } | |
| 491 | } | |
|
6c84d387
Keenan Nemetz
16d ago
|
492 | return p |
| 493 | } | |
| 494 | ||
| 495 | func (w *Web) setSessionCookie(rw http.ResponseWriter, sess *session.Session) { | |
| 496 | http.SetCookie(rw, &http.Cookie{ | |
| 497 | Name: sessionCookieName, | |
| 498 | Value: sess.Token, | |
| 499 | Path: "/", | |
| 500 | HttpOnly: true, | |
| 501 | Secure: true, | |
| 502 | SameSite: http.SameSiteLaxMode, | |
| 503 | Expires: sess.Expires, | |
| 504 | MaxAge: int(time.Until(sess.Expires).Seconds()), | |
| 505 | }) | |
| 506 | } | |
| 507 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
508 | func (w *Web) setDevSessionCookie(rw http.ResponseWriter, sess *session.Session) { |
| 509 | http.SetCookie(rw, &http.Cookie{ | |
| 510 | Name: devSessionCookieName, | |
| 511 | Value: sess.Token, | |
| 512 | Path: "/", | |
| 513 | HttpOnly: true, | |
| 514 | // Secure is false on purpose: dev login exists for a loopback HTTP | |
| 515 | // preview. It is never set unless dev_login is configured. | |
| 516 | SameSite: http.SameSiteLaxMode, | |
| 517 | Expires: sess.Expires, | |
| 518 | MaxAge: int(time.Until(sess.Expires).Seconds()), | |
| 519 | }) | |
| 520 | } | |
| 521 | ||
|
6c84d387
Keenan Nemetz
16d ago
|
522 | func (w *Web) clearCookie(rw http.ResponseWriter, name string) { |
|
f09ef39e
Keenan Nemetz
15d ago
|
523 | w.clearCookieFor(rw, name, true) |
| 524 | } | |
| 525 | ||
| 526 | func (w *Web) clearCookieFor(rw http.ResponseWriter, name string, secure bool) { | |
|
6c84d387
Keenan Nemetz
16d ago
|
527 | http.SetCookie(rw, &http.Cookie{ |
| 528 | Name: name, | |
| 529 | Value: "", | |
| 530 | Path: "/", | |
| 531 | HttpOnly: true, | |
|
f09ef39e
Keenan Nemetz
15d ago
|
532 | Secure: secure, |
|
6c84d387
Keenan Nemetz
16d ago
|
533 | SameSite: http.SameSiteLaxMode, |
| 534 | MaxAge: -1, | |
| 535 | }) | |
| 536 | } | |
| 537 | ||
|
edf9db6e
Keenan Nemetz
13d ago
|
538 | // checkCSRF is the JSON endpoints' check: the token must arrive in the |
| 539 | // X-Vole-CSRF header. It never parses the body, so a handler that has not bounded | |
| 540 | // the request cannot be made to parse one here. | |
|
6c84d387
Keenan Nemetz
16d ago
|
541 | func (w *Web) checkCSRF(r *http.Request) bool { |
|
701232cf
Keenan Nemetz
12d ago
|
542 | if !w.sameOriginRequest(r) { |
| 543 | return false | |
| 544 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
545 | return w.csrfToken(r, r.Header.Get("X-Vole-CSRF")) |
| 546 | } | |
| 547 | ||
| 548 | // csrfOK is the form endpoints' check: the header, or the csrf field of a body | |
| 549 | // that readForm has already bounded and parsed. | |
| 550 | func (w *Web) csrfOK(r *http.Request) bool { | |
|
701232cf
Keenan Nemetz
12d ago
|
551 | if !w.sameOriginRequest(r) { |
| 552 | return false | |
| 553 | } | |
|
6c84d387
Keenan Nemetz
16d ago
|
554 | got := r.Header.Get("X-Vole-CSRF") |
| 555 | if got == "" { | |
| 556 | got = r.PostFormValue("csrf") | |
| 557 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
558 | return w.csrfToken(r, got) |
| 559 | } | |
| 560 | ||
|
701232cf
Keenan Nemetz
12d ago
|
561 | // sameOriginRequest is the second lock on the same door as the CSRF token: when the |
| 562 | // browser says where a request came from, that answer has to be this instance. A | |
| 563 | // missing Origin and Referer is not evidence against a request — some clients and | |
| 564 | // privacy settings send neither — so this can only refuse a request that named | |
| 565 | // somewhere else. | |
| 566 | func (w *Web) sameOriginRequest(r *http.Request) bool { | |
| 567 | origin := strings.TrimSpace(r.Header.Get("Origin")) | |
| 568 | if origin == "" { | |
| 569 | origin = strings.TrimSpace(r.Header.Get("Referer")) | |
| 570 | } | |
| 571 | if origin == "" { | |
| 572 | return true | |
| 573 | } | |
| 574 | u, err := url.Parse(origin) | |
| 575 | if err != nil || u.Scheme == "" || u.Host == "" { | |
| 576 | return false | |
| 577 | } | |
| 578 | have := u.Scheme + "://" + u.Host | |
| 579 | want := w.canonicalBase(r) | |
| 580 | if have == want { | |
| 581 | return true | |
| 582 | } | |
| 583 | // Behind a proxy the browser's origin is the public one while the request's own | |
| 584 | // origin is the internal one; public_url is then the only correct comparison, | |
| 585 | // which canonicalBase already used when it is set. | |
| 586 | return w.Cfg != nil && w.Cfg.BaseURL() != "" && strings.EqualFold(have, want) | |
| 587 | } | |
| 588 | ||
|
edf9db6e
Keenan Nemetz
13d ago
|
589 | func (w *Web) csrfToken(r *http.Request, got string) bool { |
| 590 | pr := w.principal(r) | |
| 591 | if pr == nil || pr.session == nil || pr.session.CSRF == "" || got == "" { | |
| 592 | return false | |
| 593 | } | |
|
6c84d387
Keenan Nemetz
16d ago
|
594 | return subtle.ConstantTimeCompare([]byte(got), []byte(pr.session.CSRF)) == 1 |
| 595 | } | |
| 596 | ||
| 597 | func (w *Web) requireHTMLUser(rw http.ResponseWriter, r *http.Request) (*principal, bool) { | |
| 598 | pr := w.principal(r) | |
| 599 | if pr == nil { | |
| 600 | next := url.QueryEscape(r.URL.RequestURI()) | |
|
ed03b3d1
Keenan Nemetz
6h ago
|
601 | http.Redirect(rw, r, "/-/login?next="+next, http.StatusSeeOther) |
|
6c84d387
Keenan Nemetz
16d ago
|
602 | return nil, false |
| 603 | } | |
| 604 | return pr, true | |
| 605 | } | |
| 606 | ||
| 607 | func (w *Web) requireAPIUser(rw http.ResponseWriter, r *http.Request) (*principal, bool) { | |
| 608 | pr := w.principal(r) | |
| 609 | if pr == nil { | |
|
ab326d3a
Keenan Nemetz
14d ago
|
610 | writeJSONError(rw, http.StatusUnauthorized, "sign in first") |
|
6c84d387
Keenan Nemetz
16d ago
|
611 | return nil, false |
| 612 | } | |
| 613 | return pr, true | |
| 614 | } | |
| 615 | ||
| 616 | func writeJSON(rw http.ResponseWriter, status int, v any) { | |
| 617 | rw.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 618 | rw.WriteHeader(status) | |
| 619 | _ = json.NewEncoder(rw).Encode(v) | |
| 620 | } | |
| 621 | ||
| 622 | func writeJSONError(rw http.ResponseWriter, status int, msg string) { | |
| 623 | writeJSON(rw, status, map[string]string{"error": msg}) | |
| 624 | } | |
| 625 | ||
| 626 | // ---------- ceremony state ---------- | |
| 627 | ||
| 628 | type ceremony struct { | |
|
f920a9f1
Keenan Nemetz
16d ago
|
629 | Kind string // login | invite | passkey | register |
|
6c84d387
Keenan Nemetz
16d ago
|
630 | Data webauthn.SessionData |
|
f920a9f1
Keenan Nemetz
16d ago
|
631 | Name string |
|
62576fa8
Keenan Nemetz
14d ago
|
632 | InviteToken string |
|
6c84d387
Keenan Nemetz
16d ago
|
633 | Label string |
| 634 | Expires time.Time | |
| 635 | } | |
| 636 | ||
|
edf9db6e
Keenan Nemetz
13d ago
|
637 | // maxCeremonies bounds the in-flight ceremony map. Without a bound the map is |
| 638 | // memory an anonymous client can grow: each begin is a few hundred bytes, and a | |
| 639 | // client that never finishes one never releases it. Five minutes of TTL and a cap | |
| 640 | // mean the store is a fixed-size window, and a begin past the cap is refused | |
| 641 | // rather than accepted into an unbounded table. | |
| 642 | const maxCeremonies = 4096 | |
| 643 | ||
|
6c84d387
Keenan Nemetz
16d ago
|
644 | type ceremonyStore struct { |
| 645 | mu sync.Mutex | |
| 646 | m map[string]ceremony | |
| 647 | } | |
| 648 | ||
| 649 | func newCeremonyStore() *ceremonyStore { return &ceremonyStore{m: map[string]ceremony{}} } | |
| 650 | ||
| 651 | func (s *ceremonyStore) put(c ceremony) (string, error) { | |
| 652 | id := make([]byte, 32) | |
| 653 | if _, err := rand.Read(id); err != nil { | |
| 654 | return "", err | |
| 655 | } | |
| 656 | key := base64.RawURLEncoding.EncodeToString(id) | |
| 657 | c.Expires = time.Now().UTC().Add(ceremonyTTL) | |
|
edf9db6e
Keenan Nemetz
13d ago
|
658 | now := time.Now().UTC() |
|
6c84d387
Keenan Nemetz
16d ago
|
659 | s.mu.Lock() |
| 660 | defer s.mu.Unlock() | |
|
edf9db6e
Keenan Nemetz
13d ago
|
661 | // Sweep on every put: expired ceremonies are dropped the moment they are no |
| 662 | // longer usable, so a slow client cannot keep the table full. | |
|
6c84d387
Keenan Nemetz
16d ago
|
663 | for k, v := range s.m { |
|
edf9db6e
Keenan Nemetz
13d ago
|
664 | if now.After(v.Expires) { |
|
6c84d387
Keenan Nemetz
16d ago
|
665 | delete(s.m, k) |
| 666 | } | |
| 667 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
668 | if len(s.m) >= maxCeremonies { |
| 669 | return "", fmt.Errorf("too many sign-in attempts in progress; try again in a few minutes") | |
| 670 | } | |
|
6c84d387
Keenan Nemetz
16d ago
|
671 | s.m[key] = c |
| 672 | return key, nil | |
| 673 | } | |
| 674 | ||
| 675 | func (s *ceremonyStore) take(key string) (ceremony, bool) { | |
| 676 | if key == "" { | |
| 677 | return ceremony{}, false | |
| 678 | } | |
| 679 | s.mu.Lock() | |
| 680 | defer s.mu.Unlock() | |
| 681 | c, ok := s.m[key] | |
| 682 | if !ok { | |
| 683 | return ceremony{}, false | |
| 684 | } | |
| 685 | delete(s.m, key) | |
| 686 | if time.Now().UTC().After(c.Expires) { | |
| 687 | return ceremony{}, false | |
| 688 | } | |
| 689 | return c, true | |
| 690 | } | |
| 691 | ||
| 692 | func (w *Web) beginCeremony(rw http.ResponseWriter, c ceremony, v any) error { | |
| 693 | id, err := w.Ceremonies.put(c) | |
| 694 | if err != nil { | |
| 695 | return err | |
| 696 | } | |
| 697 | http.SetCookie(rw, &http.Cookie{ | |
| 698 | Name: ceremonyCookieName, | |
| 699 | Value: id, | |
| 700 | Path: "/", | |
| 701 | HttpOnly: true, | |
| 702 | Secure: true, | |
| 703 | SameSite: http.SameSiteLaxMode, | |
| 704 | MaxAge: int(ceremonyTTL.Seconds()), | |
| 705 | }) | |
| 706 | writeJSON(rw, http.StatusOK, v) | |
| 707 | return nil | |
| 708 | } | |
| 709 | ||
| 710 | func (w *Web) takeCeremony(rw http.ResponseWriter, r *http.Request) (ceremony, bool) { | |
| 711 | c, err := r.Cookie(ceremonyCookieName) | |
| 712 | if err != nil { | |
| 713 | writeJSONError(rw, http.StatusBadRequest, "the ceremony expired; try again") | |
| 714 | return ceremony{}, false | |
| 715 | } | |
| 716 | w.clearCookie(rw, ceremonyCookieName) | |
| 717 | state, ok := w.Ceremonies.take(c.Value) | |
| 718 | if !ok { | |
| 719 | writeJSONError(rw, http.StatusBadRequest, "the ceremony expired; try again") | |
| 720 | return ceremony{}, false | |
| 721 | } | |
| 722 | return state, true | |
| 723 | } | |
| 724 | ||
| 725 | // ---------- WebAuthn user adapter ---------- | |
| 726 | ||
| 727 | type waUser struct { | |
| 728 | account *identity.Account | |
| 729 | creds []webauthn.Credential | |
| 730 | } | |
| 731 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
732 | func (u *waUser) WebAuthnID() []byte { return []byte(u.account.Name) } |
|
6c84d387
Keenan Nemetz
16d ago
|
733 | func (u *waUser) WebAuthnName() string { return u.account.Name } |
| 734 | func (u *waUser) WebAuthnDisplayName() string { return u.account.Name } | |
| 735 | func (u *waUser) WebAuthnCredentials() []webauthn.Credential { return u.creds } | |
| 736 | ||
| 737 | func (w *Web) waUser(a *identity.Account) (*waUser, error) { | |
|
62576fa8
Keenan Nemetz
14d ago
|
738 | creds, err := w.Ident.Credentials(a.Name) |
|
6c84d387
Keenan Nemetz
16d ago
|
739 | if err != nil { |
| 740 | return nil, err | |
| 741 | } | |
| 742 | out := make([]webauthn.Credential, 0, len(creds)) | |
| 743 | for _, c := range creds { | |
| 744 | out = append(out, toWebAuthn(c)) | |
| 745 | } | |
| 746 | return &waUser{account: a, creds: out}, nil | |
| 747 | } | |
| 748 | ||
| 749 | func toWebAuthn(c identity.Credential) webauthn.Credential { | |
| 750 | transports := make([]protocol.AuthenticatorTransport, 0, len(c.Transport)) | |
| 751 | for _, t := range c.Transport { | |
| 752 | transports = append(transports, protocol.AuthenticatorTransport(t)) | |
| 753 | } | |
| 754 | return webauthn.Credential{ | |
| 755 | ID: c.ID, | |
| 756 | PublicKey: c.PublicKey, | |
| 757 | AttestationType: c.AttestationType, | |
| 758 | Transport: transports, | |
| 759 | Flags: webauthn.CredentialFlags{ | |
| 760 | UserPresent: c.Flags.UserPresent, | |
| 761 | UserVerified: c.Flags.UserVerified, | |
| 762 | BackupEligible: c.Flags.BackupEligible, | |
| 763 | BackupState: c.Flags.BackupState, | |
| 764 | }, | |
| 765 | Authenticator: webauthn.Authenticator{ | |
| 766 | AAGUID: c.Authenticator.AAGUID, | |
| 767 | SignCount: c.Authenticator.SignCount, | |
| 768 | CloneWarning: c.Authenticator.CloneWarning, | |
| 769 | Attachment: protocol.AuthenticatorAttachment(c.Authenticator.Attachment), | |
| 770 | }, | |
| 771 | } | |
| 772 | } | |
| 773 | ||
| 774 | func fromWebAuthn(c *webauthn.Credential, label string) identity.Credential { | |
| 775 | transports := make([]string, 0, len(c.Transport)) | |
| 776 | for _, t := range c.Transport { | |
| 777 | transports = append(transports, string(t)) | |
| 778 | } | |
| 779 | return identity.Credential{ | |
| 780 | ID: c.ID, | |
| 781 | PublicKey: c.PublicKey, | |
| 782 | AttestationType: c.AttestationType, | |
| 783 | Transport: transports, | |
| 784 | Flags: identity.CredentialFlags{ | |
| 785 | UserPresent: c.Flags.UserPresent, | |
| 786 | UserVerified: c.Flags.UserVerified, | |
| 787 | BackupEligible: c.Flags.BackupEligible, | |
| 788 | BackupState: c.Flags.BackupState, | |
| 789 | }, | |
| 790 | Authenticator: identity.Authenticator{ | |
| 791 | AAGUID: c.Authenticator.AAGUID, | |
| 792 | SignCount: c.Authenticator.SignCount, | |
| 793 | CloneWarning: c.Authenticator.CloneWarning, | |
| 794 | Attachment: string(c.Authenticator.Attachment), | |
| 795 | }, | |
| 796 | Label: strings.TrimSpace(label), | |
| 797 | } | |
| 798 | } | |
| 799 | ||
| 800 | // decodeJSONBody bounds every auth request before it reaches a parser. | |
| 801 | func decodeJSONBody(r *http.Request, dst any) error { | |
| 802 | defer r.Body.Close() | |
| 803 | dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) | |
| 804 | if err := dec.Decode(dst); err != nil { | |
| 805 | return errors.New("invalid request body") | |
| 806 | } | |
| 807 | return nil | |
| 808 | } |