Why AI tools produce this: AI coding tools generate this anti-pattern by default, it appears in a large share of AI-written auth code.
Dataflow rule. This is a taint-mode rule: it traces untrusted request input (query, body, params) through your code to an HTTP redirect destination, so indirection across multiple lines is caught, not just the direct one-line form. Routing the value through a recognised validation / allow-list sanitizer clears the taint and suppresses the finding. Why dataflow →
Why this matters
An attacker can craft a phishing link to your real callback that forwards the victim to a malicious site under your domain's trust.
Maintain an explicit allow-list of post-login redirect destinations (route names or full URLs you control). Never forward to an arbitrary req.query.redirect_to, req.query.next, or req.query.return_url value.
VULNERABLE
vulnerable.ts
interface Req { query: { redirect_to?: string; next?: string; url?: string }; body: { return_url?: string };}interface Res { redirect: ((url: string) => void) & ((code: number, url: string) => void);}export function badCallback(req: Req, res: Res) { // ruleid: auth.oauth.open-redirect-callback res.redirect(req.query.redirect_to as string);}export function badCallback2(req: Req, res: Res) { // ruleid: auth.oauth.open-redirect-callback res.redirect(req.body.return_url as string);}export function badCallback3(req: Req, res: Res) { const key = 'next'; // ruleid: auth.oauth.open-redirect-callback res.redirect(req.query[key] as string);}export function badCallbackIndirect(req: Req, res: Res) { const next = req.query.next as string; // ruleid: auth.oauth.open-redirect-callback -- variable indirection res.redirect(next);}export function badCallbackDefault(req: Req, res: Res) { // ruleid: auth.oauth.open-redirect-callback -- logical-or default res.redirect((req.query.next as string) || '/');}export function badCallbackStatus(req: Req, res: Res) { // ruleid: auth.oauth.open-redirect-callback -- status + url overload res.redirect(302, req.query.url as string);}
SAFE
safe.ts
interface Req { query: { next?: string };}interface Res { redirect: (url: string) => void;}// ok: auth.oauth.open-redirect-callback -- map the input to a controlled constantconst DESTINATIONS: Record<string, string> = { profile: '/profile', settings: '/settings',};export function goodCallback(req: Req, res: Res) { switch (req.query.next) { case 'profile': res.redirect(DESTINATIONS.profile); return; case 'settings': res.redirect(DESTINATIONS.settings); return; default: res.redirect('/dashboard'); }}// ok: auth.oauth.open-redirect-callback -- always redirects to a fixed destinationexport function goodCallback2(_req: Req, res: Res) { res.redirect('/dashboard');}const ALLOWED = new Set(['/profile', '/settings']);// ok: auth.oauth.open-redirect-callback -- inline allow-list guard validates the// value before it reaches res.redirect.export function guardedCallback(req: Req, res: Res) { if (ALLOWED.has(req.query.next as string)) { res.redirect(req.query.next as string); }}const ALLOWED_ARR = ['/profile', '/settings'];// ok: auth.oauth.open-redirect-callback -- Array.includes guard.export function guardedCallbackIncludes(req: Req, res: Res) { if (ALLOWED_ARR.includes(req.query.next as string)) { res.redirect(req.query.next as string); }}
Suppressing this rule
If a finding is a genuine false positive, scope the suppression to the exact line and leave a reason, never disable the rule project-wide. Disable directives are line-scoped by design.