Refuse a next value containing a backslash, a control character or a space, then url.Parse it: accept a path starting with exactly one slash, or a URL whose scheme and host exactly match an allowlisted origin, without user info; otherwise a fixed page. Redirect to the value you checked; never trust strings.HasPrefix(next, "/") or a host substring test.
- Find every http.Redirect, http.RedirectHandler and Location header built from request data: query and form values such as next or return_to, the Referer header, and return URLs kept in a cookie or the session.
- Refuse a value that contains a backslash, an ASCII control character or a space before parsing it: browsers read \ as / and drop tabs and newlines, and net/url treats a backslash in a path as an ordinary character.
- Parse it with url.Parse and refuse errors, user info (u.User) and opaque URLs such as javascript:alert(1). With no scheme and no host it is a path: accept it only if it starts with exactly one slash, because //evil.example/ names another host.
- Otherwise compare u.Scheme + "://" + u.Host with an allowlist of whole origins, which also refuses an explicit port. Never use strings.Contains or strings.HasSuffix on the host.
- Redirect to the value you checked with http.Redirect(w, r, target, http.StatusSeeOther), and to a fixed page on any refusal.
- Add regression tests that sign in through the form and work out where the browser lands from the raw Location header, following the WHATWG URL Standard; this card's tests carry a small helper for that.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
func (Vulnerable) RedirectAfterLogin(w http.ResponseWriter, r *http.Request, next string) {
http.Redirect(w, r, cmp.Or(next, DefaultPage), http.StatusSeeOther)
}With IMPL=vulnerable, 8 of 16 tests fail (8 exploit tests).
The fix
// Site comes from configuration, never from the Host header.
const Site = "https://app.example.test"
// allowedOrigins are the only places a sign-in may send the browser.
var allowedOrigins = []string{Site, "https://docs.example.test"}
func (Fixed) RedirectAfterLogin(w http.ResponseWriter, r *http.Request, next string) {
http.Redirect(w, r, safeRedirectTarget(next), http.StatusSeeOther)
}
// safeRedirectTarget returns next when it is a path on this site or a URL on an allowed origin.
// net/url is not a browser: browsers read "\" as "/" and drop tabs and newlines, so a value
// with a backslash, a control character or a space is refused before it is parsed.
func safeRedirectTarget(next string) string {
if next == "" || strings.ContainsFunc(next, func(c rune) bool { return c == '\\' || c <= ' ' || c == 0x7f }) {
return DefaultPage
}
u, err := url.Parse(next)
if err != nil || u.User != nil || u.Opaque != "" { // user@host, javascript:..., mailto:...
return DefaultPage
}
if u.Scheme == "" && u.Host == "" {
// A path on this site starts with exactly one slash: "//evil.example/" names a host.
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
return DefaultPage
}
return next // redirect to the value that was checked
}
// The whole origin must match: scheme, host and (no) port. Never Contains or HasSuffix.
if !slices.Contains(allowedOrigins, u.Scheme+"://"+u.Host) {
return DefaultPage
}
return next
}With IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
func (Wrong) RedirectAfterLogin(w http.ResponseWriter, r *http.Request, next string) {
http.Redirect(w, r, checkedTarget(next), http.StatusSeeOther)
}
func checkedTarget(next string) string {
if next == "" {
return DefaultPage
}
// A path on this site...
if strings.HasPrefix(next, "/") {
return next
}
// ...or a full link back to one of our sites, app.example.test or docs.example.test.
if u, err := url.Parse(next); err == nil && strings.Contains(u.Hostname(), "example.test") {
return next
}
return DefaultPage
}With IMPL=wrong, 3 of 16 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: each value arrives in a link to the real sign-in page, such as
// /login?next=%2F%2Fevil.example%2F. After a genuine sign-in the browser must still be on this
// site or the allowlisted docs site, judged the way a browser reads the Location header.
func expectLandsOnAnAllowedSite(t *testing.T, next string) {
t.Helper()
location := redirectLocation(t, signInWithNext(t, next))
if origin := landingOrigin(location, site); !slices.Contains(allowedOrigins, origin) {
t.Errorf("next=%s sent the browser to %s (origin %s)", next, location, origin)
}
}
func TestExploitAnAbsoluteURLToAnotherSite(t *testing.T) {
expectLandsOnAnAllowedSite(t, "https://evil.example/")
}
func TestExploitASchemeRelativeURL(t *testing.T) {
expectLandsOnAnAllowedSite(t, "//evil.example/")
}
func TestExploitThreeSlashes(t *testing.T) {
expectLandsOnAnAllowedSite(t, "///evil.example/")
}
func TestExploitABackslashAfterTheSlash(t *testing.T) {
expectLandsOnAnAllowedSite(t, `/\evil.example/`)
}
func TestExploitTwoBackslashes(t *testing.T) {
expectLandsOnAnAllowedSite(t, `\\evil.example/`)
}
func TestExploitOurHostNameAsAUserName(t *testing.T) {
expectLandsOnAnAllowedSite(t, "https://app.example.test@evil.example/")
}
func TestExploitALookAlikeHostThatContainsAnAllowedName(t *testing.T) {
expectLandsOnAnAllowedSite(t, "https://docs.example.test.evil.example/")
}
func TestExploitABackslashBeforeTheAt(t *testing.T) {
expectLandsOnAnAllowedSite(t, `https://evil.example\@docs.example.test/`)
}
func TestExploitEncodedSlashes(t *testing.T) {
expectLandsOnAnAllowedSite(t, "/%2F%2Fevil.example/")
}
func TestExploitAJavascriptURL(t *testing.T) {
expectLandsOnAnAllowedSite(t, "javascript:alert(document.domain)")
}- http.Redirect validates nothing, it only tidies relative values: it joins a value without a leading slash to the request's directory and runs path.Clean on values with no scheme and no host. In this card's vulnerable run that turned ///evil.example/ into /evil.example/, but //evil.example/, /\evil.example/, absolute URLs and javascript: URLs went out unchanged, and \\evil.example/ became /\\evil.example/, which browsers still read as evil.example.
- strings.HasPrefix(next, "/") accepts //evil.example/ and /\evil.example/, and a host test with strings.Contains or strings.HasSuffix accepts docs.example.test.evil.example. That is this card's wrong implementation; CodeQL's go/bad-redirect-check exists for the first mistake.
- net/url is not a browser. url.Parse rejects https://evil.example\@docs.example.test/ as invalid user info, http.Redirect then sends it unchanged, and a browser goes to evil.example. Refuse backslashes before parsing instead of relying on parse errors.
- Testing with http.Client hides that payload: the client parses the Location header before CheckRedirect runs and returns an error for a value net/url cannot parse. Send the request with the transport's RoundTrip, as this card's tests do, to see the raw header.
- Browsers refuse to follow a Location header whose scheme is not http or https, and html/template replaces a javascript: URL in an href with #ZgotmplZ, but it lets //evil.example/ through. A next value rendered into a link needs the same check as the redirect.
- CodeQL's open redirect query ignores values read from r.PostForm, so it found nothing here even though the sign-in form copies next from the link. Do not take its silence as a pass.
- Take the site's origin from configuration, not from r.Host or X-Forwarded-Host, and apply the same check to every redirect: sign-out, language switchers and OAuth return URLs.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/go-nethttp does this for you, in Docker if you do not have Go).
- Against your own app, sign in through /login?next=%2F%2Fevil.example%2F and /login?next=https%3A%2F%2Fdocs.example.test.evil.example%2F, read the raw Location header and expect a path on your site or an allowlisted origin.
- Run CodeQL's go/bad-redirect-check and gosec -include=G710; expect the HasPrefix check in impl/wrong.go, and read the scanner notes above for what each one misses.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | go.lang.security.injection.open-redirect.open-redirect | One finding, a false positive: app.go's own redirect of signed-out visitors to "/login?next=" + url.QueryEscape(r.URL.RequestURI()); the rule treats a concatenation as safe only when its constant part names a host. Nothing in impl/, where next arrives as a string parameter from app.go: the rule is marked interfile and Semgrep CE does not follow values across files. On a probe file with r.FormValue("next") and http.Redirect in one function it flagged the plain redirect and the HasPrefix-checked one alike.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> . |
| Semgrep | p/golang | Besides the open-redirect finding above, one go.lang.security.audit.net.cookie-missing-secure finding on the session cookie in app.go, which takes Secure from Config.SecureCookies (true in production, false for the plain-HTTP tests) rather than the literal true the rule looks for. Nothing about the redirects in impl/.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | go/unvalidated-url-redirection | No results. The Go model (NetHttp.qll) treats Request.PostForm, PostFormValue, Body, Header, Cookie and Referer as sources an attacker cannot use for a redirect ("a form field submitted in a POST request"), and this card reads next with r.PostForm.Get, although the sign-in page copies that field from the link's next parameter. On a copy that reads r.FormValue("next") the query reported the redirect in all three implementations, fixed.go included, because it does not recognise safeRedirectTarget as a check.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image |
| CodeQL | go/bad-redirect-check | Two results: strings.HasPrefix(next, "/") in impl/wrong.go, correctly, and the path check in impl/fixed.go, a false positive. The query wants a test of the second character for both / and \, and fixed.go has already refused every backslash with strings.ContainsFunc and every // with HasPrefix.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12 |
| gosec | G710Open redirect via taint analysis | No findings on the card. On the probe file, where r.FormValue("next") reaches http.Redirect in the same function, it reported both redirects, the HasPrefix-checked one included (MEDIUM severity, HIGH confidence).Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec -include=G710,G124,G121 ./... |
| gosec | G124Insecure HTTP cookie configuration | One finding on the session cookie in app.go (MEDIUM severity, HIGH confidence), for the same reason as Semgrep's cookie-missing-secure: Secure comes from configuration. G121 (CrossOriginProtection bypass patterns) found nothing; the app adds no bypass.Checked: gosec 2.29.0 |
| Component | Version |
|---|---|
| Language | Go |
| Runtime | Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9 |
| Framework | Go standard library 1.27.2 (net/http, net/url) |
| gotest.tools/gotestsum (JUnit report, a go.mod tool) | 1.13.0 |
| golang.org/x/text (raised for gotestsum) | 0.42.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/go-nethttpfolder.
go mod download
go build ./... tool# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go mod download && go build ./... tool'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=fixed \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=vulnerable \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=wrong \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs open-redirect/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
- Toolchain
- go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
- gotestsum 1.13.0
- semgrep 1.180.0
- gosec 2.29.0
- govulncheck 1.8.0
- codeql CLI 2.27.1, codeql/go-queries 1.6.12
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 8 of 16 tests fail (8 exploit tests); wrong: 3 of 16 tests fail (3 exploit tests)
- Test results
- fixed 16/16 pass; vulnerable 8/16 fail (8 exploit tests: every payload except three slashes and the encoded slashes); wrong 3/16 fail (3 exploit tests: //, /\ and the look-alike host), with --network=none after install; the same on Go 1.26.9. http.Redirect's path.Clean keeps ///evil.example/ on the site, which is why the Go vulnerable and wrong runs fail one test fewer than the Node.js, Java and C# cards.
- Dependency freshness
- Only the test runner is a module: gotestsum 1.13.0 (2025-09-11), with golang.org/x/text raised to 0.42.0 (2026-09-08) for GO-2026-5970, which also selects golang.org/x/sys 0.47.0 (2026-06-30) and golang.org/x/tools 0.49.0 (2026-08-13). Go 1.27.2 (2026-10-08) is under seven days old but is a security release. govulncheck 1.8.0 found no vulnerabilities in the module or the standard library.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- Go standard library: net/http Redirect
- Go source: http.Redirect in Go 1.27.2 (path.Clean on relative values)
- Go standard library: net/url Parse
- Go standard library: net/http CrossOriginProtection
- Go standard library: html/template contexts (ZgotmplZ)
- WHATWG URL Standard: basic URL parser
- RFC 9110: 303 See Other
- CodeQL Go model of net/http: request fields not used as redirect sources
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')