TL;DR
A check that asks “does string X appear somewhere in this input” is not validation. Substring containment (strpos, indexOf, includes) and unanchored prefix matching answer the wrong question. They confirm that a needle is present. They do not confirm that the input is structurally the thing the code expected. That gap is where the bypass lives.
The failure mode
Two operations get misused as validation.
Substring containment:
if (strpos($request_uri, $expected) !== false) {
// treat request as trusted / routed / internal
}
strpos returns a match when $expected appears anywhere in the string. Not as a path segment. Not at a boundary. Anywhere. /anything?x=<expected> satisfies it exactly as well as the intended /<expected>/resource.
Prefix matching against a host or origin:
if (target.startsWith("https://app.example.com")) {
// treat as same-site
}
This accepts https://app.example.com.evil.com. The prefix is intact; the authority is attacker-controlled. The comparison never parsed the URL, so it never learned where the host actually ends.
Both share one root cause: a string operation stands in for a parse. The code compares bytes when it needed structure.
Where it breaks
Redirect allowlists
The canonical case, covered at length in the open redirect writeup. An allowlist implemented with startsWith or strpos is bypassed by appending a controlled label (app.example.com.evil.com), prepending userinfo ([email protected]), or embedding the allowed value in a path (evil.com/app.example.com). The allowlist matches. The browser resolves a different origin.
Origin and host checks
CSRF and CORS logic that gates on strpos($origin, 'example.com') accepts example.com.evil.com and evil.com/?ref=example.com. The check reads as “same company.” What it means is “the seven characters e-x-a-m-p-l-e appear.”
Request routing that changes behavior
The pattern is not limited to redirects and origins. Any code path that uses substring containment to decide how a request is handled inherits the same flaw, and the blast radius depends on what the code does next.
A 2026 example, outside the redirect context: a WordPress analytics plugin shipped a performance module that used strpos($request_uri, $namespace) to decide whether an incoming request was destined for its proxy endpoint. The namespace was not a secret. It is printed in the page source of every page that loads the tracking script. Appending it to any URL as an unrelated query parameter satisfied the check, and the module then unloaded every other active plugin for that request, security plugins included. Unauthenticated, and on a request the attacker chose. The full analysis, including the confirmed WAF bypass and the vendor fix, is in a writeup on WP Spear. The root cause is the one above: strpos answered “appears anywhere,” and the code needed “is the request path.” This is CWE-697 (incorrect comparison), and the redirect variant is the one OWASP documents under unvalidated redirects. The interpretation-conflict framing sits under CWE-436.
The fix
Parse first, then compare structurally.
For a request path, resolve the path and require an exact segment match, not containment:
$path = parse_url($request_uri, PHP_URL_PATH); // no query, no fragment
$expected = '/wp-json/' . trim($namespace, '/');
$ok = $path === $expected || str_starts_with($path, $expected . '/');
For a URL, parse it and compare the host by exact label, per the WHATWG URL spec rather than a hand-rolled prefix test:
const u = new URL(target, base);
const ok = u.origin === base.origin; // origin, not startsWith
For an allowlist, compare against parsed values with strict equality, not substring membership:
$host = parse_url($target, PHP_URL_HOST);
$ok = in_array($host, $allowed_hosts, true); // exact, type-strict
The rule is the same in each: parse_url (or the platform URL object) turns the input into components, and the decision is made on a component with === / in_array(..., true). See the parse_url notes on its own edge cases before trusting it blindly.
Test payloads
Against any check suspected of substring or prefix matching, the standard set:
https://allowed.example.com.evil.com/
https://evil.com/allowed.example.com
https://[email protected]/
https://evil.com#allowed.example.com
/?param=<allowed-substring>
/legit/path/../../<allowed-substring>
If any of these flips the check to “trusted,” the check is comparing bytes, not parsing structure. Replace it.