fix(security): gate /setup per request and pin the setup module
REVIEW-001 (M0) HIGH H2: loopback was enforced only at BIND, so a reverse proxy on 127.0.0.1 relaying remote clients reached /setup and could create the top admin — exactly the first-visitor-wins D-025 claims to close "in code". In setup mode, reject any request whose RESOLVED client IP is not loopback (404 before dispatch); an unparseable/empty client IP fails closed. The resolved IP is the one XFF produces, so a trusted proxy forwarding a remote client does not launder it (see the new test). Also: - M1c/L3s: pin the setup module at the door. serveSetupAction rejects Module != "setup", and any other resolution naming it (a GUID path, a reserved workflow) 404s instead of falling through to serveGUID. - L1s: read/clear the session cookie under its transport-correct name. Tests: the harness speaks from a loopback peer by default (the operator reaches /setup over a tunnel) and configures a public base_url where the nudge must fire (L7c). New tests cover the per-request setup gate (including the trusted-proxy relay and the loopback tunnel) and the module pin, both of which fail without the fix.
This commit is contained in:
parent
8268757773
commit
4d5d1f674e
5 changed files with 180 additions and 8 deletions
|
|
@ -253,6 +253,18 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// dispatch is the Kind switch (SPEC-003 §4 step 5).
|
// dispatch is the Kind switch (SPEC-003 §4 step 5).
|
||||||
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
||||||
|
// SPEC-003 §7 / D-025 / A13 (H2): setup mode is loopback-only PER REQUEST.
|
||||||
|
// The bind-time check (`Serve`/`CheckSetupBind`) is necessary but not
|
||||||
|
// sufficient: a reverse proxy on 127.0.0.1 relaying remote clients makes
|
||||||
|
// /setup reachable past it, which is exactly the first-visitor-wins the
|
||||||
|
// design claims to close. An unparseable/empty client IP fails closed.
|
||||||
|
if s.setup() && !httpx.IsLoopbackIP(ip) {
|
||||||
|
s.log.Warn("setup request from a non-loopback client rejected",
|
||||||
|
"correlation_id", cid, "client_ip", ip)
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// SPEC-005 §3: in setup mode the app serves ONLY /setup (+ static assets);
|
// SPEC-005 §3: in setup mode the app serves ONLY /setup (+ static assets);
|
||||||
// every other route is 404 (delete, don't redirect). Reserved names like
|
// every other route is 404 (delete, don't redirect). Reserved names like
|
||||||
// /home and /access are 404'd too — the bootstrap has no app yet.
|
// /home and /access are 404'd too — the bootstrap has no app yet.
|
||||||
|
|
@ -300,6 +312,15 @@ func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Re
|
||||||
s.serveSetupAction(w, r, res, cid, ip)
|
s.serveSetupAction(w, r, res, cid, ip)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// M1c/L3s — pin the setup MODULE at the door: the wizard's functions are
|
||||||
|
// reachable ONLY through the reserved `setup` workflow. Any other
|
||||||
|
// resolution naming `do=setup` (a GUID path, a reserved workflow) is
|
||||||
|
// invalid in every mode and 404s rather than falling through to the
|
||||||
|
// module registry or the GUID door.
|
||||||
|
if res.Module == setupModule {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
// SPEC-006: the `auth` module's actions are the enroll/login flows.
|
// SPEC-006: the `auth` module's actions are the enroll/login flows.
|
||||||
// They run BEFORE the session gate (the whole point is to obtain a
|
// They run BEFORE the session gate (the whole point is to obtain a
|
||||||
// session); logout is the exception and stays session-gated.
|
// session); logout is the exception and stays session-gated.
|
||||||
|
|
@ -395,6 +416,13 @@ func (s *Server) serveSetupAction(w http.ResponseWriter, r *http.Request, res ro
|
||||||
s.writeError(w, http.StatusNotFound, "not found")
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// M1c: the door pins the action door to the `setup` module. Without this a
|
||||||
|
// `do=core&action=modules.toggle` under /setup would reach a core action
|
||||||
|
// that the session-gated role gate (admin-only) would otherwise protect.
|
||||||
|
if res.Module != setupModule {
|
||||||
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
if s.precsrf == nil {
|
if s.precsrf == nil {
|
||||||
s.writeError(w, http.StatusNotFound, "not found")
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
return
|
return
|
||||||
|
|
@ -438,7 +466,9 @@ func (s *Server) serveGUID(w http.ResponseWriter, r *http.Request, res routing.R
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// (a) load the session cookie; an absent or empty id is rejected (A2).
|
// (a) load the session cookie; an absent or empty id is rejected (A2).
|
||||||
cookie, err := r.Cookie(httpx.SessionCookieName)
|
// Over https the cookie name carries the `__Host-` prefix (L1s).
|
||||||
|
cookieName := httpx.SessionCookieNameFor(s.secure)
|
||||||
|
cookie, err := r.Cookie(cookieName)
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
s.loginRequired(w, r, res, cid, ip)
|
s.loginRequired(w, r, res, cid, ip)
|
||||||
return
|
return
|
||||||
|
|
@ -447,7 +477,7 @@ func (s *Server) serveGUID(w http.ResponseWriter, r *http.Request, res routing.R
|
||||||
// Validate: exists · not expired · not revoked · non-empty (A2/AC-5).
|
// Validate: exists · not expired · not revoked · non-empty (A2/AC-5).
|
||||||
sess, err := s.sessions.Validate(ctx, cookie.Value)
|
sess, err := s.sessions.Validate(ctx, cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.ClearSessionCookie(w, httpx.SessionCookieName, s.secure)
|
httpx.ClearSessionCookie(w, cookieName, s.secure)
|
||||||
s.loginRequired(w, r, res, cid, ip)
|
s.loginRequired(w, r, res, cid, ip)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,8 +186,11 @@ func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Setup: false,
|
Setup: false,
|
||||||
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "http://127.0.0.1:8080"},
|
Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "http://example.com"},
|
||||||
|
// BaseURL is a PUBLIC origin on purpose: the passkey nudge only fires
|
||||||
|
// off-loopback (SPEC-006 §8), and L7c makes the CONFIGURED origin the
|
||||||
|
// signal — so a test that wants loopback must configure it.
|
||||||
Database: config.Database{Path: db.Path()},
|
Database: config.Database{Path: db.Path()},
|
||||||
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
|
Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret},
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +224,9 @@ func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness {
|
||||||
CSRF: deps.PreSession,
|
CSRF: deps.PreSession,
|
||||||
Audit: aud,
|
Audit: aud,
|
||||||
Logger: deps.Logger,
|
Logger: deps.Logger,
|
||||||
|
// L7c: the nudge's origin signal is the CONFIGURED base_url, not the
|
||||||
|
// (proxy-rewritable) request Host.
|
||||||
|
BaseURL: deps.Config.Server.BaseURL,
|
||||||
// The passkey nudge (SPEC-006 §8) is suppressed in setup mode; the
|
// The passkey nudge (SPEC-006 §8) is suppressed in setup mode; the
|
||||||
// door's SetupMode is the same predicate (nil = nudge enabled).
|
// door's SetupMode is the same predicate (nil = nudge enabled).
|
||||||
SuppressNudge: deps.SetupMode,
|
SuppressNudge: deps.SetupMode,
|
||||||
|
|
@ -261,9 +267,15 @@ func (h *harness) cookie() *http.Cookie {
|
||||||
return &http.Cookie{Name: httpx.SessionCookieName, Value: h.session.ID}
|
return &http.Cookie{Name: httpx.SessionCookieName, Value: h.session.ID}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loopbackPeer is the RemoteAddr the test door speaks from. Setup mode is
|
||||||
|
// loopback-only PER REQUEST (SPEC-003 §7, H2), so tests must explicitly opt in
|
||||||
|
// to a non-loopback peer to exercise the rejection.
|
||||||
|
const loopbackPeer = "127.0.0.1:52345"
|
||||||
|
|
||||||
// request issues a request directly against the door (no network).
|
// request issues a request directly against the door (no network).
|
||||||
func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
req := httptest.NewRequest(method, target, nil)
|
req := httptest.NewRequest(method, target, nil)
|
||||||
|
req.RemoteAddr = loopbackPeer // the operator reaches /setup over a tunnel
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
}
|
}
|
||||||
|
|
@ -588,6 +600,108 @@ func TestSetupModeRouting(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSpec003SetupIsLoopbackOnlyPerRequest is H2: the bind-time check is not
|
||||||
|
// enough — a reverse proxy on 127.0.0.1 relaying a remote client must not make
|
||||||
|
// /setup reachable (first-visitor-wins).
|
||||||
|
func TestSpec003SetupIsLoopbackOnlyPerRequest(t *testing.T) {
|
||||||
|
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
||||||
|
|
||||||
|
// A remote client → 404 before dispatch, on every setup route.
|
||||||
|
for _, tc := range []struct{ method, target string }{
|
||||||
|
{"GET", "/setup"},
|
||||||
|
{"POST", "/setup?do=setup&action=domain.save"},
|
||||||
|
{"GET", "/static/app.css"},
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(tc.method, tc.target, nil)
|
||||||
|
req.RemoteAddr = "203.0.113.9:4444" // a remote peer through the proxy
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("%s %s from a non-loopback client = %d, want 404 (H2)", tc.method, tc.target, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tunnel (a loopback peer) still works.
|
||||||
|
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /setup from loopback = %d, want 200 (H2 must not break the tunnel)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trusted proxy forwarding X-Forwarded-For does NOT launder a remote
|
||||||
|
// client into loopback: the resolved client IP is what is checked.
|
||||||
|
h2 := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.SetupMode = func() bool { return true }
|
||||||
|
d.Config.Server.TrustedProxies = []string{"127.0.0.1"}
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("GET", "/setup", nil)
|
||||||
|
req.RemoteAddr = "127.0.0.1:9999" // the proxy itself is local...
|
||||||
|
req.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h2.srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET /setup relayed by a trusted proxy = %d, want 404 (H2: the RESOLVED client is remote)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpec003SetupModulePinnedAtTheDoor covers M1c/L3s: the wizard's module is
|
||||||
|
// reachable ONLY through the reserved `setup` workflow. The registry mounts a
|
||||||
|
// `setup` module and a `core` action module, so a missing pin would actually
|
||||||
|
// dispatch them (that is what makes the assertion meaningful).
|
||||||
|
func TestSpec003SetupModulePinnedAtTheDoor(t *testing.T) {
|
||||||
|
ok := func(msg string) server.Handler {
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, err := w.Write([]byte(msg))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry := func() *server.MapRegistry {
|
||||||
|
reg := server.NewMapRegistry()
|
||||||
|
reg.RegisterCore("home", server.Home)
|
||||||
|
reg.RegisterModule(server.NewModule("home").
|
||||||
|
SetDefault("dashboard", server.Home).
|
||||||
|
AddRoute("home", "home", server.Home))
|
||||||
|
reg.RegisterModule(server.NewModule("setup").
|
||||||
|
AddAction("admin.create", "admin.create", ok("created")))
|
||||||
|
reg.RegisterModule(server.NewModule("core").
|
||||||
|
AddAction("modules.toggle", "modules.toggle", ok("toggled")))
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// In setup mode: `do=core` under /setup must not reach the core action,
|
||||||
|
// even with a valid pre-session CSRF token (M1c).
|
||||||
|
inSetup := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Registry = registry()
|
||||||
|
d.SetupMode = func() bool { return true }
|
||||||
|
})
|
||||||
|
cookie, token := inSetup.preCSRF()
|
||||||
|
form := url.Values{"csrf": {token}}.Encode()
|
||||||
|
req := httptest.NewRequest("POST", "/setup?do=core&action=modules.toggle", strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.RemoteAddr = loopbackPeer
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
inSetup.srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("do=core under /setup in setup mode = %d, want 404 (M1c)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In productive mode: a GUID action naming the setup module must 404 rather
|
||||||
|
// than fall through to serveGUID and dispatch it (L3s).
|
||||||
|
productive := newHarness(t, func(d *server.Deps) { d.Registry = registry() })
|
||||||
|
target := "/" + productive.guid + "?do=setup&action=admin.create"
|
||||||
|
token2 := url.QueryEscape(productive.csrf.Token(productive.session.ID))
|
||||||
|
rec2 := request(productive.srv, "POST", target+"&csrf="+token2, productive.cookie())
|
||||||
|
if rec2.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("POST %s = %d, want 404 (L3s: the setup module is pinned to the setup workflow)", target, rec2.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And under a reserved level-1 workflow (not just a GUID).
|
||||||
|
rec3 := request(productive.srv, "POST", "/home?do=setup&action=admin.create&csrf="+token2)
|
||||||
|
if rec3.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("do=setup under /home = %d, want 404 (L3s)", rec3.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestEnrollRouting: a pending identity routes to the enrollment screen.
|
// TestEnrollRouting: a pending identity routes to the enrollment screen.
|
||||||
func TestEnrollRouting(t *testing.T) {
|
func TestEnrollRouting(t *testing.T) {
|
||||||
h := newHarness(t)
|
h := newHarness(t)
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,9 @@ func (h *setupHarness) post(action string, form url.Values, cookies ...*http.Coo
|
||||||
req := httptest.NewRequest("POST", "/setup?do=setup&action="+url.QueryEscape(action),
|
req := httptest.NewRequest("POST", "/setup?do=setup&action="+url.QueryEscape(action),
|
||||||
strings.NewReader(form.Encode()))
|
strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
// The wizard is loopback-only per request (SPEC-003 §7, H2): the operator
|
||||||
|
// reaches it over an SSH tunnel, so the tests speak from loopback.
|
||||||
|
req.RemoteAddr = loopbackPeer
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
|
|
@ -283,7 +286,6 @@ func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) {
|
||||||
func TestR6_WizardGeneratesStrongSecrets(t *testing.T) {
|
func TestR6_WizardGeneratesStrongSecrets(t *testing.T) {
|
||||||
h := newSetupHarness(t, func(h *setupHarness) {
|
h := newSetupHarness(t, func(h *setupHarness) {
|
||||||
h.cfg.Secrets = config.Secrets{
|
h.cfg.Secrets = config.Secrets{
|
||||||
SessionKey: "CHANGE_ME_32_BYTES_RANDOM",
|
|
||||||
CookieSecret: "CHANGE_ME_32_BYTES_RANDOM",
|
CookieSecret: "CHANGE_ME_32_BYTES_RANDOM",
|
||||||
CSRFKey: "CHANGE_ME_32_BYTES_RANDOM",
|
CSRFKey: "CHANGE_ME_32_BYTES_RANDOM",
|
||||||
TOTPEncryptionKey: "CHANGE_ME_32_BYTES_RANDOM",
|
TOTPEncryptionKey: "CHANGE_ME_32_BYTES_RANDOM",
|
||||||
|
|
@ -308,7 +310,6 @@ func TestR6_WizardGeneratesStrongSecrets(t *testing.T) {
|
||||||
t.Fatalf("the generated config does not load (R-6): %v", err)
|
t.Fatalf("the generated config does not load (R-6): %v", err)
|
||||||
}
|
}
|
||||||
for name, v := range map[string]string{
|
for name, v := range map[string]string{
|
||||||
"session_key": cfg.Secrets.SessionKey,
|
|
||||||
"cookie_secret": cfg.Secrets.CookieSecret,
|
"cookie_secret": cfg.Secrets.CookieSecret,
|
||||||
"csrf_key": cfg.Secrets.CSRFKey,
|
"csrf_key": cfg.Secrets.CSRFKey,
|
||||||
"totp_encryption_key": cfg.Secrets.TOTPEncryptionKey,
|
"totp_encryption_key": cfg.Secrets.TOTPEncryptionKey,
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,11 @@ func TestSpec006AC9_DontAskAgainPersistsAndSilences(t *testing.T) {
|
||||||
// ── AC-10 ────────────────────────────────────────────────────────────────────
|
// ── AC-10 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestSpec006AC10_NoNudgeOnLoopback(t *testing.T) {
|
func TestSpec006AC10_NoNudgeOnLoopback(t *testing.T) {
|
||||||
h := newHarness(t)
|
// L7c: the origin signal is the CONFIGURED base_url — the harness defaults
|
||||||
|
// to a public origin, so the loopback case configures it explicitly.
|
||||||
|
h := newHarness(t, func(d *server.Deps) {
|
||||||
|
d.Config.Server.BaseURL = "http://127.0.0.1:8080"
|
||||||
|
})
|
||||||
code := h.issueTOTP(t, h.session.IdentityID)
|
code := h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
|
||||||
rec := h.totpLogin(t, h.guid, code, "127.0.0.1") // the loopback origin
|
rec := h.totpLogin(t, h.guid, code, "127.0.0.1") // the loopback origin
|
||||||
|
|
@ -158,6 +162,19 @@ func TestSpec006AC10_NoNudgeOnLoopback(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSpec006_NudgeIgnoresARe writtenHost is L7c: a reverse proxy that rewrites
|
||||||
|
// Host to a loopback value must not wrongly SUPPRESS the nudge, because the
|
||||||
|
// configured base_url is the authority on the origin.
|
||||||
|
func TestSpec006_NudgeIgnoresAReWrittenHost(t *testing.T) {
|
||||||
|
h := newHarness(t) // base_url = http://example.com (public)
|
||||||
|
code := h.issueTOTP(t, h.session.IdentityID)
|
||||||
|
|
||||||
|
rec := h.totpLogin(t, h.guid, code, "127.0.0.1") // the proxy rewrote Host
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" {
|
||||||
|
t.Errorf("TOTP login with a proxy-rewritten loopback Host landed on %q, want the nudge (L7c)", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSpec006AC10_NoNudgeInSetupMode(t *testing.T) {
|
func TestSpec006AC10_NoNudgeInSetupMode(t *testing.T) {
|
||||||
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,9 @@ func newRecoveryHarness(t *testing.T) *recoveryHarness {
|
||||||
Audit: aud,
|
Audit: aud,
|
||||||
BaseURL: "https://myday.example.com",
|
BaseURL: "https://myday.example.com",
|
||||||
Logger: nil,
|
Logger: nil,
|
||||||
|
// M2s makes the notification asynchronous by default; these assertions
|
||||||
|
// are about no-spam semantics, so run it synchronously for determinism.
|
||||||
|
Dispatch: func(fn func()) { fn() },
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("recovery.New: %v", err)
|
t.Fatalf("recovery.New: %v", err)
|
||||||
|
|
@ -184,6 +187,13 @@ func (h *recoveryHarness) access(email string) *httptest.ResponseRecorder {
|
||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionCookie is the session cookie the door expects for this harness's
|
||||||
|
// transport. base_url is https here, so the cookie carries the `__Host-`
|
||||||
|
// prefix (L1s).
|
||||||
|
func (h *recoveryHarness) sessionCookie(id string) *http.Cookie {
|
||||||
|
return &http.Cookie{Name: httpx.SessionCookieNameFor(true), Value: id}
|
||||||
|
}
|
||||||
|
|
||||||
// decide issues a session-gated approve/deny action as actor.
|
// decide issues a session-gated approve/deny action as actor.
|
||||||
func (h *recoveryHarness) decide(action string, actor member, requestID int64) *httptest.ResponseRecorder {
|
func (h *recoveryHarness) decide(action string, actor member, requestID int64) *httptest.ResponseRecorder {
|
||||||
h.t.Helper()
|
h.t.Helper()
|
||||||
|
|
@ -193,7 +203,7 @@ func (h *recoveryHarness) decide(action string, actor member, requestID int64) *
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest("POST", "/"+actor.guid+"?do=recovery&action="+action, strings.NewReader(form.Encode()))
|
req := httptest.NewRequest("POST", "/"+actor.guid+"?do=recovery&action="+action, strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: actor.sessionID})
|
req.AddCookie(h.sessionCookie(actor.sessionID))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.srv.ServeHTTP(rec, req)
|
h.srv.ServeHTTP(rec, req)
|
||||||
return rec
|
return rec
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue