Open redirect¶
When is it relevant¶
A new or changed code path that redirects to, or links to, a location taken from input. Examples are returnTo, next, redirect_uri, the Referer header, OAuth state, and client-side window.location assignments.
Attack¶
The attacker sends a link on your trusted domain that bounces the victim to a phishing site. Worse, a login or OAuth flow redirects to the attacker with codes or tokens attached. Prefix checks are bypassed with values like //evil.com, /\evil.com, https://app.example.com.evil.com and javascript:.
Mitigation¶
- Redirect only to relative paths, validated by parsing them against your own origin, or to an allowlist of exact origins.
- Compare parsed origins, never string prefixes.
- For OAuth, match registered redirect URIs exactly.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
| Allowlist of destinations | Few known targets | OWASP redirects |
| Accept a relative path only | "Return to where you were" after login | Express API |
| Parse and compare the host | Redirects between your own domains | Express API |
Vulnerable — redirect to whatever arrives
router.get('/login', (req, res) => {
if (req.session.userId) return res.redirect(req.query.next || '/dashboard')
res.render('login', { next: req.query.next })
})
Vulnerable — "must start with a slash"
const next = req.query.next || '/'
if (!next.startsWith('/')) return res.redirect('/')
res.redirect(next) // //evil.com and /\evil.com still escape
//evil.com is a protocol-relative URL and /\evil.com is treated as one by
several browsers — both start with /.
Mitigated — allowlist
const DESTINATIONS = { dashboard: '/dashboard', billing: '/billing', help: '/help' }
res.redirect(DESTINATIONS[req.query.next] ?? '/dashboard')
Mitigated — relative path only
function safeNext(value) {
if (typeof value !== 'string') return '/dashboard'
// exactly one leading slash, no backslashes, no scheme
if (!/^\/(?!\/)[\w\-./?=&%]*$/.test(value)) return '/dashboard'
return value
}
res.redirect(safeNext(req.query.next))
Mitigated — compare the resolved host
const ALLOWED_HOSTS = new Set(['app.example.com', 'docs.example.com'])
const target = new URL(req.query.next, 'https://app.example.com')
const ok = target.protocol === 'https:' && ALLOWED_HOSTS.has(target.host)
res.redirect(ok ? target.href : '/dashboard')
Resolving against your own base first means a relative path stays relative and an absolute URL is judged on its real host.
Docs: res.redirect · WHATWG URL
Bypasses to check
//evil.com,/\evil.com,\/\/evil.com,https:/\evil.com.- Userinfo tricks:
https://app.example.com@evil.com/— the host isevil.com; astartsWith('https://app.example.com')check passes. - Suffix matching:
endsWith('example.com')allowsevil-example.comandexample.com.evil.com. - Check then change: the value is validated, then decoded
(
decodeURIComponent) or concatenated with a prefix before the redirect. - Double encoding (
%252f%252f) and unicode look-alikes decoded downstream. - The redirect happening somewhere other than
res.redirect: aLocationheader set by hand, an HTML meta refresh, orwindow.locationin a template. - OAuth and SSO
redirect_uri/state, logoutreturnTo, and any?url=/?target=/?continue=parameter. - Open redirect used as a step in SSRF or to leak a token in the
Referer.
What to grep for
res.redirect(,res.location(,Location,meta http-equiv="refresh"req.query.next,returnTo,redirect_uri,continue,callbackstartsWith('/'),endsWith(,includes(