noctcore-security/no-user-controlled-redirect
A redirect whose target origin is not fixed at authoring time is an open redirect. Enabled in
recommended.
Recommended preset: error · Autofix: no · Suggestions: no · Type information: not needed
// `?next=https://evil.com` sends the user anywhereres.redirect(req.query.next);
// the userinfo trick: `returnTo = "@evil.com"` makes the host evil.comres.redirect(302, `${appUrl}${returnTo}`);// literal or same-origin pathres.redirect('/login');res.redirect(302, `/users/${id}`);// rule options: {"trustedOrigins":["this.shared.appUrl","buildAuthErrorRedirect()"],"sanitizers":["sanitizeReturnTo"],"trustedPaths":["OAUTH_TWO_FACTOR_CHALLENGE_PATH"]}// trusted origin closed by `/`, or followed by a sanitized path (see options)res.redirect(302, `${this.shared.appUrl}/dashboard`);res.redirect(302, `${this.shared.appUrl}${sanitizeReturnTo(flow.returnTo)}`);The second bad example is the one worth the rule. ${appUrl}${returnTo} looks origin-fixed, and it
is safe exactly as long as some sanitizer upstream keeps returnTo a path. If that sanitizer ever
regresses, nothing at the redirect site says so. This rule does.
What it flags
Section titled “What it flags”The URL argument of each configured redirect callee goes through the same fixed-origin analysis as
no-user-controlled-fetch-url: author-written text, in-file const resolution, +, new URL(input, base). A same-origin relative path passes; a runtime host, a runtime value right after a lone /,
or a runtime value right after an origin that no /, ? or # has closed is flagged.
What it does not flag
Section titled “What it does not flag”- A literal URL, or a same-origin relative path even with a runtime segment (
/users/${id}). - A
trustedOriginsmatch closed by/,?or#, or followed by a sanitizer’s result or a trusted path; a sanitizer’s result on its own. - Redirect calls not listed in
redirectCallees(ctx.redirectneeds configuring).
When the URL argument is an object literal, the URL is read from its urlProperty (default href),
and an object without that property is skipped. That is what keeps TanStack Router quiet:
throw redirect({ to: '/dashboard' }) names a route, while redirect({ href }) is a real location.
// a route, not a locationthrow redirect({ to: '/auth/login', search: { redirect: location.href } });// a runtime locationthrow redirect({ href: search.next });Options
Section titled “Options”| Option | Type | Default | Meaning |
|---|---|---|---|
redirectCallees |
CalleeSpec[] |
see below | Call shapes that redirect. Setting this replaces the defaults. |
trustedOrigins |
string[] |
[] |
Expressions (source text) that are a fixed-origin URL. name() matches any call to name. |
sanitizers |
string[] |
[] |
Functions whose result is a safe same-origin path. |
trustedPaths |
string[] |
[] |
Expressions (source text) that hold a safe same-origin path, e.g. an imported constant. |
A CalleeSpec is { name, object?, urlArgument?, urlProperty? }: name is the method or function
name; object the receiver source text, omitted for a bare call; urlArgument the index of the URL
argument, or 'last' (default 0); urlProperty the property to read when the URL argument is an
object literal (default 'href').
Default redirectCallees:
| Call | URL argument |
|---|---|
redirect(url) (Next.js, Remix, React Router) |
0 |
NextResponse.redirect(url) |
0 |
reply.redirect(url) (Fastify v5) |
0 |
res.redirect([status,] url) (Express) |
last |
response.redirect([status,] url) (Express) |
last |
'last' covers both Express forms: res.redirect(url) and res.redirect(302, url).
redirectCallees
Section titled “redirectCallees”'noctcore-security/no-user-controlled-redirect': ['error', { redirectCallees: [ { object: 'res', name: 'redirect', urlArgument: 'last' }, { object: 'ctx', name: 'redirect' }, // Koa { object: 'reply', name: 'redirect', urlArgument: 1 }, // Fastify v4: reply.redirect(302, url) ],}],trustedOrigins, sanitizers, trustedPaths
Section titled “trustedOrigins, sanitizers, trustedPaths”A worked example for a NestJS OAuth controller that redirects back into the web app:
'noctcore-security/no-user-controlled-redirect': ['error', { // `this.shared.appUrl` is config; `buildAuthErrorRedirect(appUrl, code)` returns a URL on it. trustedOrigins: ['this.shared.appUrl', 'buildAuthErrorRedirect()'], // Returns a validated same-origin path (starts with a single `/`). sanitizers: ['sanitizeReturnTo'], // Imported path constants the rule cannot read across files. trustedPaths: ['OAUTH_TWO_FACTOR_CHALLENGE_PATH'],}],// rule options: {"trustedOrigins":["this.shared.appUrl","buildAuthErrorRedirect()"],"sanitizers":["sanitizeReturnTo"],"trustedPaths":["OAUTH_TWO_FACTOR_CHALLENGE_PATH"]}res.redirect(302, `${this.shared.appUrl}${OAUTH_TWO_FACTOR_CHALLENGE_PATH}`);res.redirect(302, `${this.shared.appUrl}${sanitizeReturnTo(flow.returnTo)}`);res.redirect(302, buildAuthErrorRedirect(this.shared.appUrl, code));// rule options: {"trustedOrigins":["this.shared.appUrl","buildAuthErrorRedirect()"],"sanitizers":["sanitizeReturnTo"],"trustedPaths":["OAUTH_TWO_FACTOR_CHALLENGE_PATH"]}// still flagged: `flow.returnTo` was sanitized somewhere else, which this site cannot seeres.redirect(302, `${this.shared.appUrl}${flow.returnTo}`);Entries match by source text with whitespace ignored. An entry ending in () matches any call to
that callee, whatever its arguments. For Next.js middleware, trustedOrigins: ['request.url'] lets
NextResponse.redirect(new URL('/login', request.url)) pass.
When not to use it
Section titled “When not to use it”If your redirects are all to external identity providers built from discovery documents, the rule
will flag each one; list the builder in trustedOrigins rather than turning the rule off.
Credits
Section titled “Credits”Based on a rule from tsforge (MIT).