Resolve next against your own origin with new URL(next, site), the WHATWG parser browsers use, and redirect only if url.origin is in an exact allowlist and the URL carries no user name or password; otherwise redirect to a fixed page. Pass res.redirect the url.href you checked. Never trust startsWith('/') or a host substring test.
- Find every redirect built from request data: res.redirect and res.location with req.query, req.body, req.params, cookies or the Referer header, and client-side location.href, location.assign or window.open with values from the URL.
- 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 (a repeated ?next= arrives as an array) or that fails to parse.
- Redirect to url.href, the URL you checked, and on any refusal to a fixed page such as /account.
- When next only ever points at your own pages, consider storing the return path in the session before sending the user to sign in, and using only the stored copy afterwards.
- Add regression tests that sign in with this card's payloads and resolve the Location header 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(res, next) {
res.redirect(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(res, next) {
res.redirect(safeRedirectTarget(next));
}
export function safeRedirectTarget(next, fallback = '/account') {
// A repeated ?next= arrives as an array; anything but one non-empty string is refused.
if (typeof next !== 'string' || next === '') return fallback;
let 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(res, next) {
res.redirect(checkedTarget(next));
}
function checkedTarget(next) {
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).
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 res = await signInWithNext(next);
const url = landing(res);
assert.ok(
url !== null && ALLOWED_ORIGINS.includes(url.origin),
`next=${next} sent the browser to ${res.location} (origin ${url?.origin ?? 'none'})`,
);
});
}
});- 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, including forgotten ones that someone else can take over. 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, https://app.example.test//evil.example/, stays on the site.
- res.redirect validates nothing. Express 5.3.0 only percent-encodes characters that cannot appear in a URL (encodeurl), so backslashes and @ reach the browser unchanged. Before 4.19.2 that encoding could make the browser read a URL differently from the allowlist that approved it (CVE-2024-29041), which is one more reason to redirect to the parsed url.href.
- Express 5 removed res.redirect('back'), and the migration guide's replacement, res.redirect(req.get('Referrer') || '/'), sends users to whichever page linked to you. Check the Referer header like next.
- Browsers refuse a Location header whose scheme is not http or https, so javascript:alert(document.domain) does nothing in res.redirect, but the same unchecked value in an href or in location.href runs as script in your origin. In the browser, run the same check with new URL(next, location.origin).
- Semgrep's Express rule flags res.redirect(req.query.next) inside a route handler but not res.redirect(req.body.next || '/account') or a redirect in a helper module, and it found nothing on this card; keep the regression tests.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/node-express does this for you).
- Against your own app, sign in through /login?next=%2F%5Cevil.example%2F and /login?next=https%3A%2F%2Fapp.example.test%40evil.example%2F, resolve each Location header with new URL(location, 'https://your.app/login') and expect your own origin.
- Grep for res.redirect, res.location and location.href; static analysis found nothing on this card because the redirect sits in a helper module, 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 this card, for any implementation: the request value is read in app.js and redirected in impl/*.js, and the rule is file-local. On a single-file probe it flagged res.redirect(req.body.next) and res.redirect(req.query.next) in route handlers, arrow functions included, and an inline next.startsWith('/') check before res.redirect(next). It missed res.redirect(req.body.next || '/account'), the usual way to add a default, and did not flag the fixed helper.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> app.js impl |
| Semgrep | javascript.express.security.audit.possible-user-input-redirect.unknown-value-in-redirect | No findings on this card or on the probe. It only matches a variable assigned from query.x, req.query.x or req.x that later reaches res.redirect, and the probe read req.body.next.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | p/default, p/security-audit, p/owasp-top-ten | No redirect findings. The packs' one finding was javascript.express.security.injection.raw-html-format on the sign-in form in app.js, which builds HTML in a template string; next goes through escapeHtml there, so it is a false positive here.Checked: Semgrep 1.180.0 (CE); on app.js and impl |
| CodeQL | js/server-side-unvalidated-url-redirection | From the query source: sinks are redirect calls such as res.redirect and Location header writes. Its barriers are startsWith checks whose prefix fixes the host (such as 'https://app.example.test/'), concatenation after '?' or '#', and modelled sanitizers. startsWith('/') is deliberately not a barrier, and neither is an origin comparison on a parsed URL. This card loads impl/*.js with import(./impl/${name}.js); whether CodeQL links the route to the redirect through that dynamic import was not tested.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| express-session | 1.19.0 |
| URL (WHATWG URL Standard, built into Node.js) | Node.js 26.11.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/node-expressfolder.
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/node-express 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
- express 5.3.0
- expressSession 1.19.0
- 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
- express 5.3.0 was published on 2026-10-09; it is taken at one day old because it is a security release (qs: CVE-2026-2391, CVE-2026-82417, CVE-2026-82562; proxy-addr: CVE-2026-90711). express-session 1.19.0 dates from 2026-01-22. npm audit found 0 vulnerabilities and npm audit signatures verified all 73 packages. 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
- Node.js: URL class (WHATWG URL API)
- Express 5 API: res.redirect and res.location
- Migrating to Express 5: res.redirect('back') and res.location('back')
- GHSA-rv95-896h-c2vc / CVE-2024-29041: Express.js open redirect in malformed URLs
- WHATWG URL Standard: basic URL parser
- CodeQL query help: js/server-side-unvalidated-url-redirection
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')