Separate Home credential routes from the directory host
Guard every application request against canonical hosts and restrict authentication and passkey assets to the credential host. Redirect credential root to the directory without rendering its content. Validation: make test passed, including race detector. Removing credentialOnly or serveKnownHost rejection made the corresponding adversarial route test fail (both exit 1).
10 files changed,
+194
−41
internal/home/app.go+51 −40internal/home/app_test.go+2 −0internal/home/keymailstub_test.go+1 −0internal/home/logs_test.go+8 −0internal/home/noscript_test.go+4 −0internal/home/origins.go+30 −0internal/home/origins_test.go+77 −0internal/home/passkey_test.go+9 −0internal/home/signin.go+1 −1internal/home/signin_test.go+11 −0
diff --git a/internal/home/app.go b/internal/home/app.go| index e5562cb..ef62fdd 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -136,9 +136,17 @@ func (a *App) Router() http.Handler { |
| // blind-SSRF surface by design — and csrf.Protect is pure origin |
| // evidence (Sec-Fetch-Site, then Origin, then Referer) that never |
| // consults a session, so it works before anybody is signed in. |
| + r.Use(serveKnownHost) |
| r.Use(csrf.Protect(Origin)) |
| - r.Get("/", a.handleIndex) |
| + r.Get("/", func(w http.ResponseWriter, r *http.Request) { |
| + if r.Host == credentialHost { |
| + w.Header().Set("Location", directoryOrigin+"/") |
| + w.WriteHeader(http.StatusSeeOther) |
| + return |
| + } |
| + a.handleIndex(w, r) |
| + }) |
| // This /healthz is NOT what answers in a deployed instance: |
| // rastrillo.Serve registers its own GET /healthz on the outer mux |
| // (serve.go's buildHandler), and Go 1.22+ http.ServeMux prefers that |
| @@ -150,48 +158,51 @@ func (a *App) Router() http.Handler { |
| w.WriteHeader(http.StatusOK) |
| }) |
| - r.Get("/signin", a.handleSigninForm) |
| - r.Post("/signin", a.handleSigninBegin) |
| - r.Get("/signin/sent", a.handleSigninSent) |
| - // GET renders, POST redeems. See handleVerifyForm for why. |
| - r.Get(linkPath, a.handleVerifyForm) |
| - r.Post(linkPath, a.handleVerify) |
| - r.Get(callbackPath, a.handleCallback) |
| - |
| - // The second factor. The ceremony endpoints are JSON POSTs driven by |
| - // static/passkey.js; the recovery POST is a plain form and works |
| - // with script off, because recovery is exactly the moment WebAuthn |
| - // is not working. |
| - r.Get(confirmPath, a.handlePasskeyConfirm) |
| - r.Post("/passkey/stepup/begin", a.passkeys.StepUpBegin) |
| - r.Post("/passkey/stepup/finish", a.passkeys.StepUpFinish) |
| - r.Post("/passkey/signin/begin", a.passkeys.SignInBegin) |
| - r.Post("/passkey/signin/finish", a.passkeys.SignInFinish) |
| - r.Post("/passkey/signin/recovery", a.passkeys.SignInRecovery) |
| - // Enrolling changes who can get in, and there is no operator reset, |
| - // so it wants a recent session rather than merely a valid one — see |
| - // enrolFreshness for the lockout this closes. |
| r.Group(func(r chi.Router) { |
| - r.Use(a.sessions.RequireFresh(enrolFreshness)) |
| - r.Get("/passkey/enrol", a.handlePasskeyEnrol) |
| - r.Post("/passkey/register/begin", a.passkeys.RegisterBegin) |
| - r.Post("/passkey/register/finish", a.passkeys.RegisterFinish) |
| + r.Use(credentialOnly) |
| + r.Get("/signin", a.handleSigninForm) |
| + r.Post("/signin", a.handleSigninBegin) |
| + r.Get("/signin/sent", a.handleSigninSent) |
| + // GET renders, POST redeems. See handleVerifyForm for why. |
| + r.Get(linkPath, a.handleVerifyForm) |
| + r.Post(linkPath, a.handleVerify) |
| + r.Get(callbackPath, a.handleCallback) |
| + |
| + // The second factor. The ceremony endpoints are JSON POSTs driven by |
| + // static/passkey.js; the recovery POST is a plain form and works |
| + // with script off, because recovery is exactly the moment WebAuthn |
| + // is not working. |
| + r.Get(confirmPath, a.handlePasskeyConfirm) |
| + r.Post("/passkey/stepup/begin", a.passkeys.StepUpBegin) |
| + r.Post("/passkey/stepup/finish", a.passkeys.StepUpFinish) |
| + r.Post("/passkey/signin/begin", a.passkeys.SignInBegin) |
| + r.Post("/passkey/signin/finish", a.passkeys.SignInFinish) |
| + r.Post("/passkey/signin/recovery", a.passkeys.SignInRecovery) |
| + // Enrolling changes who can get in, and there is no operator reset, |
| + // so it wants a recent session rather than merely a valid one — see |
| + // enrolFreshness for the lockout this closes. |
| + r.Group(func(r chi.Router) { |
| + r.Use(a.sessions.RequireFresh(enrolFreshness)) |
| + r.Get("/passkey/enrol", a.handlePasskeyEnrol) |
| + r.Post("/passkey/register/begin", a.passkeys.RegisterBegin) |
| + r.Post("/passkey/register/finish", a.passkeys.RegisterFinish) |
| + }) |
| + |
| + // The codes page shows ten codes once, so it wants a recent session |
| + // rather than merely a valid one. |
| + r.Group(func(r chi.Router) { |
| + r.Use(a.sessions.RequireFresh(codesFreshness)) |
| + r.Get("/passkey/codes", a.handleRecoveryCodesPage) |
| + r.Post("/passkey/codes", a.handleRegenerateRecoveryCodes) |
| + }) |
| + |
| + // Stable, unfingerprinted paths: Task 7's scriptless allowlist has |
| + // to name them, and a rule that cannot name its subject is not a |
| + // rule. |
| + r.Get("/static/webauthn.mjs", serveAsset(webauthnJS(), "text/javascript; charset=utf-8")) |
| + r.Get("/static/passkey.js", serveAsset(staticAsset("passkey.js"), "text/javascript; charset=utf-8")) |
| }) |
| - // The codes page shows ten codes once, so it wants a recent session |
| - // rather than merely a valid one. |
| - r.Group(func(r chi.Router) { |
| - r.Use(a.sessions.RequireFresh(codesFreshness)) |
| - r.Get("/passkey/codes", a.handleRecoveryCodesPage) |
| - r.Post("/passkey/codes", a.handleRegenerateRecoveryCodes) |
| - }) |
| - |
| - // Stable, unfingerprinted paths: Task 7's scriptless allowlist has |
| - // to name them, and a rule that cannot name its subject is not a |
| - // rule. |
| - r.Get("/static/webauthn.mjs", serveAsset(webauthnJS(), "text/javascript; charset=utf-8")) |
| - r.Get("/static/passkey.js", serveAsset(staticAsset("passkey.js"), "text/javascript; charset=utf-8")) |
| - |
| // Vendored from rastrillo's ui package, not linked from it: an app |
| // owns its stylesheet from the moment it lands, and |
| // `rastrillo doctor --fix` is what reconciles it on an upgrade. |
diff --git a/internal/home/app_test.go b/internal/home/app_test.go| index 76b5755..efd584e 100644 |
| --- a/internal/home/app_test.go |
| +++ b/internal/home/app_test.go |
| @@ -12,6 +12,7 @@ import ( |
| func TestIndex(t *testing.T) { |
| app := newTestApp(t) |
| req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + req.Host = directoryHost |
| rec := httptest.NewRecorder() |
| app.Router().ServeHTTP(rec, req) |
| if rec.Code != http.StatusOK { |
| @@ -30,6 +31,7 @@ func TestIndex(t *testing.T) { |
| func TestHealthz(t *testing.T) { |
| app := newTestApp(t) |
| req := httptest.NewRequest(http.MethodGet, "/healthz", nil) |
| + req.Host = directoryHost |
| rec := httptest.NewRecorder() |
| app.Router().ServeHTTP(rec, req) |
| if rec.Code != http.StatusOK { |
diff --git a/internal/home/keymailstub_test.go b/internal/home/keymailstub_test.go| index bc28754..1cde613 100644 |
| --- a/internal/home/keymailstub_test.go |
| +++ b/internal/home/keymailstub_test.go |
| @@ -174,6 +174,7 @@ func callback(t *testing.T, app *testApp, code, state string, cookies ...*http.C |
| t.Helper() |
| req := httptest.NewRequest(http.MethodGet, |
| callbackPath+"?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil) |
| + req.Host = credentialHost |
| for _, c := range cookies { |
| if c != nil { |
| req.AddCookie(c) |
diff --git a/internal/home/logs_test.go b/internal/home/logs_test.go| index 73dbdea..ef84f57 100644 |
| --- a/internal/home/logs_test.go |
| +++ b/internal/home/logs_test.go |
| @@ -122,6 +122,10 @@ func TestNoContentInLogs(t *testing.T) { |
| }.Encode()) |
| req := httptest.NewRequest(tg.method, tg.path+"?q="+url.QueryEscape(logtest.Canaries[0]), body) |
| + req.Host = credentialHost |
| + if req.URL.Path == "/" { |
| + req.Host = directoryHost |
| + } |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| // csrf.Protect is mounted router-wide and short-circuits a |
| // state-changing request with no origin evidence — before chi |
| @@ -172,6 +176,10 @@ func TestNoContentInLogs(t *testing.T) { |
| } |
| body := strings.NewReader(url.Values{"address": {logtest.Canaries[0]}}.Encode()) |
| req := httptest.NewRequest(tg.method, tg.path, body) |
| + req.Host = credentialHost |
| + if req.URL.Path == "/" { |
| + req.Host = directoryHost |
| + } |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Set("Referer", "https://"+logtest.Canaries[1]+".example.invalid") |
| req.Header.Set("User-Agent", logtest.Canaries[0]) |
diff --git a/internal/home/noscript_test.go b/internal/home/noscript_test.go| index d463d20..51e020d 100644 |
| --- a/internal/home/noscript_test.go |
| +++ b/internal/home/noscript_test.go |
| @@ -300,6 +300,10 @@ func TestServedCSPAllowsNoInlineScript(t *testing.T) { |
| for _, tg := range targets { |
| p := tg.path |
| req := httptest.NewRequest(tg.method, p, strings.NewReader("address=someone@example.invalid")) |
| + req.Host = credentialHost |
| + if req.URL.Path == "/" { |
| + req.Host = directoryHost |
| + } |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| // The POSTs are behind csrf.Protect, which short-circuits before |
| // the handler runs — and a handler that never runs sets no |
diff --git a/internal/home/origins.go b/internal/home/origins.go| new file mode 100644 |
| index 0000000..553ed1a |
| --- /dev/null |
| +++ b/internal/home/origins.go |
| @@ -0,0 +1,30 @@ |
| +package home |
| + |
| +import "net/http" |
| + |
| +const ( |
| + suiteDomain = "oficina.works" |
| + credentialHost = "id." + suiteDomain |
| + directoryHost = suiteDomain |
| + directoryOrigin = "https://" + directoryHost |
| +) |
| + |
| +func serveKnownHost(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Host != credentialHost && r.Host != directoryHost { |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +func credentialOnly(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Host != credentialHost { |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go| new file mode 100644 |
| index 0000000..6392707 |
| --- /dev/null |
| +++ b/internal/home/origins_test.go |
| @@ -0,0 +1,77 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +func TestEveryCredentialRouteRefusesDirectoryHost(t *testing.T) { |
| + app := newTestApp(t) |
| + router := app.Router() |
| + routes := router.(chi.Routes) |
| + count := 0 |
| + err := chi.Walk(routes, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { |
| + switch route { |
| + case "/", "/healthz", "/static/tokens.css", "/static/theme.css": |
| + return nil |
| + } |
| + count++ |
| + req := httptest.NewRequest(method, "https://"+directoryHost+route, nil) |
| + req.Header.Set("Origin", Origin) |
| + rec := httptest.NewRecorder() |
| + router.ServeHTTP(rec, req) |
| + if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 { |
| + t.Errorf("%s %s on directory: status=%d cookies=%d body=%q", method, route, rec.Code, len(rec.Result().Cookies()), rec.Body.String()) |
| + } |
| + return nil |
| + }) |
| + if err != nil || count < 19 { |
| + t.Fatalf("route walk err=%v credential routes=%d", err, count) |
| + } |
| + if _, ok := app.mailer.last(); ok { |
| + t.Fatal("directory request sent mail") |
| + } |
| +} |
| + |
| +func TestDirectoryContentNeverServedOnCredentialHost(t *testing.T) { |
| + app := newTestApp(t) |
| + req := httptest.NewRequest(http.MethodGet, Origin+"/?team=untrusted", nil) |
| + rec := httptest.NewRecorder() |
| + app.Router().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != directoryOrigin+"/" || rec.Body.Len() != 0 { |
| + t.Fatalf("credential root: status=%d location=%q body=%q", rec.Code, rec.Header().Get("Location"), rec.Body.String()) |
| + } |
| +} |
| + |
| +func TestUnknownHostsCannotReachRoutes(t *testing.T) { |
| + app := newTestApp(t) |
| + router := app.Router() |
| + hosts := []string{"", "example.com", "oficina.works.attacker.invalid", "id.oficina.works.attacker.invalid", "ID.OFICINA.WORKS", "id.oficina.works.", "id.oficina.works:443", "oficina.works:443", "localhost"} |
| + err := chi.Walk(router.(chi.Routes), func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { |
| + for _, host := range hosts { |
| + req := httptest.NewRequest(method, route, nil) |
| + req.Host = host |
| + req.Header.Set("Origin", Origin) |
| + req.Header.Set("X-Forwarded-Host", credentialHost) |
| + req.Header.Set("Forwarded", "host="+credentialHost) |
| + rec := httptest.NewRecorder() |
| + router.ServeHTTP(rec, req) |
| + if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 { |
| + t.Errorf("%s %s host=%q status=%d", method, route, host, rec.Code) |
| + } |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestDirectoryIsNotWithinCredentialRP(t *testing.T) { |
| + if credentialHost == directoryHost || rpID() == directoryHost || Origin == directoryOrigin { |
| + t.Fatal("directory overlaps the credential origin or relying party") |
| + } |
| +} |
diff --git a/internal/home/passkey_test.go b/internal/home/passkey_test.go| index fde7d45..6c71d40 100644 |
| --- a/internal/home/passkey_test.go |
| +++ b/internal/home/passkey_test.go |
| @@ -40,6 +40,7 @@ func postJSON(t *testing.T, app *testApp, path string, cookie *http.Cookie, body |
| payload = "{}" |
| } |
| req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)) |
| + req.Host = credentialHost |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("Origin", Origin) |
| if cookie != nil { |
| @@ -190,6 +191,7 @@ func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) { |
| form := url.Values{"code": {codes[0]}} |
| req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode())) |
| + req.Host = credentialHost |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Set("Origin", Origin) |
| for _, c := range (&http.Response{Header: gated.Header()}).Cookies() { |
| @@ -216,6 +218,7 @@ func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) { |
| func subjectOf(t *testing.T, app *testApp, cookie *http.Cookie) string { |
| t.Helper() |
| req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + req.Host = credentialHost |
| req.AddCookie(cookie) |
| sess, ok := app.sessions.From(req) |
| if !ok { |
| @@ -230,6 +233,7 @@ func TestScriptAssetsAreServedOnNamedPaths(t *testing.T) { |
| app := newTestApp(t) |
| for _, path := range []string{"/static/webauthn.mjs", "/static/passkey.js"} { |
| req := httptest.NewRequest(http.MethodGet, path, nil) |
| + req.Host = credentialHost |
| w := httptest.NewRecorder() |
| app.Router().ServeHTTP(w, req) |
| if w.Code != http.StatusOK { |
| @@ -253,6 +257,7 @@ func TestRecoveryCodesPageNeedsAFreshSession(t *testing.T) { |
| get := func() int { |
| req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil) |
| + req.Host = credentialHost |
| req.AddCookie(cookie) |
| w := httptest.NewRecorder() |
| app.Router().ServeHTTP(w, req) |
| @@ -304,6 +309,7 @@ func TestRecoveryCodeProducesAFreshSession(t *testing.T) { |
| form := url.Values{"code": {codes[0]}} |
| req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode())) |
| + req.Host = credentialHost |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Set("Origin", Origin) |
| for _, c := range (&http.Response{Header: gated.Header()}).Cookies() { |
| @@ -319,6 +325,7 @@ func TestRecoveryCodeProducesAFreshSession(t *testing.T) { |
| // The RequireFresh-guarded page opens to it. |
| page := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil) |
| + page.Host = credentialHost |
| page.AddCookie(recovered) |
| pw := httptest.NewRecorder() |
| app.Router().ServeHTTP(pw, page) |
| @@ -361,6 +368,7 @@ func TestEnrolmentNeedsAFreshSession(t *testing.T) { |
| // The page itself redirects to re-authenticate rather than 404ing or |
| // 403ing: a detour, not a dead end. |
| req := httptest.NewRequest(http.MethodGet, "/passkey/enrol", nil) |
| + req.Host = credentialHost |
| req.AddCookie(cookie) |
| w := httptest.NewRecorder() |
| app.Router().ServeHTTP(w, req) |
| @@ -402,6 +410,7 @@ func TestEnrolThenReadCodesWithoutReauthenticating(t *testing.T) { |
| enrol(t, app, cookie) |
| req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil) |
| + req.Host = credentialHost |
| req.AddCookie(cookie) |
| w := httptest.NewRecorder() |
| app.Router().ServeHTTP(w, req) |
diff --git a/internal/home/signin.go b/internal/home/signin.go| index 4931bcf..973f3c7 100644 |
| --- a/internal/home/signin.go |
| +++ b/internal/home/signin.go |
| @@ -15,7 +15,7 @@ import ( |
| // the session cookie and is the WebAuthn RP. §4 splits it from the |
| // directory origin deliberately, and both the CSRF check and the cookie |
| // attributes are keyed on this exact string. |
| -const Origin = "https://id.oficina.works" |
| +const Origin = "https://" + credentialHost |
| // linkPath is where magic links land. It must match the route mounted |
| // below; signin's own default is /auth/verify, which this app does not |
diff --git a/internal/home/signin_test.go b/internal/home/signin_test.go| index 3a0d078..e21409c 100644 |
| --- a/internal/home/signin_test.go |
| +++ b/internal/home/signin_test.go |
| @@ -27,6 +27,7 @@ func signIn(t *testing.T, app *testApp, addr string) *httptest.ResponseRecorder |
| t.Helper() |
| form := url.Values{"address": {addr}} |
| req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) |
| + req.Host = credentialHost |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Set("Origin", Origin) |
| w := httptest.NewRecorder() |
| @@ -65,6 +66,7 @@ func redeem(t *testing.T, app *testApp, token string) *httptest.ResponseRecorder |
| t.Helper() |
| form := url.Values{"token": {token}} |
| req := httptest.NewRequest(http.MethodPost, linkPath, strings.NewReader(form.Encode())) |
| + req.Host = credentialHost |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Set("Origin", Origin) |
| w := httptest.NewRecorder() |
| @@ -164,6 +166,7 @@ func TestGetDoesNotRedeemTheLink(t *testing.T) { |
| token := capturedLink(t, app) |
| req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil) |
| + req.Host = credentialHost |
| w := httptest.NewRecorder() |
| app.Router().ServeHTTP(w, req) |
| @@ -197,6 +200,7 @@ func TestVerifyPageDoesNotDistinguishTokens(t *testing.T) { |
| get := func(token string) *httptest.ResponseRecorder { |
| req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil) |
| + req.Host = credentialHost |
| w := httptest.NewRecorder() |
| app.Router().ServeHTTP(w, req) |
| return w |
| @@ -331,6 +335,7 @@ func TestSigninPostRequiresOriginEvidence(t *testing.T) { |
| for _, path := range []string{"/signin", linkPath} { |
| form := url.Values{"address": {"person@example.invalid"}} |
| req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) |
| + req.Host = credentialHost |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| w := httptest.NewRecorder() |
| app.Router().ServeHTTP(w, req) |
| @@ -350,6 +355,7 @@ func TestSigninPostRequiresOriginEvidence(t *testing.T) { |
| func TestClientIPNeverCollapsesToOneBucket(t *testing.T) { |
| req := func(remote, xff string) *http.Request { |
| r := httptest.NewRequest(http.MethodPost, "/signin", nil) |
| + r.Host = credentialHost |
| r.RemoteAddr = remote |
| if xff != "" { |
| r.Header.Set("X-Forwarded-For", xff) |
| @@ -396,6 +402,7 @@ func TestCallbackWithNoPendingCookieMintsNothing(t *testing.T) { |
| "", |
| } { |
| req := httptest.NewRequest(http.MethodGet, callbackPath+q, nil) |
| + req.Host = credentialHost |
| w := httptest.NewRecorder() |
| app.Router().ServeHTTP(w, req) |
| @@ -431,6 +438,7 @@ func TestForgedPendingBlobIsRefused(t *testing.T) { |
| app := newTestApp(t) |
| req := httptest.NewRequest(http.MethodGet, callbackPath+"?code=x&state=s", nil) |
| + req.Host = credentialHost |
| req.AddCookie(&http.Cookie{ |
| Name: pendingCookie, |
| // Well-formed shape — payload.mac — with a MAC that is not one. |
| @@ -461,6 +469,7 @@ func TestSigninIsRateLimitedPerIPNotGlobally(t *testing.T) { |
| post := func(ip, addr string) int { |
| form := url.Values{"address": {addr}} |
| req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) |
| + req.Host = credentialHost |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Set("Origin", Origin) |
| req.Header.Set("X-Forwarded-For", ip) |
| @@ -499,6 +508,7 @@ func TestSigninIsRateLimitedPerAddress(t *testing.T) { |
| for i := range 12 { |
| form := url.Values{"address": {"target@example.invalid"}} |
| req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) |
| + req.Host = credentialHost |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Set("Origin", Origin) |
| req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i+1)) |
| @@ -527,6 +537,7 @@ func TestVerifyPageWritesNothing(t *testing.T) { |
| before := dumpDatabase(t, app) |
| for _, tok := range []string{token, "not-a-real-token", ""} { |
| req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(tok), nil) |
| + req.Host = credentialHost |
| app.Router().ServeHTTP(httptest.NewRecorder(), req) |
| } |
| if after := dumpDatabase(t, app); after != before { |