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_uri matching 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 Referer header.
  • 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

  1. Prefer relative paths only. Reject anything with a scheme or a host — most ?next= parameters only ever need /dashboard.
  2. If absolute URLs are required, parse the URL and compare the hostname against an exact allowlist. Never substring-match.
  3. Better still, use an identifier. ?next=dashboard mapped 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

Example · javascript
// ❌ 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.

Example · javascript
// 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.

Example · javascript
// 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 takeover

Stopping 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.

Example · javascript
// 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

  • Be the first to comment on this lesson.