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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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
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
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
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
// 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 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.
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);
});
}
});- 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.express.security.audit.express-open-redirect.express-open-redirect | No 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 |
| Semgrep | p/owasp-top-ten, p/javascript, p/typescript, p/security-audit | No findings on the server action, the pages or lib/impl/*.ts.Checked: Semgrep 1.180.0 (CE); on app and lib |
| CodeQL | js/server-side-unvalidated-url-redirection | Half 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 |
| Component | Version |
|---|---|
| Language | TypeScript |
| Runtime | Node.js 26.11.1 |
| Framework | Next.js 16.4.0 (App Router server action with redirect() from next/navigation) |
| next | 16.4.0 |
| react, react-dom | 19.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 |
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.
npm ci# 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 --silentEach 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.
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
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- Next.js: redirect() (303 for form posts in server actions, external URLs, try/catch)
- Next.js: Data Security guide (server action security features)
- React: useActionState
- Node.js: URL class (WHATWG URL API)
- WHATWG URL Standard: basic URL parser
- CodeQL query help: js/server-side-unvalidated-url-redirection
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')