| 1 | package home |
| 2 | |
| 3 | import ( |
| 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. |
| 36 | const 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. |
| 52 | func (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. |
| 74 | func (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. |
| 122 | type signoutVerdict int |
| 123 | |
| 124 | const ( |
| 125 | signoutRefused signoutVerdict = iota |
| 126 | signoutAdmitted |
| 127 | signoutConfirm |
| 128 | ) |
| 129 | |
| 130 | func (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. |
| 169 | func (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. |
| 188 | func (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 | |