Skip to content
highCVSS 7.5CWE-22A01:2021 – Broken Access Control

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

PHPno bypass exists for thisSecure
<?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

PHPeach of these has a bypassVulnerable
<?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) and Path.Combine(base, user) both discard the base entirely when the user part is absolute. A prefix check on base passes 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 on getPath() sees the un-collapsed form while the filesystem sees the collapsed one. Use getCanonicalPath(). See Java.
  • Go: filepath.Join cleans, 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.