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
<?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:
- Resolve symlinks, not just
...realpath()in PHP,getCanonicalPath()in Java,fs.realpathin Node,Path.resolve(strict=False)thenis_relative_toin Python,filepath.EvalSymlinksin Go. The functions that only collapse..textually —normalize(),path.resolve(),Path.GetFullPath()— leave the symlink case open. - Compare with a trailing separator.
/app/dataas a prefix also matches/app/data-backup. Compare against/app/data/, or use a component-wise comparison (Path.startsWithin Java,is_relative_toin Python) which does not have this problem. - Handle the failure value.
realpath()returnsfalsefor a stream wrapper as well as for a missing file. Code that treatsfalseas "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
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:
- Replace the parameter with a key looked up in a fixed map. (the fix)
- If the file set cannot be enumerated, resolve to a canonical path and confine it, with the three caveats above. (the alternative)
- Additionally, set
open_basedir,allow_url_fopen=Off, and a dedicatedupload_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, becausejoindiscards 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.
Related
An order of operations that answers the questions that change what you do next, before spending time on payloads that the answers would have ruled out.
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.
A filter that removes bad strings has to anticipate every spelling. Resolution has to anticipate nothing. That asymmetry is why str_replace loses.
Two hardening settings that are worth having and are not fixes. Knowing exactly what each one confines tells you what is still reachable when you meet them.
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.