Skip to content

UntrustedHeaderValue ​

error native on by default

Group: Security

Detect request-derived data reaching a response-header value without a dominating CR/LF rejection or safe construction summary.

Example ​

php
<?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 selected

Reported:

  • 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/else chain, a ternary or match (true) arm, or the right operand of &&/|| entered only then, or code after an if whose 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_match with 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 builtin strpbrk whose literal character list contains both bytes. They may be used bare, negated with !, compared with ===, ==, !==, != against 0, 1 (preg_match only) or false in either operand order, written as preg_match(...) > 0, and combined through || (rejecting guard) or && (accepting branch). The paired str_contains checks and the preg_match(...) === 0 forms 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 match when 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, foreach or argument, a catch binding or static declaration of the same name, extract(), an assignment through a variable variable, a global declaration of it or a $GLOBALS access 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, hash or hash_hmac hexadecimal 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, the strcmp and strpos families, substr_count, levenshtein, similar_text, in_array, array_key_exists, str_contains, str_starts_with, str_ends_with, every ctype_* and is_* 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 + array is 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_match receives an offset.

  • Request data that a switch compared 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, a default arm 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, an unset, or the use of a local variable, property, offset of a local or class constant ends it, as does a reference to the superglobal. A switch (true) reads each case expression like the condition of an if/elseif chain.

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:

json
{
  "rules": {
    "UntrustedHeaderValue": {
      "enabled": false
    }
  }
}

Suppress ​

Before the statement or declaration (or the first statement of the file):

php
// @custos-ignore UntrustedHeaderValue

Released under the MIT License. Native rules and rules modeled on Php Inspections (EA Extended); independent clean-room implementation.