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/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.
15func 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).
26func 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.
42func 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
57func 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.
69func 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.
123func 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.
186func 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
204const (
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.
212func 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.
220func 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.
237func 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.
266func 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.
287func 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.
363func 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.
398func 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