Skip to content

Commit 9cab5c7

Browse files
committed
fix(security): tighten ?return=... allowlist on login + logout
The post-login redirect honored an arbitrary ?return= query value as long as it wasn't literally /login or /logout. An attacker phishing a victim to https://gearbox.example.com/login?return=https://attacker would land them on the attacker's harvest page immediately after a successful login, with the URL bar showing only the moment of redirect from a legitimate origin. The Logout handler had the same shape and could relay the value back to the login page. Replace the two-string blocklist with a same-origin allowlist: isSafeReturnURL accepts only relative paths starting with `/`, rejects protocol-relative `//host`, rejects `/\…` (some browsers normalize the backslash to a forward slash, opening another protocol-relative path), rejects any embedded backslash, and rejects schemed URIs entirely. Applies to both LoginPost and Logout. Test pins down the accept/reject matrix including the textbook bypasses (`//evil.example.com`, `/\evil.example.com`, `javascript:`, `data:`). P0-4 from the 2026-05 security audit.
1 parent 63d6bca commit 9cab5c7

2 files changed

Lines changed: 83 additions & 4 deletions

File tree

gearbox/internal/framework/handler/login.go

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,37 @@ import (
1010
"github.com/sarg3nt/gearbox/internal/framework/templates/pages"
1111
)
1212

13+
// isSafeReturnURL reports whether a `?return=` (or `?next=`) query value is
14+
// safe to redirect the browser to after login/logout. Only same-origin
15+
// relative paths are allowed — anything that could escape to an external host
16+
// is rejected so the login page can't be used as a phishing redirector.
17+
//
18+
// The accepted shape is exactly: starts with `/`, does NOT start with `//`
19+
// (protocol-relative), does NOT start with `/\` (some browsers normalize this
20+
// to `//` and follow it as protocol-relative), and does not contain a
21+
// scheme-like prefix elsewhere. Backslashes anywhere in the value are
22+
// rejected because some browsers normalize them to forward slashes during
23+
// URL parsing, opening sneak paths like `/\/evil.example.com`.
24+
//
25+
// 2026-05 audit P0-4.
26+
func isSafeReturnURL(s string) bool {
27+
if s == "" {
28+
return false
29+
}
30+
if len(s) < 1 || s[0] != '/' {
31+
return false
32+
}
33+
if len(s) >= 2 && (s[1] == '/' || s[1] == '\\') {
34+
return false
35+
}
36+
for i := 0; i < len(s); i++ {
37+
if s[i] == '\\' {
38+
return false
39+
}
40+
}
41+
return true
42+
}
43+
1344
// resolvePostLoginPath returns the path the user should land on after login
1445
// or after visiting "/" without a destination. Cascade:
1546
//
@@ -124,9 +155,13 @@ func (h *Handler) LoginPost(w http.ResponseWriter, r *http.Request) {
124155
}
125156

126157
// Redirect to return URL if provided, otherwise honor the per-user
127-
// default-landing-path with system fallback.
158+
// default-landing-path with system fallback. The return URL must be a
159+
// same-origin relative path — anything else (an absolute URL, a
160+
// protocol-relative URL, a `javascript:` URI) is rejected to prevent
161+
// the login page from being used as a phishing redirector
162+
// (2026-05 audit P0-4).
128163
redirectTarget := h.resolvePostLoginPath(user.ID)
129-
if returnURL != "" && returnURL != "/login" && returnURL != "/logout" {
164+
if isSafeReturnURL(returnURL) && returnURL != "/login" && returnURL != "/logout" {
130165
redirectTarget = returnURL
131166
}
132167
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
@@ -178,9 +213,12 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
178213
}
179214
}
180215

181-
// Build redirect URL with logout message
216+
// Build redirect URL with logout message. The return URL must pass the
217+
// same safety check as the post-login redirect (2026-05 audit P0-4) so
218+
// the logout page can't be used to inject an attacker-controlled
219+
// `?return=...` into the login page's URL.
182220
redirectURL := "/login?message=" + url.QueryEscape("You have been logged out.")
183-
if returnURL != "" && returnURL != "/" && returnURL != "/login" && returnURL != "/logout" {
221+
if isSafeReturnURL(returnURL) && returnURL != "/" && returnURL != "/login" && returnURL != "/logout" {
184222
redirectURL += "&return=" + url.QueryEscape(returnURL)
185223
}
186224

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package handler
2+
3+
import "testing"
4+
5+
// 2026-05 audit P0-4. The login and logout handlers both consult
6+
// isSafeReturnURL to decide whether a ?return=... query value can be
7+
// used as a redirect target. These tests pin down the matrix so future
8+
// edits don't accidentally widen what the helper accepts.
9+
func TestIsSafeReturnURL(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input string
13+
want bool
14+
}{
15+
// Safe: same-origin relative paths.
16+
{"plain-root", "/", true},
17+
{"plain-path", "/dashboard", true},
18+
{"nested-path", "/settings/profile", true},
19+
{"with-query", "/search?q=hello", true},
20+
{"with-fragment", "/page#section", true},
21+
22+
// Unsafe: anything that could escape origin.
23+
{"empty", "", false},
24+
{"absolute-https", "https://evil.example.com/x", false},
25+
{"absolute-http", "http://evil.example.com/x", false},
26+
{"protocol-relative", "//evil.example.com/x", false},
27+
{"backslash-relative", "/\\evil.example.com", false},
28+
{"backslash-prefix", "\\evil.example.com", false},
29+
{"javascript-uri", "javascript:alert(1)", false},
30+
{"data-uri", "data:text/html,evil", false},
31+
{"no-leading-slash", "dashboard", false},
32+
{"backslash-anywhere", "/legit\\path", false},
33+
}
34+
for _, tt := range tests {
35+
t.Run(tt.name, func(t *testing.T) {
36+
if got := isSafeReturnURL(tt.input); got != tt.want {
37+
t.Errorf("isSafeReturnURL(%q) = %v, want %v", tt.input, got, tt.want)
38+
}
39+
})
40+
}
41+
}

0 commit comments

Comments
 (0)