UntrustedHeaderValue
error native on by defaultGroup: Security
Detect request-derived data reaching a response-header value without a dominating CR/LF rejection or safe construction summary.
Example
<?php
header('X-Label: ' . $_GET['label']);
$label = $_GET['label'];
if (preg_match('/[\r\n]/', $label)) {
exit;
}
header('X-Label: ' . $label); // not reported: line breaks were rejected
header('Content-Length: ' . strlen($_POST['body'])); // not reported: integer result
$direction = match ($_GET['sort']) { 'desc' => 'DESC', default => 'ASC' };
header('X-Sort: ' . $direction); // not reported: only literals can be selectedReported:
- line 2: Reject line breaks and validate this header value.
Detection
- Report request-derived data reaching a response-header value without a dominating CR/LF rejection or safe construction summary.
- 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.
- A CR/LF rejection is recognized for a variable when the tested expression still carries the single assignment that reaches the header value, and the conditions guarding the use prove the test found no line break: the branch of an
if/elseif/elsechain, a ternary ormatch (true)arm, or the right operand of&&/||entered only then, or code after anifwhose branch for a line break always leaves (exit,return,throw,break,continue), with or without other arms. The test result may also be kept in a local and tested there. A jump label between the test and the use voids it. - The accepted line-break tests are a two-argument builtin
preg_matchwith a literal, modifier-free pattern made of one character class holding exactly CR and LF (delimiter/,#or~, single- or double-quoted), and a two-argument builtinstrpbrkwhose literal character list contains both bytes. They may be used bare, negated with!, compared with===,==,!==,!=against0,1(preg_matchonly) orfalsein either operand order, written aspreg_match(...) > 0, and combined through||(rejecting guard) or&&(accepting branch). The pairedstr_containschecks and thepreg_match(...) === 0forms established by the shared flow analysis remain accepted. - The header value is examined structurally: a concatenation or interpolated string is safe when every part is; a ternary when both results are; a
matchwhen every arm result is (the subject and arm conditions only select, and a throwing arm yields nothing); a variable when its single reaching assignment is. The walk is bounded to 32 nested steps; deeper values keep the report. A variable is followed to its reaching assignment only when its scope cannot rebind it behind that assignment; a reference alias, a by-reference closure capture,foreachor argument, acatchbinding orstaticdeclaration of the same name,extract(), an assignment through a variable variable, aglobaldeclaration of it or a$GLOBALSaccess that is not a different literal entry keeps the report.
Exceptions (no report)
Constant values, validated finite choices, established CR/LF rejection and unknown flows are excluded.
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.
Known boolean or numeric scalar output and resolved builtin
md5,sha1,hashorhash_hmachexadecimal output cannot inject the target text syntax. Raw binary or unknown output modes retain source taint. These narrow text proofs do not establish URL or filesystem-path authorization.Results that are numbers or booleans whatever bytes the input holds: resolved builtins returning an integer or boolean (
strlen,mb_strlen,count,crc32,ord, thestrcmpandstrposfamilies,substr_count,levenshtein,similar_text,in_array,array_key_exists,str_contains,str_starts_with,str_ends_with, everyctype_*andis_*function), unary+/-, and binary-,*,/,%,**. Binary+qualifies only when one operand is provably not an array (a literal, an(int),(float),(bool)or(string)cast, or one of those builtin calls):array + arrayis a union that keeps request strings.A guard proves nothing, and the report is kept, when the value is reassigned after it on any path, when it tests another value or a transformed copy, when its body can fall through, when the pattern has modifiers, a negated or wider class, or only one of the two bytes, or when
preg_matchreceives an offset.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: header-specific validation must preserve application meaning.
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:
{
"rules": {
"UntrustedHeaderValue": {
"enabled": false
}
}
}Suppress
Before the statement or declaration (or the first statement of the file):
// @custos-ignore UntrustedHeaderValue