| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "net/url" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "amadan.net/oficina/home/internal/home/sealtest" |
| 11 | ) |
| 12 | |
| 13 | // signedInPair walks a full sign-in through both roles and returns the |
| 14 | // credential-origin session and the directory session it produced. |
| 15 | func signedInPair(t *testing.T, p exchangePair) (credential, directory *http.Cookie) { |
| 16 | t.Helper() |
| 17 | credential = signInFully(t, p.credential, sealtest.CanaryAddress) |
| 18 | pending, intent := p.start(t) |
| 19 | token, _ := p.mint(t, intent, credential) |
| 20 | return credential, p.finish(t, token, pending) |
| 21 | } |
| 22 | |
| 23 | // resumeMints reports whether the credential origin would mint an |
| 24 | // assertion for this session without asking anything: the silent |
| 25 | // re-entry that made "Sign out" a lie (polish audit B2). |
| 26 | func resumeMints(t *testing.T, p exchangePair, credential *http.Cookie) bool { |
| 27 | t.Helper() |
| 28 | _, intent := p.start(t) |
| 29 | rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, credential) |
| 30 | switch { |
| 31 | case rec.Code == 200 && strings.Contains(rec.Body.String(), `name="assertion"`): |
| 32 | return true |
| 33 | case rec.Code == 303 && strings.HasPrefix(rec.Header().Get("Location"), "/signin"): |
| 34 | return false |
| 35 | } |
| 36 | t.Fatalf("resume answered %d %q", rec.Code, rec.Header().Get("Location")) |
| 37 | return false |
| 38 | } |
| 39 | |
| 40 | // signoutHop posts to the credential sign-out with the given evidence |
| 41 | // headers; an empty value leaves that header out. |
| 42 | func signoutHop(p exchangePair, origin, fetchSite, referer string, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| 43 | r := httptest.NewRequest("POST", Origin+"/signout", nil) |
| 44 | for k, v := range map[string]string{"Origin": origin, "Sec-Fetch-Site": fetchSite, "Referer": referer} { |
| 45 | if v != "" { |
| 46 | r.Header.Set(k, v) |
| 47 | } |
| 48 | } |
| 49 | for _, c := range cookies { |
| 50 | r.AddCookie(c) |
| 51 | } |
| 52 | rec := httptest.NewRecorder() |
| 53 | p.credential.Router().ServeHTTP(rec, r) |
| 54 | return rec |
| 55 | } |
| 56 | |
| 57 | func clearsCookie(rec interface{ Result() *http.Response }, name string) bool { |
| 58 | for _, c := range rec.Result().Cookies() { |
| 59 | if c.Name == name && c.Value == "" && c.MaxAge < 0 { |
| 60 | return true |
| 61 | } |
| 62 | } |
| 63 | return false |
| 64 | } |
| 65 | |
| 66 | // TestSignOutEndsTheCredentialSession is B2: signing out of the |
| 67 | // directory must also end the id.* session, so the next Sign in asks for |
| 68 | // the email (or passkey) step instead of bouncing straight back in. |
| 69 | func TestSignOutEndsTheCredentialSession(t *testing.T) { |
| 70 | p := newExchangePair(t) |
| 71 | credential, directory := signedInPair(t, p) |
| 72 | if !resumeMints(t, p, credential) { |
| 73 | t.Fatal("precondition: a live credential session mints silently") |
| 74 | } |
| 75 | |
| 76 | // The directory ends its own session, then hands the same POST on to |
| 77 | // the credential origin. 307, not 303: the method has to survive so |
| 78 | // the credential origin receives a POST it can origin-check, rather |
| 79 | // than a GET anyone could link to. |
| 80 | out := exchangeRequest(p.directory, "POST", directoryOrigin+"/signout", directoryOrigin, url.Values{}, directory) |
| 81 | if out.Code != http.StatusTemporaryRedirect || out.Header().Get("Location") != Origin+"/signout" { |
| 82 | t.Fatalf("directory sign-out: %d %q", out.Code, out.Header().Get("Location")) |
| 83 | } |
| 84 | if !clearsCookie(out, directory.Name) { |
| 85 | t.Fatal("directory sign-out left its cookie") |
| 86 | } |
| 87 | if page := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, directory); strings.Contains(page.Body.String(), "You are signed in.") { |
| 88 | t.Fatal("directory session survived sign-out") |
| 89 | } |
| 90 | |
| 91 | // The browser re-sends the POST to id.*. These are the headers |
| 92 | // Chromium really sends on that hop (captured live, polish-audit B2): |
| 93 | // the 307 nulls Origin, so the directory is named by Referer alone. |
| 94 | end := signoutHop(p, "null", "same-site", directoryOrigin+"/", credential) |
| 95 | if end.Code != http.StatusSeeOther || end.Header().Get("Location") != directoryOrigin+"/signed-out" { |
| 96 | t.Fatalf("credential sign-out: %d %q", end.Code, end.Header().Get("Location")) |
| 97 | } |
| 98 | if !clearsCookie(end, credential.Name) { |
| 99 | t.Fatal("credential sign-out left its cookie") |
| 100 | } |
| 101 | if end.Header().Get("Cache-Control") != "no-store" { |
| 102 | t.Fatal("sign-out response cacheable") |
| 103 | } |
| 104 | |
| 105 | // The cookie is gone from the browser, but a copy of it must not work |
| 106 | // either: the row is revoked, not merely forgotten. |
| 107 | if resumeMints(t, p, credential) { |
| 108 | t.Fatal("credential session survived sign-out: Sign in re-enters without a prompt") |
| 109 | } |
| 110 | var rows int |
| 111 | if err := p.credential.db.Writer().QueryRow(`SELECT count(*) FROM sessions`).Scan(&rows); err != nil || rows != 0 { |
| 112 | t.Fatal("credential session row not revoked", rows, err) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // TestCredentialSignOutRefusesOtherOrigins: the credential sign-out is |
| 117 | // outside csrf.Protect (the directory's hand-off arrives same-site, not |
| 118 | // same-origin), so it carries its own exact origin check. Anything other |
| 119 | // than a registered consumer (the directory is one) or the credential |
| 120 | // origin itself is refused and the session survives, and a GET does |
| 121 | // nothing at all. blue.docs is registered in newExchangePair; red.docs is |
| 122 | // the same site but is not. |
| 123 | func TestCredentialSignOutRefusesOtherOrigins(t *testing.T) { |
| 124 | p := newExchangePair(t) |
| 125 | credential, _ := signedInPair(t, p) |
| 126 | for _, c := range []struct{ origin, site, referer string }{ |
| 127 | {"", "", ""}, |
| 128 | {"null", "", ""}, |
| 129 | {"null", "same-site", ""}, |
| 130 | {"https://evil.invalid", "cross-site", directoryOrigin + "/"}, |
| 131 | {"https://red.docs.oficina.works", "same-site", ""}, |
| 132 | {"https://red.docs.oficina.works", "same-site", directoryOrigin + "/"}, |
| 133 | {"http://oficina.works", "cross-site", ""}, |
| 134 | {"https://oficina.works.evil.invalid", "cross-site", ""}, |
| 135 | {"null", "same-site", "https://red.docs.oficina.works/"}, |
| 136 | {"null", "cross-site", directoryOrigin + "/"}, |
| 137 | {"null", "same-site", "http://oficina.works/"}, |
| 138 | {"null", "same-site", "https://oficina.works.evil.invalid/"}, |
| 139 | {"null", "same-site", "https://user@oficina.works/"}, |
| 140 | {"", "same-site", "https://evil.invalid/"}, |
| 141 | } { |
| 142 | rec := signoutHop(p, c.origin, c.site, c.referer, credential) |
| 143 | if rec.Code != http.StatusForbidden || clearsCookie(rec, credential.Name) { |
| 144 | t.Fatalf("%+v: %d", c, rec.Code) |
| 145 | } |
| 146 | } |
| 147 | // Both shapes of the directory's own hand-off pass: Origin named (the |
| 148 | // Fetch spec's reading), or nulled with Referer (Chromium's). |
| 149 | for _, c := range []struct{ origin, site, referer string }{ |
| 150 | {directoryOrigin, "same-site", ""}, |
| 151 | {"null", "same-site", directoryOrigin + "/"}, |
| 152 | {"", "", directoryOrigin + "/"}, |
| 153 | } { |
| 154 | fresh, _ := signedInPair(t, p) |
| 155 | if rec := signoutHop(p, c.origin, c.site, c.referer, fresh); rec.Code != http.StatusSeeOther || resumeMints(t, p, fresh) { |
| 156 | t.Fatalf("directory hand-off %+v refused or left the session: %d", c, rec.Code) |
| 157 | } |
| 158 | } |
| 159 | if rec := exchangeRequest(p.credential.App, "GET", Origin+"/signout", "", nil, credential); rec.Code/100 == 3 || clearsCookie(rec, credential.Name) { |
| 160 | t.Fatal("GET signed out", rec.Code) |
| 161 | } |
| 162 | if !resumeMints(t, p, credential) { |
| 163 | t.Fatal("refused sign-out still ended the session") |
| 164 | } |
| 165 | // Its own origin may sign out too, for a future button on id.* pages. |
| 166 | if rec := exchangeRequest(p.credential.App, "POST", Origin+"/signout", Origin, url.Values{}, credential); rec.Code != http.StatusSeeOther { |
| 167 | t.Fatal("same-origin sign-out refused", rec.Code) |
| 168 | } |
| 169 | if resumeMints(t, p, credential) { |
| 170 | t.Fatal("same-origin sign-out left the session") |
| 171 | } |
| 172 | // Neither role answers the other's host: the credential sign-out is |
| 173 | // id.*-only, and the directory's is the directory's. |
| 174 | if rec := exchangeRequest(p.directory, "POST", Origin+"/signout", directoryOrigin, url.Values{}); rec.Code != 404 { |
| 175 | t.Fatal("directory served credential sign-out", rec.Code) |
| 176 | } |
| 177 | if rec := exchangeRequest(p.credential.App, "POST", directoryOrigin+"/signout", directoryOrigin, url.Values{}); rec.Code != 404 { |
| 178 | t.Fatal("credential served directory sign-out", rec.Code) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // TestDirectoryPolicyAdmitsTheSignOutHandOff: CSP form-action is enforced |
| 183 | // across a form's redirects, so without the credential origin in the |
| 184 | // directory's form-action the 307 is blocked in the browser and B2 comes |
| 185 | // back with nothing on screen but a console error. |
| 186 | func TestDirectoryPolicyAdmitsTheSignOutHandOff(t *testing.T) { |
| 187 | p := newExchangePair(t) |
| 188 | policy := p.directory.SecurityPolicy() |
| 189 | var formAction string |
| 190 | for _, d := range strings.Split(policy, ";") { |
| 191 | if d = strings.TrimSpace(d); strings.HasPrefix(d, "form-action ") { |
| 192 | formAction = d |
| 193 | } |
| 194 | } |
| 195 | found := false |
| 196 | for _, source := range strings.Fields(formAction) { |
| 197 | found = found || source == Origin |
| 198 | } |
| 199 | if !found { |
| 200 | t.Fatalf("directory form-action omits %s: %q", Origin, formAction) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | const ( |
| 205 | teamOrigin = "https://blue.docs.oficina.works" |
| 206 | previewOrigin = "https://sheets-preview.example.net" |
| 207 | ) |
| 208 | |
| 209 | // withPreviewConsumer registers a preview deployment beside the pair's |
| 210 | // directory and team consumers: an origin that is registered but is not |
| 211 | // on the suite's site, so the browser marks its hop cross-site. |
| 212 | func withPreviewConsumer(p exchangePair) exchangePair { |
| 213 | p.credential.runtime.consumers["preview_sheets"] = previewOrigin |
| 214 | return p |
| 215 | } |
| 216 | |
| 217 | // isFinishSigningOut reports whether rec is the branded refusal page: the |
| 218 | // way out for a hop that arrived without evidence, a same-origin form |
| 219 | // that csrf.SameOrigin admits without any Referer. |
| 220 | func isFinishSigningOut(rec *httptest.ResponseRecorder) bool { |
| 221 | body := rec.Body.String() |
| 222 | return strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") && |
| 223 | rec.Header().Get("Cache-Control") == "no-store" && |
| 224 | strings.Contains(body, "<title>Finish signing out · Oficina</title>") && |
| 225 | strings.Contains(body, "<h1>Finish signing out</h1>") && |
| 226 | strings.Contains(body, "Select Sign out to finish signing out of Oficina on this device.") && |
| 227 | strings.Contains(body, `<form method="post" action="/signout"`) && |
| 228 | strings.Contains(body, `<button type="submit" rst-btn="primary">Sign out</button>`) |
| 229 | } |
| 230 | |
| 231 | // TestCredentialSignOutAdmitsRegisteredConsumers is round 2's widening |
| 232 | // (spec §9.5): an app's own Sign out 307s to id.*/signout, so every |
| 233 | // origin in HOME_CONSUMERS is admitted, by Origin or (Origin nulled by |
| 234 | // the redirect, as Chromium and WebKit do) by Referer, and lands on THAT |
| 235 | // origin's /signed-out. The destination comes from the matched |
| 236 | // registration and never from the request. |
| 237 | func TestCredentialSignOutAdmitsRegisteredConsumers(t *testing.T) { |
| 238 | for _, c := range []struct{ origin, site, referer, want string }{ |
| 239 | {teamOrigin, "same-site", "", teamOrigin}, |
| 240 | {"null", "same-site", teamOrigin + "/", teamOrigin}, |
| 241 | {"null", "same-site", teamOrigin + "/d/42?view=compact", teamOrigin}, |
| 242 | {"", "", teamOrigin + "/", teamOrigin}, |
| 243 | {teamOrigin, "", "", teamOrigin}, |
| 244 | {"null", "same-site", directoryOrigin + "/", directoryOrigin}, |
| 245 | {directoryOrigin, "same-site", directoryOrigin + "/", directoryOrigin}, |
| 246 | } { |
| 247 | // A pair per case: sign-in's rate limiter would refuse ten in a row. |
| 248 | p := withPreviewConsumer(newExchangePair(t)) |
| 249 | fresh, _ := signedInPair(t, p) |
| 250 | rec := signoutHop(p, c.origin, c.site, c.referer, fresh) |
| 251 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != c.want+"/signed-out" { |
| 252 | t.Fatalf("%+v: %d %q", c, rec.Code, rec.Header().Get("Location")) |
| 253 | } |
| 254 | if !clearsCookie(rec, fresh.Name) || rec.Header().Get("Cache-Control") != "no-store" { |
| 255 | t.Fatalf("%+v: cookie kept or response cacheable", c) |
| 256 | } |
| 257 | if resumeMints(t, p, fresh) { |
| 258 | t.Fatalf("%+v: session survived an admitted sign-out", c) |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // TestCredentialSignOutNeverTakesADestination: nothing a request carries |
| 264 | // chooses where it lands. A query or a form field naming somewhere else |
| 265 | // is ignored and the browser goes to the matched origin's /signed-out. |
| 266 | func TestCredentialSignOutNeverTakesADestination(t *testing.T) { |
| 267 | p := newExchangePair(t) |
| 268 | credential, _ := signedInPair(t, p) |
| 269 | r := httptest.NewRequest("POST", Origin+"/signout?return_to=https://evil.invalid/&next=https://evil.invalid/", strings.NewReader(url.Values{"return_to": {"https://evil.invalid/"}, "redirect": {"//evil.invalid"}}.Encode())) |
| 270 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 271 | r.Header.Set("Origin", "null") |
| 272 | r.Header.Set("Sec-Fetch-Site", "same-site") |
| 273 | r.Header.Set("Referer", teamOrigin+"/") |
| 274 | r.AddCookie(credential) |
| 275 | rec := httptest.NewRecorder() |
| 276 | p.credential.Router().ServeHTTP(rec, r) |
| 277 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != teamOrigin+"/signed-out" { |
| 278 | t.Fatalf("destination taken from the request: %d %q", rec.Code, rec.Header().Get("Location")) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | // TestCredentialSignOutRefusalIsBrandedAndRecoverable: a refused POST is |
| 283 | // never bare error text (the B2 review's finding 1). It is the same |
| 284 | // branded page a plain GET shows, with status 403, and its Sign out |
| 285 | // button is a same-origin POST that then succeeds without any Referer: |
| 286 | // the recovery for a browser that strips it. |
| 287 | func TestCredentialSignOutRefusalIsBrandedAndRecoverable(t *testing.T) { |
| 288 | p := withPreviewConsumer(newExchangePair(t)) |
| 289 | credential, _ := signedInPair(t, p) |
| 290 | for _, c := range []struct{ origin, site, referer string }{ |
| 291 | // No evidence at all, or a nulled Origin with the Referer stripped. |
| 292 | {"", "", ""}, |
| 293 | {"null", "same-site", ""}, |
| 294 | {"null", "cross-site", ""}, |
| 295 | // Unregistered: another site, and a team host on the same site. |
| 296 | {"https://evil.invalid", "cross-site", ""}, |
| 297 | {"null", "cross-site", "https://evil.invalid/"}, |
| 298 | {"https://red.docs.oficina.works", "same-site", ""}, |
| 299 | {"null", "same-site", "https://red.docs.oficina.works/"}, |
| 300 | // A named Origin is the evidence; a Referer cannot overrule it. |
| 301 | {"https://evil.invalid", "cross-site", teamOrigin + "/"}, |
| 302 | {"https://red.docs.oficina.works", "same-site", teamOrigin + "/"}, |
| 303 | // Registered, but the browser's site marking contradicts it: a |
| 304 | // suite origin arriving cross-site went through another site on |
| 305 | // the way, and a preview is never same-site. |
| 306 | {teamOrigin, "cross-site", ""}, |
| 307 | {"null", "cross-site", teamOrigin + "/"}, |
| 308 | {"null", "cross-site", directoryOrigin + "/"}, |
| 309 | {previewOrigin, "same-site", ""}, |
| 310 | {"null", "same-site", previewOrigin + "/"}, |
| 311 | {teamOrigin, "bogus", ""}, |
| 312 | // Near misses on the Referer: scheme, userinfo, suffix, port. |
| 313 | {"null", "same-site", "http://blue.docs.oficina.works/"}, |
| 314 | {"null", "same-site", "https://user@blue.docs.oficina.works/"}, |
| 315 | {"null", "same-site", "https://blue.docs.oficina.works.evil.invalid/"}, |
| 316 | {"null", "same-site", "https://blue.docs.oficina.works:8443/"}, |
| 317 | {"null", "same-site", "https://BLUE.docs.oficina.works/"}, |
| 318 | {"null", "same-site", "not a url\x7f"}, |
| 319 | } { |
| 320 | rec := signoutHop(p, c.origin, c.site, c.referer, credential) |
| 321 | if rec.Code != http.StatusForbidden || !isFinishSigningOut(rec) || clearsCookie(rec, credential.Name) { |
| 322 | t.Fatalf("%+v: %d, branded=%v\n%s", c, rec.Code, isFinishSigningOut(rec), rec.Body.String()) |
| 323 | } |
| 324 | } |
| 325 | if !resumeMints(t, p, credential) { |
| 326 | t.Fatal("a refused sign-out ended the session") |
| 327 | } |
| 328 | |
| 329 | page := exchangeRequest(p.credential.App, "GET", Origin+"/signout", "", nil, credential) |
| 330 | if page.Code != http.StatusOK || !isFinishSigningOut(page) { |
| 331 | t.Fatalf("GET /signout: %d\n%s", page.Code, page.Body.String()) |
| 332 | } |
| 333 | if !resumeMints(t, p, credential) { |
| 334 | t.Fatal("GET /signout ended the session") |
| 335 | } |
| 336 | |
| 337 | // The button: a same-origin form POST, which is what the browser |
| 338 | // sends from that page, with no Referer at all. |
| 339 | r := httptest.NewRequest("POST", Origin+"/signout", nil) |
| 340 | r.Header.Set("Origin", Origin) |
| 341 | r.Header.Set("Sec-Fetch-Site", "same-origin") |
| 342 | r.AddCookie(credential) |
| 343 | rec := httptest.NewRecorder() |
| 344 | p.credential.Router().ServeHTTP(rec, r) |
| 345 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != directoryOrigin+"/signed-out" { |
| 346 | t.Fatalf("Finish signing out button: %d %q", rec.Code, rec.Header().Get("Location")) |
| 347 | } |
| 348 | if resumeMints(t, p, credential) { |
| 349 | t.Fatal("Finish signing out left the session") |
| 350 | } |
| 351 | if rec := exchangeRequest(p.directory, "GET", directoryOrigin+"/signout", "", nil); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { |
| 352 | t.Fatal("directory served the credential's sign-out page", rec.Code) |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | // TestCredentialSignOutFromAPreviewConfirmsFirst: a preview is registered |
| 357 | // but off the suite's site, so its hop is cross-site, and the id.* cookie |
| 358 | // (SameSite=Lax) does not ride a cross-site POST. Answering it with a |
| 359 | // sign-out would clear the cookie in the browser but leave the session |
| 360 | // row alive, so a copy of the cookie would still sign in. So the hop gets |
| 361 | // the "Finish signing out" page, the cookie is kept, and its same-origin |
| 362 | // button, which does carry the cookie, revokes the row. |
| 363 | func TestCredentialSignOutFromAPreviewConfirmsFirst(t *testing.T) { |
| 364 | for _, c := range []struct{ origin, site, referer string }{ |
| 365 | {previewOrigin, "cross-site", ""}, |
| 366 | {"null", "cross-site", previewOrigin + "/"}, |
| 367 | {"", "", previewOrigin + "/"}, |
| 368 | } { |
| 369 | p := withPreviewConsumer(newExchangePair(t)) |
| 370 | credential, _ := signedInPair(t, p) |
| 371 | // The browser sends no cookie on this hop; send none. |
| 372 | rec := signoutHop(p, c.origin, c.site, c.referer) |
| 373 | if rec.Code != http.StatusOK || !isFinishSigningOut(rec) || clearsCookie(rec, credential.Name) { |
| 374 | t.Fatalf("%+v: %d, branded=%v, cleared=%v", c, rec.Code, isFinishSigningOut(rec), clearsCookie(rec, credential.Name)) |
| 375 | } |
| 376 | if !resumeMints(t, p, credential) { |
| 377 | t.Fatalf("%+v: the session ended before it was confirmed", c) |
| 378 | } |
| 379 | r := httptest.NewRequest("POST", Origin+"/signout", nil) |
| 380 | r.Header.Set("Origin", Origin) |
| 381 | r.Header.Set("Sec-Fetch-Site", "same-origin") |
| 382 | r.AddCookie(credential) |
| 383 | confirm := httptest.NewRecorder() |
| 384 | p.credential.Router().ServeHTTP(confirm, r) |
| 385 | if confirm.Code != http.StatusSeeOther || confirm.Header().Get("Location") != directoryOrigin+"/signed-out" || !clearsCookie(confirm, credential.Name) { |
| 386 | t.Fatalf("%+v: confirm %d %q", c, confirm.Code, confirm.Header().Get("Location")) |
| 387 | } |
| 388 | // The row is revoked, so a copy of the old cookie is refused. |
| 389 | if resumeMints(t, p, credential) { |
| 390 | t.Fatalf("%+v: a copy of the cookie still signs in after confirming", c) |
| 391 | } |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | // TestCredentialSignOutKeepsASuiteOriginsPort: a registered suite origin |
| 396 | // on a non-default port is same-site. The site is judged by hostname, |
| 397 | // and the browser goes back to the full origin, port included. |
| 398 | func TestCredentialSignOutKeepsASuiteOriginsPort(t *testing.T) { |
| 399 | const ported = "https://blue.sheets.oficina.works:8443" |
| 400 | for _, c := range []struct{ origin, site, referer string }{ |
| 401 | {ported, "same-site", ""}, |
| 402 | {"null", "same-site", ported + "/"}, |
| 403 | } { |
| 404 | p := newExchangePair(t) |
| 405 | p.credential.runtime.consumers["team_sheets_ported"] = ported |
| 406 | credential, _ := signedInPair(t, p) |
| 407 | rec := signoutHop(p, c.origin, c.site, c.referer, credential) |
| 408 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != ported+"/signed-out" { |
| 409 | t.Fatalf("%+v: %d %q", c, rec.Code, rec.Header().Get("Location")) |
| 410 | } |
| 411 | if resumeMints(t, p, credential) { |
| 412 | t.Fatalf("%+v: session survived", c) |
| 413 | } |
| 414 | } |
| 415 | // Still the site rule: that ported suite origin arriving cross-site is refused. |
| 416 | p := newExchangePair(t) |
| 417 | p.credential.runtime.consumers["team_sheets_ported"] = ported |
| 418 | credential, _ := signedInPair(t, p) |
| 419 | if rec := signoutHop(p, ported, "cross-site", "", credential); rec.Code != http.StatusForbidden { |
| 420 | t.Fatal("ported suite origin admitted cross-site", rec.Code) |
| 421 | } |
| 422 | } |
| 423 | |