Skip to main content
interlace
Plugin: browser-securityRules

no-incomplete-url-sanitization

Disallow URL substring tests and partial scheme denylists as security decisions

Rejects URL checks that look like validation but cannot constrain the URL

Severity: 🟠 HIGH
CWE: CWE-020: Improper Input Validation
OWASP: A01:2021 Broken Access Control

Rule Details

Two checks that read as URL validation but cannot make the decision they are being asked to make.

A substring test standing in for a host check. The host of a URL lives in exactly one place — the authority component — and the only way to read it is to parse the URL. url.includes('trusted.com') is true for https://evil.io/?r=trusted.com (the string is in the query) and for https://trusted.com.evil.io/ (the string is a prefix of a different host). indexOf(…) !== -1 and lastIndexOf(…) !== -1 are the same test spelled differently. lastIndexOf is the most common way this bug is written, because it is usually reached for intending a suffix check — which it only becomes once the result is compared against host.length - needle.length.

A dangerous-scheme denylist that stops at javascript:. A sanitiser that rejects javascript: and hands everything else through still passes data:text/html;base64,PHNjcmlwdD4…, which executes script in an href on every current browser. A denylist has to enumerate every dangerous scheme, and the list grows; an allowlist of http: / https: denies the rest by default.

Why This Matters

  • Open redirect / SSRF: a host allowlist that a query parameter can satisfy is not an allowlist.
  • Cookie scoping: Domain= derived from a substring-checked Host header hands the cookie to an attacker-controlled host.
  • XSS: an incomplete scheme denylist is a script-execution sink one URL away.

❌ Incorrect

// A substring test cannot decide the host
function isTrustedApi(url: string) {
  return url.includes('trusted.com'); // ❌ "https://evil.io/?r=trusted.com"
}

// indexOf spelled the same bug
function isTrustedSubdomain(hostname: string) {
  return hostname.lastIndexOf('.trusted.com') !== -1; // ❌ ".trusted.com.evil.io"
}

// A denylist that stops at javascript:
function sanitizeHref(raw: string) {
  const value = String(raw).trim().toLowerCase();
  if (value.startsWith('javascript:')) return '#'; // ❌ data: still executes
  return raw;
}

✅ Correct

// Parse, then compare the host with an explicit boundary
function isTrustedApi(url: string) {
  try {
    const { hostname } = new URL(url);
    return hostname === 'trusted.com' || hostname.endsWith('.trusted.com');
  } catch {
    return false; // a URL that will not parse is not trusted
  }
}

// Allowlist the schemes you support
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
function sanitizeHref(raw: string) {
  try {
    const parsed = new URL(raw, window.location.origin);
    return ALLOWED_PROTOCOLS.includes(parsed.protocol) ? parsed.href : '#';
  } catch {
    return '#';
  }
}

⚙️ Configuration

This rule has no configuration options.

Known False Negatives

startsWith / endsWith on a URL

Why: url.startsWith('https://trusted.com') and url.endsWith('trusted.com') are bypassable the same way, but both are also written correctly far more often than includes is — host.endsWith('.trusted.com') with the leading dot is the recommended fix. Flagging the family wholesale would report the fix as the bug.

// ❌ NOT DETECTED
if (url.startsWith('https://trusted.com')) go(url); // "https://trusted.com.evil.io"

Mitigation: compare a parsed hostname, never a URL prefix.

Receivers with no name and no taint

Why: the rule needs evidence that the value under test is a URL — either a binding named for one (url, host, origin, href, …) or a taint path from a request or location. value.includes('trusted.com') on an opaque local is not enough to justify a report.

// ❌ NOT DETECTED - nothing says `value` holds a URL
if (value.includes('trusted.com')) go(value);

Mitigation: name URL bindings for what they hold.

Denylists split across functions

Why: the scheme denylist is judged per enclosing function. A helper that tests javascript: while its caller tests data: is reported.

// ❌ REPORTED even though the pair is complete
const isJs = (u) => u.startsWith('javascript:');
const isData = (u) => u.startsWith('data:');

Mitigation: keep the scheme decision in one place — ideally an allowlist.

📚 References

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.