oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "net/http"
5 "net/url"
6 "strings"
7
8 "amadan.net/rastrillo/rastrillo/csrf"
9)
10
11// signoutPath is the same path on the credential origin, the directory
12// and every connected app. A consumer's POST /signout (the directory's,
13// or an app's through home/connect's Routes.Signout) ends its own session
14// and answers 307 to the credential origin's POST /signout, which ends
15// the id.* session and lands the browser on that consumer's /signed-out.
16//
17// Both halves are needed. The directory session is only the app list;
18// the credential session is what /authorize reads, and it lives 30 days.
19// Ending just the first (as sign-out did until polish-audit B2) made
20// "You are signed out" a promise the next click broke: Sign in, from the
21// directory or from any app, passed straight back through /authorize
22// with no prompt. Round 2 chains each app's own Sign out the same way
23// (spec shared-chrome §9.5).
24//
25// 307 rather than 303 so the hop stays a POST. A GET sign-out is one any
26// page can trigger with a link or an <img>; a POST is not. See
27// signoutReturn for how the credential side tells a consumer's hand-off
28// from anyone else's POST.
29//
30// What this does NOT end: sessions an app instance minted from an
31// earlier assertion. Those are the instance's own sessions on its own
32// host, and the home keeps no list of where it has handed an assertion
33// (a hibernating fleet would not hear a revocation anyway). Each app's
34// own Sign out ends its session; what this guarantees is that no app can
35// get a NEW assertion for this browser without the email or passkey step.
36const signoutPath = "/signout"
37
38// handleCredentialSignout ends the credential-origin session.
39//
40// It sits outside csrf.Protect because a consumer's hand-off is same-site
41// (a team instance, or the directory) or cross-site (a preview), never
42// same-origin, and Protect refuses both. In its place is a narrower
43// check, signoutReturn, which also names the only place the browser may
44// go next.
45//
46// A refusal is the branded "Finish signing out" page, status 403, never
47// bare error text: by then the consumer has already ended its own
48// session, so a plain error would leave the browser half signed out with
49// no way on. The page's Sign out button is a same-origin POST, which
50// needs no Referer, so the browsers and extensions that strip one still
51// have a way through.
52func (a *App) handleCredentialSignout(w http.ResponseWriter, r *http.Request) {
53 w.Header().Set("Cache-Control", "no-store")
54 back, verdict := a.signoutReturn(r)
55 switch verdict {
56 case signoutRefused:
57 a.render(w, r, http.StatusForbidden, "signout_confirm.html", nil)
58 return
59 case signoutConfirm:
60 // Admitted, but the id.* cookie did not ride this hop, so there is
61 // no row to revoke yet. Clearing the cookie now would leave the
62 // row alive behind it; keep it, and let the same-origin button
63 // (which does carry the cookie) end the session.
64 a.render(w, r, http.StatusOK, "signout_confirm.html", nil)
65 return
66 }
67 a.sessions.SignOut(w, r)
68 http.Redirect(w, r, back+"/signed-out", http.StatusSeeOther)
69}
70
71// handleCredentialSignoutPage is GET /signout: the same page, doing
72// nothing until its button is pressed. Sign-out is never a GET, because
73// a GET is one any link or <img> can trigger.
74func (a *App) handleCredentialSignoutPage(w http.ResponseWriter, r *http.Request) {
75 w.Header().Set("Cache-Control", "no-store")
76 a.render(w, r, http.StatusOK, "signout_confirm.html", nil)
77}
78
79// signoutReturn admits a sign-out POST from this origin's own pages or
80// from an origin registered in HOME_CONSUMERS (the directory is one), and
81// nothing else: not an unregistered team instance on the same site, not
82// any other origin, not a request with no evidence. It returns the origin
83// the browser goes back to, which is always the matched registration
84// (the directory, for this origin's own button), and never anything the
85// request supplied. So there is no open redirect to close.
86//
87// HOME_CONSUMERS is the allowlist that already decides who may receive an
88// assertion, so admitting its origins here adds no party that could not
89// already take part in sign-in.
90//
91// Origin alone cannot carry the hand-off. Chromium and WebKit send
92// "Origin: null" on a POST that a 307 has carried across origins
93// (verified in real browsers, polish-audit B2; the Fetch spec's tainted-
94// origin rule would keep the consumer's, but the browsers do not), so the
95// hop that matters arrives with a null Origin. Referer survives the
96// redirect, and under Referrer-Policy strict-origin-when-cross-origin
97// (rastrillo's default, and what connect's 307 sets) it is exactly the
98// consumer's origin. So:
99//
100// - Origin, when it names an origin, must be a registered one (or pass
101// the ordinary same-origin check for this one); a Referer cannot
102// overrule it;
103// - a null or absent Origin falls back to Referer, whose origin must be
104// exactly a registered one;
105// - either way, Sec-Fetch-Site must agree with where that origin is:
106// same-site for one on the suite's site, cross-site for one off it (a
107// preview), or absent. A suite origin arriving cross-site passed
108// through another site on the way, and a preview is never same-site.
109//
110// A preview is admitted but not signed out on the spot (signoutConfirm).
111// Its hop is cross-site, and the id.* session cookie is SameSite=Lax, so
112// the POST arrives without it: there is no row to revoke, and clearing
113// the cookie in the browser would leave a live row behind a copy of it.
114// It gets the "Finish signing out" page, whose same-origin POST carries
115// the cookie and revokes the row (the #74 review, finding 1).
116//
117// A page elsewhere cannot set Referer to a consumer's origin. What is
118// left, and accepted (spec §9.5), is logout CSRF from a page ON a
119// consumer origin: any of them can post here. That needs a user-injected
120// form on a page the victim visits, and a consumer that renders one must
121// not do it on a registered origin.
122type signoutVerdict int
123
124const (
125 signoutRefused signoutVerdict = iota
126 signoutAdmitted
127 signoutConfirm
128)
129
130func (a *App) signoutReturn(r *http.Request) (string, signoutVerdict) {
131 if csrf.SameOrigin(r, a.origins.Credential) {
132 return a.origins.Directory, signoutAdmitted
133 }
134 claimed := r.Header.Get("Origin")
135 if claimed == "" || claimed == "null" {
136 ref, err := url.Parse(r.Header.Get("Referer"))
137 if err != nil || ref.User != nil || ref.Scheme == "" || ref.Host == "" {
138 return "", signoutRefused
139 }
140 claimed = ref.Scheme + "://" + ref.Host
141 }
142 matched, ok := a.consumerOrigin(claimed)
143 if !ok {
144 return "", signoutRefused
145 }
146 suite := a.onSuiteSite(matched)
147 switch r.Header.Get("Sec-Fetch-Site") {
148 case "":
149 case "same-site":
150 if !suite {
151 return "", signoutRefused
152 }
153 case "cross-site":
154 if suite {
155 return "", signoutRefused
156 }
157 default:
158 return "", signoutRefused
159 }
160 if !suite {
161 return matched, signoutConfirm
162 }
163 return matched, signoutAdmitted
164}
165
166// consumerOrigin returns the registered consumer origin exactly equal to
167// origin. Registrations are validated at boot (assertion.NewVerifier), so
168// an exact string match is a match on scheme, host and port.
169func (a *App) consumerOrigin(origin string) (string, bool) {
170 if a.runtime == nil {
171 return "", false
172 }
173 for _, registered := range a.runtime.consumers {
174 if registered == origin {
175 return registered, true
176 }
177 }
178 return "", false
179}
180
181// onSuiteSite reports whether origin's hostname is the directory's host
182// or under it. The directory is the suite's apex (oficina.works), and the
183// credential origin and every team instance sit under it, so this is the
184// site a browser's Sec-Fetch-Site compares against. A site has no port,
185// so the hostname is compared, never the host: a registered team on :8443
186// is still same-site (the #74 review, finding 2). The caller keeps the
187// full origin, port included, for the redirect.
188func (a *App) onSuiteSite(origin string) bool {
189 u, err := url.Parse(origin)
190 if err != nil {
191 return false
192 }
193 host := u.Hostname()
194 return host == a.directoryHost || strings.HasSuffix(host, "."+a.directoryHost)
195}
196