UntrustedFilesystemPath
error native on by defaultGroup: Security
Detect request-derived path components reaching file read/write/delete or include sinks without established canonical containment or finite filename selection.
Example
php
<?php
readfile('/srv/downloads/' . $_GET['file']);Reported:
- line 2: Validate filesystem containment before using this path.
Detection
- Report request-derived path components reaching file read/write/delete or include sinks without established canonical containment or finite filename selection.
- Resolve builtin calls and named arguments before checking the contract. Project-local callees are considered only when their bodies or immutable summaries establish the required facts. The default is enabled, with positive proof required.
- For flow-dependent conditions, require a reachable source-to-use path with no intervening invalidation. Unknown calls, ambiguous aliases, recursion or analysis-budget exhaustion cannot establish definite facts.
Exceptions (no report)
- Proven boundary-aware containment with explicit symlink policy, fixed paths, validated finite names and unknown flows are excluded.
- E1a. Canonical containment is proven for a variable only when all of the following hold. Anything weaker keeps the report.
- The variable's single reaching value at the sink is a call to builtin
realpath(), so symlinks and..are already resolved. - A prefix test on that same value (same reaching assignment) has succeeded where the sink runs: the conditions guarding it prove the test passed, from the branch of an
if/elseif/elsechain, a ternary ormatch (true)arm or the right operand of&&/||entered only then, or from code after anifwhose branch for a failed test always leaves (return, throw, exit, …), with or without other arms. Conditions combine through parentheses,!,&&,||and comparisons with booleans, and the test result may be kept in a local; a non-leaving body or a jump label between the test and the sink proves nothing. - The test is
str_starts_with($p, PREFIX),strpos($p, PREFIX) === 0without an offset (or!== 0on the rejecting side, operands in either order), orstrncmp($p, PREFIX, N) === 0with an integerNnot smaller than the byte length of a literalPREFIX. Loose comparison is not accepted:strpos() == 0is also true forfalse. PREFIXis a string literal longer than one byte that starts and ends with/, or$base . '/'/$base . DIRECTORY_SEPARATORwhere$baseresolves to a builtinrealpath()call on a path without request data and is proven notfalseat the test. An unchecked base may befalse, which turns the prefix into/and accepts every absolute path. A prefix without the trailing separator also matches sibling directories.- Nothing in the scope can rebind or alter the variable (or the
$basevariable) behind the analysis: no closure capturing it by reference, nocatchbinding orstaticdeclaration of the same name, no reference alias, noextract()call, noglobaldeclaration of it and no$GLOBALSuse; and, located after therealpath()assignment, no other assignment to it (compound, destructuring slot, offset write, variable variable), no++/--, noforeachkey or value variable and no argument bound to a by-reference parameter of a resolved callee. A write that ends before therealpath()assignment is superseded by it. The check is positional, so a later reuse of the variable name keeps the report. A value that code holding a reference may have replaced (a closure capturing the variable by reference that is then called, a called function of the same file writing it throughglobalor$GLOBALS) keeps the request data it held and gains the request data that code writes to it, so the report stays. After acatchclause bound to the variable it holds the exception and is no longer request-derived. Passing the variable to a function the analysis cannot resolve neither validates it nor removes its request data: a project helper such asinside($p, $base)is not a containment proof, whether or not its declaration is visible. - An explicit
=== falserejection of the canonical value is not required: each accepted test already fails forfalse.
- The variable's single reaching value at the sink is a call to builtin
- E1b. A value produced by a
matchexpression whose arm results all carry no request data is a finite server-chosen name, whatever the subject is. One request-derived arm result keeps the report, and so does a variable holding the selection onceextract()of request data or a reference holder writing request data may have replaced it. The shared flow analysis provides this: amatchcarries the request data of its arm results only. - A concatenation is excluded when every operand is clean or excluded by E1a/E1b.
- Unresolved targets, incomplete required facts, suppressed findings and PHP versions lacking the referenced API or language feature produce no report. User declarations shadowing builtin names are not builtin contracts.
- A value the shared flow analysis proves is not the failure value at the use is never reported, whatever form the proof takes: a test in an enclosing branch, loop condition or short-circuit operand, an early exit, a statement that leaves on the failing outcome (
$v !== false or exit;,$v or throw …;,$v ?: throw …;), a failing branch that ends in a call that never returns (a callee declarednever, or a function or a final or private method every path of which throws or exits), a test stored in a boolean and tested later while neither the boolean nor the tested variable has been written, and, on PHP 8.0 and later outside anytrywith acatch, a builtin call that returned after receiving the value for a parameter that only accepts arrays, objects or callables: such a call throws aTypeErrorforfalse. Aswitch (true)case is read like the condition of anif/elseifchain; in aswitchon the variable itself with constant labels, the cases after afalseornulllabel and thedefaulthold a value that is neither, as does atruecase. Positions PHP may skip prove nothing: the right side of??=, the arguments of a null-safe call and anything chained after?->. A null-safe call to a never-returning method does not end the path, andf(...)creates a closure without callingf. An arm ofmatch (true)is read like aswitch (true)case; an arm that throws, exits or calls a function that never returns takes no part in what holds after thematch.|and&between two tests are read like||and&&for the outcome that fixes both operands (a false|, a true&), and a strictin_array($v, [...], true)over literals that include neitherfalsenornullexcludes both where it holds.gotojumps are followed, including a jump out of atryorcatchblock, which runs thefinallyblocks it leaves on its way. Where agotolabel is written before the use, and after the place where its scope first names the checked variable, a variable that can hold the same value, or code that may write any variable (extract,compact,get_defined_vars,parse_strgiven one argument, a variable variable,$GLOBALS,eval, an included file), a jump can land between a guard and the use: only what the flow analysis establishes counts there, and a guard recognized by where it is written proves nothing. Two variables can hold the same value when one is written from the other by a plain assignment, an assignment by reference or??=, when aforeachtakes one out of the other, or through a chain of those; in the value assigned, the receiver and the arguments of a call, the operands of an operator other than??, the variables a string embeds and an element key do not count, and a statement that only reads (echo $prefix . $parts['host'];) ties nothing. A parameter is named from the start of the function, and a checked expression that reads$thisor no variable counts every label before it. In a scope the flow analysis does not follow (one that evaluates or includes code, which may write any variable) a guarded use after such a label is therefore reported. A generator is followed: while it is suspended at ayield, the code that consumes it may write only what an unknown call may write, and, when the generator returns by reference, the variable it yields. A label written before all of that, such as aretry:label ahead of thetrythat produces and checks the value, changes nothing, whatever else the function reads beside the checked value; at file level every label written before the use counts. What was established of astaticlocal holds until the next call, or code that runs without a call being written (an object converted to text, a magic property or offset access, the destructor of an object a variable held when it is given another value), that may enter the function again and assign the variable. A call of PHP's own counts as such when it may run code of the project: it is given a callback other than the name of a PHP function that only computes on text and numbers and raises no warning whatever text it is given (a closed list:strlen,trim,intvaland the like, nothexdec,absorchr, which warn about text that is no number), an object it may convert, a class name it loads (method_exists($name, 'm'),is_callable('A::b'),defined('A::B'),class_alias($name, 'B'),new ReflectionClass($name),$arrayObject->setIteratorClass($name)), a class to construct ($statement->fetchObject('Row')) or a filter that one of PHP's ownFILTER_…constants does not name, which may be the one that calls back, or it runs what was registered earlier (an error, session or output-buffer handler, the callbacks of a cURL transfer or an XML parser, an iterator wrapped in another, a filter added to a stream). A warning that a PHP function raises by itself when called directly (hex2bingiven an odd number of digits, a file that cannot be read) is not counted, although the error handler of the project receives it. This limits every guard form of this specification, not only the ones the flow analysis reads. - Request data that a
switchcompared with constant labels is the matched label inside an arm reached only through string labels that do not read as numbers (not empty and not starting, after whitespace, with a digit, a sign or a dot), and after the switch when every other way out replaced it or left the scope:switch ($sort) { case 'name': case 'date': break; default: $sort = 'name'; }validates$sort. A numeric, empty, boolean or null label, a label expression, a fall-through from another arm, adefaultarm and any other subject prove nothing. The subject is a plain variable or an element of a request superglobal read under one constant key (switch ($_GET['action']), not$_FILES); the element is the matched label only while nothing that may run code is evaluated: a call, an object creation, an assignment, anunset, or the use of a local variable, property, offset of a local or class constant ends it, as does a reference to the superglobal. Aswitch (true)reads each case expression like the condition of anif/elseifchain.
Fix
No automatic fix: canonicalization, existence and symlink races require deliberate caller policy.
PHP versions
Apply only on PHP versions supporting the referenced APIs and syntax (within PHP 5.3–8.5). Builtin behavior and argument contracts follow the configured PHP version. Extension-specific calls require resolved extension API symbols; do not treat same-named application functions as extension calls.
Options
None. Enable or disable this rule through normal rule configuration.
Configure
In custos.json:
json
{
"rules": {
"UntrustedFilesystemPath": {
"enabled": false
}
}
}Suppress
Before the statement or declaration (or the first statement of the file):
php
// @custos-ignore UntrustedFilesystemPath