Allowlist and Prefix Bypasses
An allowlist is the correct fix, so most of them are not really allowlists. Here is how to tell, and what a prefix does and does not stop.
Two things called the same thing
"We validate against an allowlist" describes two very different implementations.
A real allowlist maps a key to a value. The user's input is a key; it is never a path. If the key is not in the map, you get the default. There is nothing to encode, nothing to traverse, no wrapper to reach, and no bypass on this site applies.
A prefix check confirms that the input begins with or contains something approved, then uses the input as a path anyway. This is not an allowlist. It is a denylist wearing a hat, and it fails the same way denylists do.
Most implementations described as allowlists are the second kind.
What a real allowlist looks like
<?php
$pages = [
'home' => 'home.php',
'about' => 'about.php',
'contact' => 'contact.php',
];
// The input is a KEY. It is looked up. It is never concatenated into a path.
$file = $pages[$_GET['page'] ?? 'home'] ?? null;
if ($file === null) { http_response_code(404); exit; }
include __DIR__ . '/pages/' . $file;
// ../../../etc/passwd -> not a key -> 404
// php://filter/… -> not a key -> 404
// %252e%252e%252f… -> not a key -> 404
//
// Every technique on this site is aimed at influencing a path. This code
// has no attacker-influenced path to aim at.The ones that are not
<?php
// 1. in_array on a value that is then used as a path anyway. Correct in
// isolation — but see the loose-comparison note below.
if (in_array($_GET['page'], ['home', 'about'])) { include($_GET['page'] . '.php'); }
// Bypass: none, if the comparison is strict. This one is fine.
// Bypass: in_array($x, $arr) without the third argument is LOOSE. In PHP 8
// string-to-string comparison is no longer surprising, but
// in_array(0, ['home']) is still true in PHP 7. Check the version.
// 2. "Starts with the right directory."
if (str_starts_with($_GET['page'], 'pages/')) { include($_GET['page']); }
// Bypass: pages/../../../etc/passwd <- it does start with pages/
// 3. "Contains the right directory."
if (str_contains($_GET['page'], 'pages')) { include($_GET['page']); }
// Bypass: ../../../etc/passwd?pages <- contains it
// Bypass: pages/../../../etc/passwd
// 4. "Ends with an approved extension."
if (str_ends_with($_GET['page'], '.php')) { include($_GET['page']); }
// Bypass: ../../../../tmp/uploaded.php
// Bypass: php://filter/convert.base64-encode/resource=../config.php
// 5. Checked before normalising, used after.
$p = $_GET['page'];
if (str_contains($p, '..')) { exit; }
include(urldecode($p)); // <- the decode happens AFTER the check
// Bypass: %2e%2e%2f%2e%2e%2fetc/passwd
// 6. realpath() prefix check without a trailing separator.
$base = realpath(__DIR__ . '/pages');
$real = realpath($base . '/' . $_GET['page']);
if (str_starts_with($real, $base)) { include($real); }
// Bypass: a sibling directory named pages-backup or pages2 passes the check.
// Compare against $base . DIRECTORY_SEPARATOR instead.What a prepended directory actually buys
include('pages/' . $page) is not a security measure and nobody writes it as one. But it has one significant security effect and one significant non-effect, and both are worth knowing precisely.
It kills every PHP wrapper. A scheme only counts at position zero. With pages/ in front, php://filter, data://, zip://, phar:// and expect:// all become ordinary directory names. This is a genuine mitigation — it removes source disclosure, filter chains, and phar deserialization in one go — and there is no way to recover from it via the parameter. You cannot move text that is already in front of your input.
It does nothing against traversal. pages/../../../etc/passwd resolves exactly as well as ../../../etc/passwd. The prefix is consumed by the first ...
So when you meet a prefixed sink, adjust immediately: stop trying wrappers, and treat it as pure path traversal. When you write about it in a report, say that the prefix limits the impact to disclosure rather than describing it as a control the developer chose.
Working out which you are facing
Distinguishing a real allowlist from a prefix check saves a lot of time, and it takes two requests.
Send a valid value with a harmless suffix. If ?page=home works and ?page=home2 gives the same page as ?page=garbage, you are looking at a map lookup with a default. If ?page=home2 gives a different error — a missing-file error rather than a default page — the value is reaching the filesystem, and you have a path.
Send a valid value with a traversal after it. ?page=home/../home. A map lookup 404s or defaults. A path resolves it and serves the page. This is the single most informative request against a suspected allowlist.
Look for a default. A real allowlist almost always has one, and it is highly visible: every invalid input produces byte-identical output. A path-based check produces varied errors depending on where resolution failed.
If every probe returns the same default page, stop. That is the fix working, and there is nothing here. Go and find a different parameter.
The same mistakes in other runtimes
The pattern travels. What changes is which language feature makes it worse.
- Python and .NET:
os.path.join(base, user)andPath.Combine(base, user)both discard the base entirely when the user part is absolute. A prefix check onbasepasses and the prefix then evaporates. See Python and ASP.NET Core. - Node:
path.join('views', x)collapses..before the filesystem sees it, so the escape happens inside the join and a check on the joined string is checking the wrong value at the wrong time. See Node.js. - Java:
new File(dir, name)does not normalise, so a check ongetPath()sees the un-collapsed form while the filesystem sees the collapsed one. UsegetCanonicalPath(). See Java. - Go:
filepath.Joincleans, which removes..— but cleaning is not confining, and a cleaned absolute path is still absolute. See Go.
In every case the fix is identical: resolve to canonical form first, compare second, and prefer a map lookup over both.
Related
A filter that removes bad strings has to anticipate every spelling. Resolution has to anticipate nothing. That asymmetry is why str_replace loses.
Traversal works because of specific, boring rules about how a string becomes a file. Knowing them turns guessing at ../ counts into arithmetic.
PHP's stream layer makes every filesystem function accept a URL. That one design decision is why file inclusion in PHP is a different bug from file inclusion anywhere else.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.
The only runtime with a stream-wrapper layer, and therefore the only one where a filename can become a URL. What to fix, in the order it matters.