Skip to content

Commit d776066

Browse files
committed
refactor(auth): one net/url pass for returnUrl safety + canonicalisation
Follow-up to the Copilot review. The previous fix had two helpers parsing the same URL twice: - safeRelativePath: url.Parse(s), reject if scheme/host/user set - stripFragment: strings.Cut(s, "#"), throw away the tail stripFragment was the embarrassing one — net/url already exposes url.URL.Fragment = "" + url.URL.String(), no string fiddling required. Collapse both helpers into a single sanitizeRelativePath(s) (string, bool) that returns the canonical fragment-stripped form alongside the safety verdict. One Parse, one set of checks, one rebuild. Test suite merges the two matrices into a single table — including fragment-stripping cases that the previous split missed (e.g. "/?x=1#frag" must come back as "/?x=1", not "" + stray strings.Cut output).
1 parent 8013320 commit d776066

2 files changed

Lines changed: 59 additions & 70 deletions

File tree

internal/auth/oidc.go

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,28 @@ type stateBlob struct {
3333
Expiry int64 // unix seconds
3434
}
3535

36-
// safeRelativePath enforces "/path[?...][#...]" — no scheme, no host, no
37-
// protocol-relative "//host/..." trick. `url.Parse` does the heavy lifting,
38-
// we just inspect the result.
39-
func safeRelativePath(s string) bool {
36+
// sanitizeRelativePath does two related jobs in one net/url pass:
37+
//
38+
// - validates that s is a safe relative URL — no scheme, no host, no
39+
// userinfo, no protocol-relative ("//host/...") or backslash trick
40+
// - returns the canonical form with any user-supplied #fragment dropped,
41+
// because the SPA's consumeHashToken() expects the final redirect's
42+
// hash to be exclusively "#token=..." (a leftover "/foo#section" would
43+
// produce "/foo#section#token=..." which it can't parse)
44+
//
45+
// Both concerns are pure net/url plumbing — no string fiddling beyond the
46+
// "//"/"/\\" prefix sniff that url.Parse can't catch (it parses them as
47+
// scheme-less but still cross-origin).
48+
func sanitizeRelativePath(s string) (string, bool) {
4049
if !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") || strings.HasPrefix(s, "/\\") {
41-
return false
50+
return "", false
4251
}
4352
u, err := url.Parse(s)
44-
return err == nil && u.Scheme == "" && u.Host == "" && u.User == nil
53+
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
54+
return "", false
55+
}
56+
u.Fragment = ""
57+
return u.String(), true
4558
}
4659

4760
// OIDCHandler runs the OAuth2 / OIDC Authorization Code flow with the
@@ -203,10 +216,11 @@ func (h *OIDCHandler) consumeState(state string) (*stateBlob, bool) {
203216
// returnURL is hardened against the protocol-relative open redirect
204217
// (`//evil.com/x` would otherwise sail past a naive HasPrefix("/") check).
205218
func (h *OIDCHandler) Login(c *fiber.Ctx) error {
206-
returnURL := c.Query("returnUrl", "/")
207-
if !safeRelativePath(returnURL) {
208-
logger.Warn("OIDC login: rejected unsafe returnUrl, defaulting to /", "returnUrl", returnURL)
209-
returnURL = "/"
219+
returnURL := "/"
220+
if clean, ok := sanitizeRelativePath(c.Query("returnUrl", "/")); ok {
221+
returnURL = clean
222+
} else {
223+
logger.Warn("OIDC login: rejected unsafe returnUrl, defaulting to /", "returnUrl", c.Query("returnUrl"))
210224
}
211225
nonce, err := randomToken()
212226
if err != nil {
@@ -301,27 +315,17 @@ func (h *OIDCHandler) Callback(c *fiber.Ctx) error {
301315
logger.Warn("OIDC callback: id_token has no at_hash, skipping access-token binding check")
302316
}
303317

318+
// Re-sanitize on the way out: the blob was signed by us, but defense in
319+
// depth is cheap and this is the last chance to drop any fragment that
320+
// would otherwise produce a "/foo#frag#token=..." URL the SPA can't read.
304321
returnURL := "/"
305-
if safeRelativePath(stateData.ReturnURL) {
306-
returnURL = stateData.ReturnURL
322+
if clean, ok := sanitizeRelativePath(stateData.ReturnURL); ok {
323+
returnURL = clean
307324
}
308-
// Strip any fragment the user smuggled in via returnUrl (e.g. "/foo#section"):
309-
// the SPA's consumeHashToken() expects "#token=..." to be the only hash
310-
// on the final URL. Without this strip we'd produce "/foo#section#token=...",
311-
// which the SPA fails to parse.
312-
returnURL = stripFragment(returnURL)
313325
logger.Debug("OIDC callback complete", "returnUrl", returnURL, "tokenLength", len(rawIDToken))
314326
return c.Redirect(fmt.Sprintf("%s#token=%s", returnURL, rawIDToken), fiber.StatusFound)
315327
}
316328

317-
// stripFragment returns the input URL without its #fragment, if any. Used at
318-
// the callback to make sure the only hash on the final SPA redirect URL is
319-
// the one carrying the token.
320-
func stripFragment(s string) string {
321-
before, _, _ := strings.Cut(s, "#")
322-
return before
323-
}
324-
325329
// Logout currently just bounces the browser home — the SPA clears its
326330
// localStorage token on this redirect. End-session at the IdP is opt-in
327331
// (the previous version didn't do it either; add an EndSessionEndpoint

internal/auth/oidc_test.go

Lines changed: 30 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,54 +2,39 @@ package auth
22

33
import "testing"
44

5-
func TestStripFragment(t *testing.T) {
5+
// TestSanitizeRelativePath pins both behaviours in one go: the relative-URL
6+
// safety check (open-redirect surface) and the fragment-stripping canonical
7+
// form the SPA's consumeHashToken() relies on. Both come out of one
8+
// net/url.Parse pass — no string fiddling.
9+
func TestSanitizeRelativePath(t *testing.T) {
610
for _, tc := range []struct {
7-
in, want string
11+
in string
12+
want string
13+
wantOK bool
814
}{
9-
{"/foo", "/foo"},
10-
{"/foo?x=1", "/foo?x=1"},
11-
{"/foo#frag", "/foo"},
12-
{"/foo?x=1#frag", "/foo?x=1"},
13-
{"#frag", ""},
14-
{"", ""},
15-
} {
16-
if got := stripFragment(tc.in); got != tc.want {
17-
t.Errorf("stripFragment(%q) = %q, want %q", tc.in, got, tc.want)
18-
}
19-
}
20-
}
15+
// happy paths — exact passthrough or fragment dropped
16+
{"/", "/", true},
17+
{"/foo", "/foo", true},
18+
{"/foo?x=1", "/foo?x=1", true},
19+
{"/foo?x=1#frag", "/foo?x=1", true}, // fragment stripped
20+
{"/foo#frag", "/foo", true},
21+
{"/#frag", "/", true},
2122

22-
// TestSafeRelativePath covers the open-redirect surface a CTF participant is
23-
// likely to probe through ?returnUrl=. Anything that isn't a plain
24-
// "/path[?…][#…]" must be rejected before it reaches c.Redirect, where the
25-
// browser would otherwise treat protocol-relative or backslash-prefixed
26-
// inputs as cross-origin.
27-
func TestSafeRelativePath(t *testing.T) {
28-
cases := []struct {
29-
in string
30-
want bool
31-
}{
32-
// happy paths
33-
{"/", true},
34-
{"/foo", true},
35-
{"/foo/bar?x=1#frag", true},
36-
37-
// classic open-redirect tricks — all must be rejected
38-
{"//evil.com/x", false}, // protocol-relative URL
39-
{"/\\evil.com/x", false}, // backslash-prefixed (some browsers)
40-
{"http://evil.com/x", false}, // absolute URL
41-
{"https://evil.com/x", false}, // absolute URL https
42-
{"javascript:alert(1)", false},
43-
{"data:text/html,x", false},
44-
{"//user@evil.com/x", false}, // userinfo trick
45-
{"", false}, // empty
46-
{"foo/bar", false}, // no leading slash
47-
{" /foo", false}, // leading whitespace (Go parser doesn't strip it; we'd happily redirect)
48-
}
49-
for _, tc := range cases {
50-
got := safeRelativePath(tc.in)
51-
if got != tc.want {
52-
t.Errorf("safeRelativePath(%q) = %v, want %v", tc.in, got, tc.want)
23+
// classic open-redirect tricks — all rejected
24+
{"//evil.com/x", "", false}, // protocol-relative URL
25+
{"/\\evil.com/x", "", false}, // backslash-prefixed (some browsers)
26+
{"http://evil.com/x", "", false}, // absolute URL
27+
{"https://evil.com/x", "", false}, // absolute URL https
28+
{"javascript:alert(1)", "", false},
29+
{"data:text/html,x", "", false},
30+
{"//user@evil.com/x", "", false}, // userinfo trick
31+
{"", "", false}, // empty
32+
{"foo/bar", "", false}, // no leading slash
33+
{" /foo", "", false}, // leading whitespace
34+
} {
35+
got, ok := sanitizeRelativePath(tc.in)
36+
if ok != tc.wantOK || got != tc.want {
37+
t.Errorf("sanitizeRelativePath(%q) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK)
5338
}
5439
}
5540
}

0 commit comments

Comments
 (0)