Skip to content
CWE-73A01:2021 – Broken Access Control

Defense in Depth

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.

There is one fix

The parameter must stop being a path.

Map an identifier to a filename through a fixed table. The user chooses from a set you defined; they do not supply a location. Once that is true, every technique on this site stops applying at once — there is nothing to encode, nothing to traverse, no wrapper to reach, and no resolution to confuse.

Everything else on this page is a mitigation. Mitigations are worth having and they are not substitutes. A report that recommends open_basedir without recommending the allowlist will get open_basedir and the same bug will be found again.

The allowlist

PHPSecure
<?php
const PAGES = [
    'home'    => 'home.php',
    'about'   => 'about.php',
    'contact' => 'contact.php',
];

$key = $_GET['page'] ?? 'home';
if (!array_key_exists($key, PAGES)) { http_response_code(404); exit; }

include __DIR__ . '/pages/' . PAGES[$key];

When the set genuinely cannot be enumerated

A user-content browser, a document repository, a per-tenant file store — sometimes the file set is open by design.

Then: resolve to a canonical path, and compare the result. Never check the input string.

Three things this has to get right, and each has been the subject of its own bug:

  1. Resolve symlinks, not just ... realpath() in PHP, getCanonicalPath() in Java, fs.realpath in Node, Path.resolve(strict=False) then is_relative_to in Python, filepath.EvalSymlinks in Go. The functions that only collapse .. textually — normalize(), path.resolve(), Path.GetFullPath() — leave the symlink case open.
  2. Compare with a trailing separator. /app/data as a prefix also matches /app/data-backup. Compare against /app/data/, or use a component-wise comparison (Path.startsWith in Java, is_relative_to in Python) which does not have this problem.
  3. Handle the failure value. realpath() returns false for a stream wrapper as well as for a missing file. Code that treats false as "nothing suspicious found" is a bypass. Reject explicitly.

In Go 1.24+, os.Root does all of this at the syscall level and is strictly better than any of it. Prefer it where you can.

The mitigations, and what each is worth

ControlStopsDoes not stop
Allowlist mapEverything on this site
realpath + prefix checkTraversal, symlinks, wrappersNothing much, if written correctly
Prepend a base directoryEvery php:// wrapper — filter chains includedTraversal
open_basedirReads outside the app tree: /etc, /proc, logs, sessionsFilter chains, data://, the app's own config
allow_url_fopen=OffRemote reads and remote includesEverything local, which is all of it
allow_url_include=OffClassic RFI, data://, php://inputFilter chains, traversal — and it is already the default
disable_functionsWhat the payload can do after executionThe execution itself
Re-encode uploaded imageszip:// and phar:// polyglotsTraversal to other files
str_replace / denylistsVery littleAlmost everything — and it can build the traversal for you
A WAFUnencoded payloads from a generic scannerAnything encoded, and anything targeted

How to write the remediation

The remediation section is where reports on this bug class most often go wrong, in two specific ways.

Recommending allow_url_include=Off. It is already off. It has been off by default since 2006. It is not what allowed the finding, and recommending it tells the reader the report was written from an old cheatsheet. If the finding genuinely was classic RFI on a permissive host, say that explicitly — the recommendation is correct there and only there.

Recommending sanitisation. "Sanitise the input to remove ../" is what produces the code in Beating Blacklists. It is not a fix, it is the bug's most common failed fix, and putting it in a report is how it ends up in the codebase.

A remediation that holds up says:

  1. Replace the parameter with a key looked up in a fixed map. (the fix)
  2. If the file set cannot be enumerated, resolve to a canonical path and confine it, with the three caveats above. (the alternative)
  3. Additionally, set open_basedir, allow_url_fopen=Off, and a dedicated upload_tmp_dir. (defence in depth, explicitly labelled as such)

Ordering them like that, with the labels, is what stops step 3 being implemented instead of step 1.

Verifying the fix

Retest with the full set, not just the payload from the original report. A fix that stops one spelling and not another is common enough to be the default expectation.

  • The original payload.
  • ....// and ..%2f, in case the fix was a single replace. See Beating Blacklists.
  • %252e%252e%252f, in case something decodes after the check.
  • An absolute path, in case the fix only looks for .. — and in Python and .NET, because join discards the base.
  • A wrapper, if the runtime is PHP: php://filter/convert.base64-encode/resource=….
  • A sibling directory, e.g. ../pages-backup/x, in case the prefix comparison has no trailing separator.
  • A valid value with a traversal appended, e.g. home/../home, which distinguishes a real map lookup from a path check.

If every one of those returns the same default response, the parameter is no longer a path and the fix is real. The lab implements each control so you can see what a correct one looks like from the outside.