v0.14 is out: a mobile auth pack for Swift/iOS and Android, catching insecure token storage, cleartext traffic, and OAuth in embedded WebViews. Read more →
HIGH AI PREVALENCE: HIGH auth.flow.ssrf

Untrusted request input flows into the URL of an outbound HTTP request.

CWE-918 OWASP API7:2023 js · ts DATAFLOW

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, cookies, headers) through your code to the URL of an outbound HTTP request, 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

Because the destination is attacker-controlled, this is a Server-Side Request Forgery (CWE-918): an attacker can point the request at internal services behind your firewall, or at the cloud metadata endpoint (http://169.254.169.254/...) to steal IAM/instance credentials and pivot deeper into your infrastructure.

Never build a request URL straight from a raw req.query / req.body / req.params / req.cookies / req.headers value. Validate the destination against an explicit allow-list of hosts (resolve the URL and check its host against the allow-list, rejecting private/loopback ranges) before issuing the request.

VULNERABLE
vulnerable.ts
import type { Request, Response } from 'express';
import axios from 'axios';
import http from 'node:http';
import https from 'node:https';
import got from 'got';

// Direct: ?url= straight into fetch().
export async function proxy(req: Request, res: Response): Promise<void> {
  // ruleid: auth.flow.ssrf
  const r = await fetch(req.query.url as string);
  res.json(await r.json());
}

// Request body field flows into axios.get().
export async function fetchAvatar(req: Request, res: Response): Promise<void> {
  // ruleid: auth.flow.ssrf
  const r = await axios.get(req.body.target as string);
  res.json(r.data);
}

// POST destination from the body.
export async function webhook(req: Request, res: Response): Promise<void> {
  // ruleid: auth.flow.ssrf
  await axios.post(req.body.callback as string, { ok: true });
  res.sendStatus(200);
}

// axios.request({ url: ... }) config form.
export async function relay(req: Request, res: Response): Promise<void> {
  // ruleid: auth.flow.ssrf
  const r = await axios.request({ method: 'GET', url: req.query.endpoint as string });
  res.json(r.data);
}

// Intra-procedural dataflow: assign to a local, then http.get().
export function fetchLegacy(req: Request, res: Response): void {
  const u = req.query.endpoint as string;
  // ruleid: auth.flow.ssrf
  http.get(u, (upstream) => upstream.pipe(res));
}

// https.request with a request-controlled URL.
export function fetchSecure(req: Request, res: Response): void {
  // ruleid: auth.flow.ssrf
  const upstream = https.request(req.params.target, (r) => r.pipe(res));
  upstream.end();
}

// Route param flows into got().
export async function mirror(req: Request, res: Response): Promise<void> {
  // ruleid: auth.flow.ssrf
  const r = await got(req.params.dest);
  res.send(r.body);
}

// Header value flows into a request via https.get().
export function fromHeader(req: Request, res: Response): void {
  // ruleid: auth.flow.ssrf
  https.get(req.headers['x-upstream'] as string, (r) => r.pipe(res));
}
SAFE
safe.ts
import type { Request, Response } from 'express';
import axios from 'axios';
import http from 'node:http';

// Constant destination — never tainted, never fires.
export async function health(_req: Request, res: Response): Promise<void> {
  const r = await fetch('https://api.internal/health');
  res.json(await r.json());
}

// Constant base URL with a fixed path.
export async function status(_req: Request, res: Response): Promise<void> {
  const r = await axios.get('https://api.internal/status');
  res.json(r.data);
}

const ALLOWED_HOSTS = new Set(['api.partner.com', 'cdn.partner.com']);

// Host allow-list helper: only returns the URL when its host is trusted.
function isAllowedUrl(url: string): string {
  try {
    return ALLOWED_HOSTS.has(new URL(url).host) ? url : 'https://api.partner.com/';
  } catch {
    return 'https://api.partner.com/';
  }
}

// Raw request input only reaches fetch() after passing the allow-list check.
export async function fetchPartner(req: Request, res: Response): Promise<void> {
  const target = isAllowedUrl(req.query.url as string);
  const r = await fetch(target);
  res.json(await r.json());
}

const ALLOWED_ENDPOINTS = ['https://api.partner.com/v1', 'https://api.partner.com/v2'];

// Validation helper returns a vetted endpoint via an allow-list membership test.
function validateUrl(candidate: string): string {
  return ALLOWED_ENDPOINTS.includes(candidate) ? candidate : ALLOWED_ENDPOINTS[0];
}

export async function callPartner(req: Request, res: Response): Promise<void> {
  const endpoint = validateUrl(req.body.endpoint as string);
  const r = await axios.get(endpoint);
  res.json(r.data);
}

const HOSTS = new Set(['internal.svc']);

// assertAllowedHost-style helper used before the request.
function assertAllowedHost(url: string): string {
  if (!HOSTS.has(new URL(url).host)) {
    throw new Error('host not allowed');
  }
  return url;
}

export function relayInternal(req: Request, res: Response): void {
  const dest = assertAllowedHost(req.params.target);
  http.get(dest, (upstream) => upstream.pipe(res));
}

const ALLOWED_URLS = new Set(['https://api.partner.com/data']);

// Inline allow-list guard: the raw request value is only fetched inside the
// `if (ALLOWED_URLS.has(...))` block, so it is validated before use.
export function guardedFetch(req: Request, res: Response): void {
  if (ALLOWED_URLS.has(req.query.url as string)) {
    http.get(req.query.url as string, (upstream) => upstream.pipe(res));
  }
}

const ALLOWED_LIST = ['https://api.partner.com/data'];

// Inline allow-list guard using Array.includes.
export async function guardedFetchIncludes(req: Request, res: Response): Promise<void> {
  if (ALLOWED_LIST.includes(req.body.endpoint as string)) {
    const r = await fetch(req.body.endpoint as string);
    res.json(await r.json());
  }
}

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.

// oauthlint-disable-next-line auth.flow.ssrf -- <reason>

References

https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html ↗https://cwe.mitre.org/data/definitions/918.html ↗