Skip to content

UnvalidatedRedirectTarget ​

error native on by default

Group: Security

Detect request-derived data reaching a Location header without a proven permitted-local-target or finite destination policy.

Example ​

php
<?php
header('Location: ' . $_GET['next']);
header("Location: {$_GET['next']}#top");

// Not reported: finite selection, and encoded query data on a fixed local path.
$page = match ($_GET['tab']) { 'billing' => '/account/billing', default => '/account' };
header('Location: ' . $page);
header('Location: /find?term=' . rawurlencode($_GET['term']));

Reported:

  • line 2: Validate the redirect target against the permitted destination policy.
  • line 3: Validate the redirect target against the permitted destination policy.

Detection ​

  • Report request-derived data reaching a Location header without a proven permitted-local-target or finite destination policy.
  • The first argument of header() is read as the ordered operands of its concatenation chain and of any interpolated string (double-quoted or heredoc) inside it, ignoring grouping parentheses. The leading operands whose text is statically known form the written prefix; the call is a redirect when that prefix starts with Location: in any letter case. Every operand after the prefix is checked, so request data followed by a suffix ('Location: ' . $_GET['return'] . '?saved=1') or embedded in an interpolated string is reported exactly like a trailing operand.
  • A match expression, written in place or reached through a variable with one agreeing definition, contributes only the request data of its arm results. The subject chooses an arm but is never part of the produced value. An arm that yields request data is still reported. Selections nested more than eight levels deep are treated as request-derived. 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.
  • 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) ​

  • Literal destinations, configured allowlisted origin/path validation and unknown taint flows are excluded.
  • A match over request data whose arms all produce values free of request data is a finite destination policy.
  • Fixed local path with encoded query values: when the written prefix after Location: starts with a single / (the next character is neither / nor \) and already contains ?, an operand produced by the builtin urlencode(), rawurlencode() or http_build_query() (directly, or through a variable with one agreeing definition) is not reported. Such a value can only be query data on the current origin. Any other request-derived operand in the same header is still reported, as is an encoded value without that prefix, in a path segment, or after a // or /\ prefix.
  • A backtick command as the header argument is shell output, not written header text; it has no written prefix and is not reported by this rule.
  • 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.
  • 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: the set of permitted destinations must be supplied by the application.

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": {
    "UnvalidatedRedirectTarget": {
      "enabled": false
    }
  }
}

Suppress ​

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

php
// @custos-ignore UnvalidatedRedirectTarget

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