Carry allowlisted credential destination through existing sign-in gate
4 files changed,
+271
−2
internal/home/signin.go+5 −2internal/home/signin_return.go+68 −0internal/home/signin_return_factor_test.go+98 −0internal/home/signin_return_test.go+100 −0
CI passed — run details
diff --git a/internal/home/signin.go b/internal/home/signin.go| index fcae68f..83be359 100644 |
| --- a/internal/home/signin.go |
| +++ b/internal/home/signin.go |
| @@ -155,6 +155,7 @@ func clientIP(r *http.Request) string { |
| // handleSigninForm renders the address form. |
| func (a *App) handleSigninForm(w http.ResponseWriter, r *http.Request) { |
| + rememberReturn(w, r) |
| a.render(w, http.StatusOK, "signin.html", map[string]any{}) |
| } |
| @@ -319,7 +320,9 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I |
| // |
| // Gate redirects and reports true when it has taken over. An account |
| // with no passkey passes through untouched. |
| - gated, err := a.passkeys.Gate(w, r, sess) |
| + target := readReturn(r) |
| + writeReturn(w, "/") |
| + gated, err := a.passkeys.Gate(w, requestWithReturn(r, target), sess) |
| if err != nil { |
| a.logger.Error("passkey gate", "person", ref, "err", err) |
| a.signinFailed(w) |
| @@ -334,7 +337,7 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I |
| a.signinFailed(w) |
| return |
| } |
| - http.Redirect(w, r, "/", http.StatusSeeOther) |
| + http.Redirect(w, r, target, http.StatusSeeOther) |
| } |
| // signinFailed is the one answer every failed redemption gets. One page, |
diff --git a/internal/home/signin_return.go b/internal/home/signin_return.go| new file mode 100644 |
| index 0000000..7464359 |
| --- /dev/null |
| +++ b/internal/home/signin_return.go |
| @@ -0,0 +1,68 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "time" |
| +) |
| + |
| +const returnCookie = "__Host-oficina-return" |
| +const returnTTL = 10 * time.Minute |
| + |
| +func credentialReturn(value string) string { |
| + switch value { |
| + case "/passkey/enrol", "/passkey/codes": |
| + return value |
| + } |
| + return "/" |
| +} |
| + |
| +func writeReturn(w http.ResponseWriter, value string) { |
| + maxAge := int(returnTTL.Seconds()) |
| + if value == "/" { |
| + value = "" |
| + maxAge = -1 |
| + } |
| + http.SetCookie(w, &http.Cookie{Name: returnCookie, Value: value, Path: "/", MaxAge: maxAge, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) |
| +} |
| + |
| +func readReturn(r *http.Request) string { |
| + value := "" |
| + count := 0 |
| + for _, c := range r.Cookies() { |
| + if c.Name == returnCookie { |
| + value = c.Value |
| + count++ |
| + } |
| + } |
| + if count != 1 { |
| + return "/" |
| + } |
| + return credentialReturn(value) |
| +} |
| + |
| +func rememberReturn(w http.ResponseWriter, r *http.Request) { |
| + q, err := url.ParseQuery(r.URL.RawQuery) |
| + if err != nil { |
| + writeReturn(w, "/") |
| + return |
| + } |
| + values, present := q["return_to"] |
| + if !present && q.Get("force") == "1" && readReturn(r) != "/" { |
| + return |
| + } |
| + target := "/" |
| + if len(values) == 1 { |
| + target = credentialReturn(values[0]) |
| + } |
| + writeReturn(w, target) |
| +} |
| + |
| +// Gate reads FormValue. Give it only validated navigation intent, so neither |
| +// the callback query nor the magic-link POST can override the cookie. |
| +func requestWithReturn(r *http.Request, target string) *http.Request { |
| + clone := r.Clone(r.Context()) |
| + clone.Form = url.Values{"return_to": {target}} |
| + clone.PostForm = url.Values{"return_to": {target}} |
| + return clone |
| +} |
diff --git a/internal/home/signin_return_factor_test.go b/internal/home/signin_return_factor_test.go| new file mode 100644 |
| index 0000000..09036f0 |
| --- /dev/null |
| +++ b/internal/home/signin_return_factor_test.go |
| @@ -0,0 +1,98 @@ |
| +package home |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo/webauthn/authtest" |
| +) |
| + |
| +// Mutation-checked 2026-09-08: bypassing Gate or passing the original |
| +// request allows factor bypass or hostile FormValue precedence and fails. |
| +func TestCredentialReturnSecondFactors(t *testing.T) { |
| + for _, first := range []string{"magic", "keymail"} { |
| + for _, factor := range []string{"passkey", "recovery"} { |
| + t.Run(first+factor, func(t *testing.T) { |
| + app := newTestApp(t) |
| + addr := "person@example.invalid" |
| + var session *http.Cookie |
| + if first == "keymail" { |
| + addr = "person@keymail.invalid" |
| + newKeymailStub(t, app, addr) |
| + p, state := beginKeymail(t, app, addr) |
| + session = sessionCookie(callback(t, app, "code-1", state, p)) |
| + } else { |
| + session = signInFully(t, app, addr) |
| + } |
| + authenticator := enrol(t, app, session) |
| + codes, err := app.passkeys.RegenerateRecoveryCodes(subjectOf(t, app, session)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + intent := intentFrom(returnRequest(app, "GET", "/signin?return_to=/passkey/enrol", nil)) |
| + var gatedCookies []*http.Cookie |
| + if first == "keymail" { |
| + p, state := beginKeymail(t, app, addr) |
| + got := returnRequest(app, "GET", callbackPath+"?code=code-2&state="+url.QueryEscape(state)+"&return_to=/passkey/codes", nil, p, intent) |
| + if sessionCookie(got) != nil || got.Header().Get("Location") != confirmPath { |
| + t.Fatal("Keymail bypassed second factor") |
| + } |
| + gatedCookies = got.Result().Cookies() |
| + } else { |
| + signIn(t, app, addr) |
| + got := returnRequest(app, "POST", linkPath+"?return_to=/passkey/codes", url.Values{"token": {capturedLink(t, app)}, "return_to": {"//evil.invalid"}}, intent) |
| + if sessionCookie(got) != nil || got.Header().Get("Location") != confirmPath { |
| + t.Fatal("magic link bypassed second factor") |
| + } |
| + gatedCookies = got.Result().Cookies() |
| + } |
| + var pending *http.Cookie |
| + for _, c := range gatedCookies { |
| + if strings.Contains(c.Name, "passkey") && c.Value != "" { |
| + pending = c |
| + } |
| + } |
| + if pending == nil { |
| + t.Fatal("no pending second factor") |
| + } |
| + denied := returnRequest(app, "POST", "/passkey/signin/recovery", url.Values{"code": {"invalid"}}, pending) |
| + if sessionCookie(denied) != nil { |
| + t.Fatal("invalid recovery minted session") |
| + } |
| + if factor == "recovery" { |
| + got := returnRequest(app, "POST", "/passkey/signin/recovery", url.Values{"code": {codes[0]}, "return_to": {"/passkey/codes"}}, pending) |
| + if got.Header().Get("Location") != "/passkey/enrol" || sessionCookie(got) == nil { |
| + t.Fatalf("recovery continuation=%q status=%d", got.Header().Get("Location"), got.Code) |
| + } |
| + if dest := returnRequest(app, "GET", "/passkey/enrol", nil, sessionCookie(got)); dest.Code != 200 { |
| + t.Fatalf("recovery freshness=%d", dest.Code) |
| + } |
| + } else { |
| + challenge := challengeFrom(t, postJSON(t, app, "/passkey/signin/begin", pending, nil)) |
| + clientData, authData, sig, err := authenticator.Get(challenge, authtest.Options{RPID: app.credentialHost, Origin: Origin}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b64 := base64.RawURLEncoding.EncodeToString |
| + got := postJSON(t, app, "/passkey/signin/finish", pending, map[string]string{"id": b64(authenticator.CredID), "clientDataJSON": b64(clientData), "authenticatorData": b64(authData), "signature": b64(sig)}) |
| + var response struct { |
| + To string `json:"to"` |
| + } |
| + if err := json.Unmarshal(got.Body.Bytes(), &response); err != nil { |
| + t.Fatal(err) |
| + } |
| + if response.To != "/passkey/enrol" || sessionCookie(got) == nil { |
| + t.Fatalf("passkey continuation=%q status=%d", response.To, got.Code) |
| + } |
| + if dest := returnRequest(app, "GET", "/passkey/enrol", nil, sessionCookie(got)); dest.Code != 200 { |
| + t.Fatalf("passkey freshness=%d", dest.Code) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/home/signin_return_test.go b/internal/home/signin_return_test.go| index f3e5715..21e9ef4 100644 |
| --- a/internal/home/signin_return_test.go |
| +++ b/internal/home/signin_return_test.go |
| @@ -33,6 +33,8 @@ func intentFrom(w *httptest.ResponseRecorder) *http.Cookie { |
| return nil |
| } |
| +// Mutation-checked 2026-09-08: discarding validated intent restores the |
| +// original wrong destination; trusting cookie values fails hostile cases. |
| func TestCredentialReturnMagicLink(t *testing.T) { |
| for _, target := range []string{"/passkey/enrol", "/passkey/codes"} { |
| t.Run(target, func(t *testing.T) { |
| @@ -59,3 +61,101 @@ func TestCredentialReturnMagicLink(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +func TestCredentialReturnKeymailAndFallback(t *testing.T) { |
| + for _, fallback := range []bool{false, true} { |
| + t.Run(string(rune('0'+boolInt(fallback))), func(t *testing.T) { |
| + app := newTestApp(t) |
| + newKeymailStub(t, app, "person@keymail.invalid") |
| + intent := intentFrom(returnRequest(app, "GET", "/signin?return_to=%2Fpasskey%2Fcodes", nil)) |
| + pending, state := beginKeymail(t, app, "person@keymail.invalid") |
| + code := "code-1" |
| + if fallback { |
| + state = "invalid-state" |
| + } |
| + got := callback(t, app, code, state, pending, intent) |
| + if fallback { |
| + if got.Header().Get("Location") != "/signin?force=1" || sessionCookie(got) != nil { |
| + t.Fatal("invalid Keymail callback did not fall back") |
| + } |
| + form := returnRequest(app, "GET", got.Header().Get("Location"), nil, intent) |
| + if changed := intentFrom(form); changed != nil { |
| + intent = changed |
| + } |
| + returnRequest(app, "POST", "/signin?force=1", url.Values{"address": {"person@keymail.invalid"}}, intent) |
| + got = returnRequest(app, "POST", linkPath, url.Values{"token": {capturedLink(t, app)}}, intent) |
| + } |
| + if got.Header().Get("Location") != "/passkey/codes" || sessionCookie(got) == nil { |
| + t.Fatalf("continuation=%q status=%d", got.Header().Get("Location"), got.Code) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func boolInt(b bool) int { |
| + if b { |
| + return 1 |
| + } |
| + return 0 |
| +} |
| + |
| +func TestCredentialReturnHostileAndMissingIntent(t *testing.T) { |
| + for _, value := range []string{"https://evil.invalid/", "//evil.invalid/", "/passkey/codes?x=1", "/passkey/enrol/", "%2f%2fevil.invalid", "/passkey/codes\r\nLocation: x", "/"} { |
| + t.Run(value, func(t *testing.T) { |
| + app := newTestApp(t) |
| + c := intentFrom(returnRequest(app, "GET", "/signin?return_to="+url.QueryEscape(value), nil)) |
| + if c == nil || c.MaxAge >= 0 { |
| + t.Fatal("unsafe target retained") |
| + } |
| + }) |
| + } |
| + for _, query := range []string{"", "?return_to=/passkey/enrol&return_to=/passkey/codes", "?return_to=%zz"} { |
| + app := newTestApp(t) |
| + old := &http.Cookie{Name: returnCookie, Value: "/passkey/codes"} |
| + c := intentFrom(returnRequest(app, "GET", "/signin"+query, nil, old)) |
| + if c == nil || c.MaxAge >= 0 { |
| + t.Fatal("ambiguous/plain sign-in retained old intent") |
| + } |
| + } |
| + for _, cookies := range [][]*http.Cookie{nil, {{Name: returnCookie, Value: "https://evil.invalid"}}, {{Name: returnCookie, Value: "/passkey/codes"}, {Name: returnCookie, Value: "/passkey/enrol"}}} { |
| + app := newTestApp(t) |
| + signIn(t, app, "person@example.invalid") |
| + got := returnRequest(app, "POST", linkPath+"?return_to=/passkey/codes", url.Values{"token": {capturedLink(t, app)}, "return_to": {"/passkey/enrol"}}, cookies...) |
| + if got.Header().Get("Location") != "/" || sessionCookie(got) == nil { |
| + t.Fatalf("missing/hostile cookie continuation=%q", got.Header().Get("Location")) |
| + } |
| + } |
| +} |
| + |
| +func TestCredentialReturnCookieGrantsNothing(t *testing.T) { |
| + app := newTestApp(t) |
| + c := intentFrom(returnRequest(app, "GET", "/signin?return_to=/passkey/codes", nil)) |
| + if c == nil || !c.Secure || !c.HttpOnly || c.Domain != "" || c.Path != "/" || c.SameSite != http.SameSiteLaxMode || c.MaxAge != 600 { |
| + t.Fatalf("invalid intent cookie attributes: %+v", c) |
| + } |
| + if got := returnRequest(app, "GET", "/passkey/codes", nil, c); got.Code == 200 { |
| + t.Fatal("intent granted access without session") |
| + } |
| + signIn(t, app, "person@example.invalid") |
| + got := returnRequest(app, "POST", linkPath, url.Values{"token": {capturedLink(t, app)}}, c) |
| + if gone := intentFrom(got); gone == nil || gone.MaxAge >= 0 { |
| + t.Fatal("successful completion did not clear intent") |
| + } |
| +} |
| + |
| +// Mutation-checked 2026-09-08: replacing AuthTime with time.Now grants |
| +// access after an old gesture and fails this freshness check. |
| +func TestCredentialReturnDoesNotRefreshOldKeymailGesture(t *testing.T) { |
| + app := newTestApp(t) |
| + stub := newKeymailStub(t, app, "person@keymail.invalid") |
| + stub.authTime = time.Now().Add(-24 * time.Hour) |
| + intent := intentFrom(returnRequest(app, "GET", "/signin?return_to=/passkey/codes", nil)) |
| + pending, state := beginKeymail(t, app, "person@keymail.invalid") |
| + got := callback(t, app, "code-1", state, pending, intent) |
| + if sessionCookie(got) == nil || got.Header().Get("Location") != "/passkey/codes" { |
| + t.Fatal("verified sign-in lost navigation") |
| + } |
| + if dest := returnRequest(app, "GET", "/passkey/codes", nil, sessionCookie(got)); dest.Code == 200 { |
| + t.Fatal("navigation intent refreshed stale credential authentication") |
| + } |
| +} |