Open Redirects
A low-severity bug on its own, and a critical one when chained with OAuth or a password reset.
An open redirect is an endpoint that sends the browser wherever a parameter says. In isolation it is a phishing aid: the link genuinely starts at abc.com, so it passes the user's inspection and often an email filter's.
Where it becomes serious
- OAuth token theft. If
redirect_urimatching is loose, the authorization code is delivered to the attacker. - Referer leakage. Redirecting to an attacker's site can leak the current URL — including a token in a query string — through the
Refererheader. - SSRF chaining. A validated URL that redirects to an internal address bypasses URL validation done at the first hop.
- Credential phishing after login.
?next=parameters that survive authentication send a freshly-logged-in user to a convincing fake.
The bypasses to know
Naive checks fail in predictable ways: //evil.com is protocol-relative and reads as a path; https://[email protected] puts your domain in the userinfo section; https://abc.com.evil.com passes a startsWith; and backslashes, encoded slashes and whitespace are parsed inconsistently across browsers.
What works
- Prefer relative paths only. Reject anything with a scheme or a host — most
?next=parameters only ever need/dashboard. - If absolute URLs are required, parse the URL and compare the hostname against an exact allowlist. Never substring-match.
- Better still, use an identifier.
?next=dashboardmapped server-side to a known route cannot express an external destination at all.
Add an interstitial for genuinely external links
If your product legitimately redirects to third-party URLs, show a page saying "you are leaving abc.com" with the destination visible. It removes the phishing value entirely.
Example
// ❌ Every one of these has a well-known bypass
res.redirect(req.query.next); // anything at all
if (next.startsWith('/')) res.redirect(next); // //evil.com
if (next.startsWith('https://abc.com')) res.redirect(next); // abc.com.evil.com
if (next.includes('abc.com')) res.redirect(next); // evil.com/?x=abc.com
// ✅ Relative paths only — covers almost every real use
function safeNext(next, fallback = '/dashboard') {
if (typeof next !== 'string') return fallback;
// Must start with exactly one slash, and no scheme, host or backslash.
if (!/^\/(?!\/)[\w\-./?=&%]*$/.test(next)) return fallback;
return next;
}
res.redirect(safeNext(req.query.next));When to use it
- A phishing campaign uses a legitimate abc.com login link with a next parameter, and the email filter allows it because the domain is trusted.
- An OAuth authorization code is delivered to an attacker because redirect_uri matching used a prefix comparison.
- A password reset token leaks through the Referer header after the landing page redirects to an external site.
More examples
The bypasses, and a validator that survives them
Resolving the candidate against your own origin and re-checking the result is the strongest single test — it delegates parsing to the URL implementation rather than a regex.
// Payloads that defeat naive checks — worth pasting into your own tests.
const BYPASSES = [
'//evil.com', // protocol-relative
'///evil.com',
'\\/\\/evil.com', // backslashes
'/\\evil.com',
'https://[email protected]', // userinfo
'https://abc.com%40evil.com',
'https://abc.com.evil.com', // suffix
'https://evil.com/abc.com', // substring in the path
'https://evil.com#https://abc.com',
'https://evil.com?x=https://abc.com',
'javascript:alert(document.domain)',
'data:text/html,<script>alert(1)</script>',
'/\t/evil.com', // whitespace stripped by some browsers
'/%2f%2fevil.com', // encoded slashes
];
// ── Strategy 1 (best): relative paths only ───────────────────────────
export function safeRelative(next, fallback = '/dashboard') {
if (typeof next !== 'string' || next.length > 500) return fallback;
// Decode once so an encoded payload cannot slip through, and reject
// anything that decodes into something else entirely.
let decoded;
try { decoded = decodeURIComponent(next); } catch { return fallback; }
// Reject control characters and whitespace outright.
if (/[\x00-\x20\x7f\\]/.test(decoded)) return fallback;
// Exactly one leading slash, then a normal path.
if (!/^\/(?!\/)/.test(decoded)) return fallback;
// Final check: resolved against our origin, is it still our origin?
try {
const resolved = new URL(decoded, 'https://abc.com');
if (resolved.origin !== 'https://abc.com') return fallback;
return resolved.pathname + resolved.search + resolved.hash;
} catch { return fallback; }
}
// ── Strategy 2: absolute URLs against an exact host allowlist ─────────
const ALLOWED_HOSTS = new Set(['abc.com', 'www.abc.com', 'app.abc.com']);
export function safeAbsolute(next, fallback = 'https://abc.com/dashboard') {
try {
const url = new URL(next); // throws on relative input
if (url.protocol !== 'https:') return fallback;
if (url.username || url.password) return fallback; // userinfo trick
if (!ALLOWED_HOSTS.has(url.hostname)) return fallback; // EXACT, not endsWith
return url.toString();
} catch { return fallback; }
}
// ── Strategy 3 (safest): an identifier, not a URL ─────────────────────
const DESTINATIONS = {
dashboard: '/dashboard',
billing: '/settings/billing',
invite: '/team/invite',
};
res.redirect(DESTINATIONS[req.query.next] ?? '/dashboard');
// There is no string an attacker can supply that expresses an external host.
// And the test:
it.each(BYPASSES)('rejects %s', (payload) => {
expect(safeRelative(payload)).toBe('/dashboard');
expect(safeAbsolute(payload)).toBe('https://abc.com/dashboard');
});OAuth redirect_uri: where this turns critical
Refusing to redirect the error response to an unvalidated URI is subtle and important: an error redirect is still a redirect an attacker can aim.
// If you run an authorization server, redirect_uri matching is the control
// that stops authorization codes being delivered to an attacker.
// ❌ Every one of these has produced a real CVE
if (redirectUri.startsWith(client.redirectUri)) allow();
// registered: https://app.abc.com/callback
// supplied: https://app.abc.com/callback.evil.com ← passes
if (client.redirectUris.some((u) => redirectUri.includes(u))) allow();
// supplied: https://evil.com/?x=https://app.abc.com/callback ← passes
if (new URL(redirectUri).hostname.endsWith('abc.com')) allow();
// supplied: https://evil-abc.com/callback ← passes
// ✅ EXACT string comparison against the registered set. Nothing else.
export function validateRedirectUri(client, supplied) {
if (typeof supplied !== 'string') throw new BadRequestError('invalid_request');
// The spec permits exact matching only, and there is no good reason to relax it.
if (!client.redirectUris.includes(supplied)) {
// Do NOT redirect the error to the supplied URI — that is the bug.
throw new BadRequestError('invalid_redirect_uri');
}
return supplied;
}
// Registration-time rules matter just as much:
export function validateRegisteredUri(uri) {
const url = new URL(uri);
// No wildcards, no fragments, no open paths
if (uri.includes('*')) throw new BadRequestError('wildcards_not_permitted');
if (url.hash) throw new BadRequestError('fragment_not_permitted');
// https only, except for native loopback (RFC 8252)
const isLoopback = ['127.0.0.1', '::1'].includes(url.hostname);
if (url.protocol !== 'https:' && !isLoopback) {
throw new BadRequestError('https_required');
}
// And the redirect target must not itself be an open redirect —
// review client registrations for paths like /redirect?to=
return uri;
}
// The chain worth understanding:
// loose redirect_uri matching
// → the code is delivered to the attacker
// → they exchange it (PKCE stops this for public clients — another
// reason PKCE is mandatory in OAuth 2.1)
// → account takeoverStopping token leakage through Referer
Swapping the token for an HttpOnly cookie and redirecting to a clean URL is the strongest of these — it removes the token from history, logs and referrers at once.
// If a token ever appears in a URL — a reset link, an invite, a magic link —
// then any external resource on that page leaks it in the Referer header.
// 1. Set a referrer policy globally
app.use((req, res, next) => {
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
// 2. And no referrer at all on pages that carry a token in the URL
app.get('/reset', (req, res) => {
res.set('Referrer-Policy', 'no-referrer');
res.set('Cache-Control', 'no-store'); // and keep it out of caches
res.render('reset', { token: req.query.token });
});
// 3. Better: get the token out of the URL immediately.
app.get('/reset', (req, res) => {
// Move it to a short-lived, single-use server-side record and redirect to a
// clean URL. Nothing sensitive remains in history, logs or referrers.
const handle = crypto.randomUUID();
redis.set(`reset-handle:${handle}`, req.query.token, 'EX', 600);
res.cookie('reset_handle', handle, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600_000, path: '/reset',
});
res.set('Referrer-Policy', 'no-referrer');
res.redirect('/reset'); // ← no token in the address bar
});
// 4. Any external link on such a page gets the belt-and-braces treatment
// <a href="..." rel="noopener noreferrer" target="_blank">
// 5. And an interstitial for genuinely external redirects, which removes the
// phishing value of an intentional redirect feature.
app.get('/away', (req, res) => {
const target = safeAbsoluteAnyHost(req.query.url); // still validate the scheme
if (!target) return res.redirect('/');
res.set('Referrer-Policy', 'no-referrer');
res.render('leaving', { target }); // "You are leaving abc.com → target"
});
Discussion