Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix open redirects in TypeScript and Next.js

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Resolve next against your own origin with new URL(next, site), the WHATWG parser browsers use, and pass the result to redirect() only if url.origin is in an exact allowlist and the URL has no user name or password; otherwise redirect to a fixed page. Redirect to the url.href you checked. Never trust startsWith('/') or a host substring test.

Stack
  • Language TypeScript
  • Runtime Node.js 26.11.1
  • Framework Next.js 16.4.0 (App Router server action with redirect() from next/navigation)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 37 KB
On this page

Steps

  1. Find every redirect built from request data: redirect() and permanentRedirect() from next/navigation in server actions, route handlers and server components, NextResponse.redirect() in route handlers and proxy.ts, and router.push() or location.href with values from the URL on the client.
  2. Configure your site's origin (here https://app.example.test) and the few other origins you send users to (here https://docs.example.test); never derive them from the Host or X-Forwarded-Host header.
  3. Resolve the value with new URL(next, site). It is the WHATWG parser that browsers use, so //evil.example/, /\evil.example/, tabs and user names come out exactly as the browser will see them.
  4. Accept the result only if url.origin is in the allowlist and url.username and url.password are empty. Refuse anything that is not one non-empty string (FormData.get() can also return a File) or that fails to parse.
  5. Call redirect() with url.href, the URL you checked, and on any refusal with a fixed page such as /account. Call it outside any try block: redirect() works by throwing.
  6. Add regression tests that call the server action with this card's payloads, take the URL from the thrown redirect and resolve it with new URL(location, page) before comparing origins.

Vulnerable, fixed and wrong

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

Vulnerablelib/impl/vulnerable.tsTypeScript
export function redirectAfterLogin(next: FormDataEntryValue | null): never {
  redirect(typeof next === 'string' && next !== '' ? next : '/account');
}

With IMPL=vulnerable, 9 of 16 tests fail (9 exploit tests).

The fix

Fixedlib/impl/fixed.tsTypeScript
const SITE = 'https://app.example.test'; // from configuration, never from the Host header
const ALLOWED_ORIGINS = new Set([SITE, 'https://docs.example.test']);

export function redirectAfterLogin(next: FormDataEntryValue | null): never {
  redirect(safeRedirectTarget(next));
}

function safeRedirectTarget(next: FormDataEntryValue | null, fallback = '/account') {
  // A missing field, a file upload or an empty string is refused.
  if (typeof next !== 'string' || next === '') return fallback;
  let url: URL;
  try {
    // Resolved against this site the way a browser resolves a Location header, so "//", "\",
    // tabs, "@" and other schemes come out exactly as the browser would see them.
    url = new URL(next, SITE);
  } catch {
    return fallback;
  }
  if (!ALLOWED_ORIGINS.has(url.origin) || url.username !== '' || url.password !== '') return fallback;
  return url.href; // redirect to the URL that was checked, not to the raw input
}

With IMPL=fixed, 16 of 16 tests pass.

A fix that looks safe but is not

Wrong fixlib/impl/wrong.tsTypeScript
export function redirectAfterLogin(next: FormDataEntryValue | null): never {
  redirect(checkedTarget(next));
}

function checkedTarget(next: FormDataEntryValue | null) {
  if (typeof next !== 'string' || next === '') return '/account';
  // A path on this site...
  if (next.startsWith('/')) return next;
  // ...or a full link back to one of our sites, app.example.test or docs.example.test.
  try {
    if (new URL(next).hostname.includes('example.test')) return next;
  } catch {
    // not an absolute URL
  }
  return '/account';
}

With IMPL=wrong, 4 of 16 tests fail (4 exploit tests).

Server action: sign in, then redirect to next

app/login/actions.tsTypeScript
// The sign-in form's server action. next arrives in a hidden field copied from /login?next=...,
// so anyone who can send the user a link chooses it. redirect() from next/navigation passes the
// string on unchanged: without JavaScript Next.js answers 303 with it as the Location header,
// with JavaScript it goes to the client router in the x-action-redirect header.
export async function signIn(_state: SignInState, formData: FormData): Promise<SignInState> {
  const account = accounts.get(String(formData.get('username') ?? ''));
  if (!account || !passwordMatches(formData.get('password'), account.passwordHash)) {
    return { error: 'Unknown username or wrong password' };
  }
  await startSession(account.username);
  return currentImpl().redirectAfterLogin(formData.get('next'));
}

The regression test

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.

Testtests/redirect.test.tsTypeScript
describe(`exploit (IMPL=${implName})`, () => {
  // 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.
  const attacks = [
    ['an absolute URL to another site', 'https://evil.example/'],
    ['a scheme-relative URL', '//evil.example/'],
    ['three slashes', '///evil.example/'],
    ['a backslash after the slash', '/\\evil.example/'],
    ['two backslashes', '\\\\evil.example/'],
    ['our host name as a user name', 'https://app.example.test@evil.example/'],
    ['a look-alike host that contains an allowed name', 'https://docs.example.test.evil.example/'],
    ['a backslash before the @', 'https://evil.example\\@docs.example.test/'],
    ['encoded slashes', '/%2F%2Fevil.example/'],
    ['a javascript: URL', 'javascript:alert(document.domain)'],
  ];

  for (const [name, next] of attacks) {
    test(`${name}: next=${next} lands on an allowed site`, async () => {
      const location = await signInWithNext(next);
      const url = landing(location);
      expect(
        url !== null && ALLOWED_ORIGINS.includes(url.origin),
        `next=${next} sent the browser to ${location} (origin ${url?.origin ?? 'none'})`,
      ).toBe(true);
    });
  }
});

Pitfalls

  • redirect() validates nothing and accepts absolute URLs to other sites by design. In a server action Next.js answers a no-JavaScript form post with 303 and the string as the Location header, and a JavaScript-driven call with the string in the x-action-redirect header for the client router.
  • startsWith('/') accepts //evil.example/ and /\evil.example/, and a regular expression such as ^/[^/] still accepts /\evil.example/: browsers treat a backslash as a slash in http and https URLs.
  • A host test with includes or endsWith accepts docs.example.test.evil.example, look-alike registrations and every subdomain of your domain. Compare whole origins against a set.
  • Do not rebuild the URL from parts: new URL('/.//evil.example/', site).pathname is //evil.example/, a scheme-relative URL, while url.href stays on the site.
  • redirect() throws a NEXT_REDIRECT error. Inside try/catch, a catch-all swallows the redirect unless you call it after the block or rethrow with unstable_rethrow().
  • The same unchecked value is dangerous outside redirects. React 19 replaces a javascript: URL in href, src or a form action with one that throws (checked with react-dom/server 19.3.0, including mixed case, a leading space and an embedded tab), but location.href = next and window.open(next) run it, and data: URLs pass through React unchanged; run the same check on the client.
  • A next parameter that only ever points at your own pages can be avoided: keep the return path server-side, in the session, before sending the user to sign in.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/typescript-nextjs does this for you).
  2. Against your own app, open /login?next=%2F%5Cevil.example%2F and /login?next=https%3A%2F%2Fapp.example.test%40evil.example%2F with JavaScript turned off, sign in, resolve the 303's Location with new URL(location, 'https://your.app/login') and expect your own origin; repeat with JavaScript on and check where the browser ends up.
  3. Grep for redirect(, permanentRedirect(, NextResponse.redirect( and router.push( with request-derived values; neither Semgrep nor CodeQL models next/navigation's redirect(), so the regression tests are the control.

Scanner rules

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.

Scanner rules for Fix open redirects in TypeScript and Next.js
ToolRuleWhat it found here
Semgrepjavascript.express.security.audit.express-open-redirect.express-open-redirectNo findings on any implementation: the rule matches Express's res.redirect with a req value, and a server action's FormData passed to next/navigation's redirect() is neither.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> app lib
Semgrepp/owasp-top-ten, p/javascript, p/typescript, p/security-auditNo findings on the server action, the pages or lib/impl/*.ts.Checked: Semgrep 1.180.0 (CE); on app and lib
CodeQLjs/server-side-unvalidated-url-redirectionHalf modelled. React.qll treats the parameters of functions exported from a 'use server' module as remote sources (ServerFunctionRemoteFlowSource), so signIn's FormData is a source, but the query's sinks are modelled redirect calls and Location header writes, and nothing in javascript/ql/lib models next/navigation's redirect(). No alert is expected.Checked: github/codeql main at b5b8165, library source read; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageTypeScript
RuntimeNode.js 26.11.1
FrameworkNext.js 16.4.0 (App Router server action with redirect() from next/navigation)
next16.4.0
react, react-dom19.3.0
URL (WHATWG URL Standard, built into Node.js)Node.js 26.11.1
vitest (test only)5.0.3
@types/react-dom (type check only)19.3.0
typescript (type check and next build only)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/typescript-nextjsfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs open-redirect/typescript-nextjs runs all three modes and checks the expected results.

Download the project ZIP, 37 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • next 16.4.0
  • react 19.3.0
  • vitest 5.0.3 (Vite 8.3.4)
  • typescript 6.0.3
  • semgrep 1.180.0
Matrix
fixed: 16 of 16 tests pass; vulnerable: 9 of 16 tests fail (9 exploit tests); wrong: 4 of 16 tests fail (4 exploit tests)
Test results
fixed 16/16 pass; vulnerable 9/16 fail (9 exploit tests: every payload except the encoded slashes); wrong 4/16 fail (4 exploit tests: //, ///, /\ and the look-alike host), offline after npm ci
Dependency freshness
next 16.4.0 was published on 2026-10-06, react and react-dom 19.3.0 on 2026-09-09, vitest 5.0.3 on 2026-09-30 and typescript 6.0.3 on 2026-04-16 (7.0.2 no longer exports the compiler API next build loads). npm audit found 0 vulnerabilities. tsc --noEmit and next build pass. The tests render the sign-in page with react-dom/server, submit its hidden fields to the server action as a FormData, mock next/headers' cookies() with a cookie jar, and read the redirect target with getURLFromRedirectError, the helper Next.js's action handler uses to write the Location header (next/dist/server/app-render/action-handler.js, read in 16.4.0). Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.

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

  1. OWASP Unvalidated Redirects and Forwards Cheat Sheet
  2. Next.js: redirect() (303 for form posts in server actions, external URLs, try/catch)
  3. Next.js: Data Security guide (server action security features)
  4. React: useActionState
  5. Node.js: URL class (WHATWG URL API)
  6. WHATWG URL Standard: basic URL parser
  7. CodeQL query help: js/server-side-unvalidated-url-redirection
  8. CWE-601: URL Redirection to Untrusted Site ('Open Redirect')

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.