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-checkedHostheader 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.
🔗 Related Rules
no-insecure-redirects- Redirect targetsrequire-url-validation- General URL validationno-unvalidated-deeplinks- Deep link targets
📚 References
- CWE-020: Improper Input Validation
- CWE-601: URL Redirection to Untrusted Site
- OWASP: Unvalidated Redirects and Forwards
- WHATWG URL Standard
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.