Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/auth.go

Line attribution on main · 808 line(s)

6c84d387
Keenan Nemetz
15d 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
15d 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
15d 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
15h 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
15d 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
15d 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
15d 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
15d 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
2d ago
78 ResidentKey: protocol.ResidentKeyRequirementRequired,
6c84d387
Keenan Nemetz
15d 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
13d ago
105 status int
e84a66f5
Keenan Nemetz
15d ago
106 }
107
108 func (s *statusWriter) WriteHeader(code int) {
4506a4d3
Keenan Nemetz
13d 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
13d 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
15d 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
12d ago
139 w.securityHeaders(rw, r)
5d440e37
Keenan Nemetz
10d ago
140 r = withMemo(r)
6c84d387
Keenan Nemetz
15d 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
15d 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
15d 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
12d 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
13d ago
184 w.recordPageView(r, sw)
6c84d387
Keenan Nemetz
15d ago
185 })
186 }
187
4506a4d3
Keenan Nemetz
13d 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
13d 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
13d ago
197 return
198 }
199 if !strings.HasPrefix(sw.Header().Get("Content-Type"), "text/html") {
200 return
201 }
ed03b3d1
Keenan Nemetz
2h 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
13d 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
2h 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
12d ago
360 func (w *Web) securityHeaders(rw http.ResponseWriter, r *http.Request) {
6c84d387
Keenan Nemetz
15d 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
15d 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
12d 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
15d ago
381 rw.Header().Set("Strict-Transport-Security", "max-age=31536000")
382 }
383 }
384
edf9db6e
Keenan Nemetz
12d 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
15d ago
411 func (w *Web) enabled() bool { return w.Auth != nil && w.Auth.Enabled() }
412
f920a9f1
Keenan Nemetz
15d 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
15d 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
15d ago
420 if p := w.principal(r); p != nil {
62576fa8
Keenan Nemetz
14d ago
421 return p.account.Name
6c84d387
Keenan Nemetz
15d 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
15d ago
428 }
429
430 func (w *Web) page(r *http.Request, title string) Page {
3d707ea7
Keenan Nemetz
18h ago
431 p := Page{Title: title, Section: pageSection(r.URL.Path), memo: memoOf(r), InstanceName: w.instanceName()}
6c84d387
Keenan Nemetz
15d 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
15d 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
2h 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
2h 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
2h 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
2h 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
18h 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
15d 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
15d 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
15d 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
15d ago
533 SameSite: http.SameSiteLaxMode,
534 MaxAge: -1,
535 })
536 }
537
edf9db6e
Keenan Nemetz
12d 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
15d 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
12d 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
15d ago
554 got := r.Header.Get("X-Vole-CSRF")
555 if got == "" {
556 got = r.PostFormValue("csrf")
557 }
edf9db6e
Keenan Nemetz
12d 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
12d 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
15d 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
2h ago
601 http.Redirect(rw, r, "/-/login?next="+next, http.StatusSeeOther)
6c84d387
Keenan Nemetz
15d 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
15d 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
15d ago
629 Kind string // login | invite | passkey | register
6c84d387
Keenan Nemetz
15d ago
630 Data webauthn.SessionData
f920a9f1
Keenan Nemetz
15d ago
631 Name string
62576fa8
Keenan Nemetz
14d ago
632 InviteToken string
6c84d387
Keenan Nemetz
15d ago
633 Label string
634 Expires time.Time
635 }
636
edf9db6e
Keenan Nemetz
12d 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
15d 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
12d ago
658 now := time.Now().UTC()
6c84d387
Keenan Nemetz
15d ago
659 s.mu.Lock()
660 defer s.mu.Unlock()
edf9db6e
Keenan Nemetz
12d 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
15d ago
663 for k, v := range s.m {
edf9db6e
Keenan Nemetz
12d ago
664 if now.After(v.Expires) {
6c84d387
Keenan Nemetz
15d ago
665 delete(s.m, k)
666 }
667 }
edf9db6e
Keenan Nemetz
12d 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
15d 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
15d 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
15d 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 }