Skip to content

CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting') - PHP

Overview

HTTP Response Splitting in PHP occurs when user-supplied strings are written to HTTP headers without a decision about what they may contain. PHP's native header() function sends a raw HTTP header line, so anything the caller concatenates into that string becomes header bytes.

What header() Actually Does with a Newline

PHP does not reject the value - it discards the whole header and carries on. Measured on PHP 8.5.8: header("Location: /home\r\nSet-Cookie: admin=true") emits Warning: Header may not contain more than a single header, new line detected, the Location header is absent from the response entirely, no exception is raised, and execution continues to the next statement. A bare \n behaves the same way. This is not the 500 that ASP.NET Core, Node, Flask and Django give, and it is not the space substitution Tomcat and Jetty perform - PHP is a third behaviour again, and the one whose failure mode is easiest to miss.

One value shape goes the other way. In main/SAPI.c, sapi_header_op strips trailing whitespace from the header line before it scans for a CR or LF, and the strip uses isspace(), which covers both. A value whose only newline is at the end is therefore trimmed and the header is sent: header("Location: /account\r\n") emits Location: /account, with no warning and no drop. That is not an injection route, because nothing follows a trailing CRLF - but it is the strip-rather-than-reject repair this page argues against, performed by the runtime itself, and it means a test that establishes the sink is live by sending a trailing newline establishes nothing. Put the CRLF mid-value, as the payloads below do.

What that leaves is a broken endpoint rather than a secured one. The canonical shape

header("Location: $target");
exit;

becomes an exit with no redirect: the browser gets a 200 with whatever output had already been written, and the user is not sent anywhere. An attacker can turn every redirect on the site into a dead end at will, and the only trace is a warning in a log nobody reads. Meanwhile the payloads that need no newline at all - a ; in a cookie value, a " in a Content-Disposition filename, an absolute URL in a Location - are untouched by this check and work exactly as written.

The check also belongs to header() specifically. A framework response object, a custom emitter, a SAPI-level integration, a queue message that is later turned into headers, or anything writing bytes to the output stream itself does not have it. See the main CWE-113 page for how the other ecosystems differ.

Primary Defence: Never pass user input directly to header(). Decide what the value is allowed to be - an allowlist of redirect destinations, a character class for a filename, set membership for an enumerated value - and answer 400 when it does not match, rather than filtering characters out of it. Prefer setcookie() over header("Set-Cookie: ...") for cookies.

Common Vulnerable Patterns

Unvalidated Redirect with header()

<?php
// VULNERABLE - user controls the redirect destination
$target = $_GET['redirect'];
header("Location: $target");
exit;

// redirect=%0d%0aContent-Type:%20text/html%0d%0a%0d%0a<script>alert(1)</script>
//   PHP 8.5: warning, Location dropped, 200 with no redirect - not injection
//   PHP without the header() check, or a custom emitter: the split response
//
// redirect=https://evil.example/login
//   every version: 302 Location: https://evil.example/login (open redirect)

Why this is vulnerable: Nothing decides where the user is sent, so $target becomes the header whatever it holds. The CRLF payload is the one this pattern is usually written up with and it is the weaker reading on PHP 8.5: $_GET has already decoded once, so a raw \r\n reaches header(), which warns and drops the header - the request answers 200 with no redirect, which is a denial of service on the endpoint rather than an injection. The reading that works on every version needs no control character: https://evil.example/login is a legal header value, so this line is an open redirect (CWE-601), a phishing link carrying the application's own domain. The CRLF reading is still live wherever the header is built somewhere other than header(). Deciding what redirect may be closes all of them.

<?php
// VULNERABLE - user-supplied value embedded in Set-Cookie
$preference = $_COOKIE['theme'] ?? $_GET['theme'];
header("Set-Cookie: theme=$preference; Path=/");

// theme=dark%0d%0aSet-Cookie:%20admin=1
//   PHP 8.5: warning, the whole Set-Cookie header is dropped
//
// theme=dark;%20Domain=example.com
//   PHP 8.5: Set-Cookie: theme=dark; Domain=example.com; Path=/
//   emitted verbatim, and needs no newline at all

Why this is vulnerable: Concatenating the cookie by hand throws away everything setcookie() does, and the CRLF is the least of it. HttpOnly, Secure, SameSite and an expiry are all absent, so the cookie is readable from JavaScript and sent over plaintext. And ; is the cookie grammar's own attribute separator, so a $preference of dark; Domain=example.com extends the attributes of the cookie being set - measured on PHP 8.5.8, that string is emitted verbatim, because there is no newline for header() to object to. The attribute has to be one the browser will honour: Domain=evil.example is the payload usually quoted and it is discarded, since RFC 6265 requires Domain to domain-match the responding host. Widening within the site's own registrable domain (Domain=example.com) is the version that works. $_COOKIE['theme'] also makes this a self-perpetuating loop: whatever was stored is read back and written out again.

Unvalidated Content-Disposition Filename

<?php
// VULNERABLE - filename from query string placed into Content-Disposition header
$filename = $_GET['filename'];
header("Content-Disposition: attachment; filename=\"$filename\"");
readfile(__DIR__ . '/downloads/report.pdf');

// filename=report.pdf%0d%0aContent-Type:%20text/html
//   PHP 8.5: warning, Content-Disposition dropped, file served inline
//
// filename=a.pdf%22;%20filename*=UTF-8%27%27evil.html
//   PHP 8.5: emitted verbatim; the quote closes the parameter and the
//   filename* that follows takes precedence, so the browser saves evil.html

Why this is vulnerable: The payload worth knowing about here contains no newline. A " closes the quoted filename parameter and a ; starts another, and RFC 6266 gives filename* precedence over filename - so the download is saved under a name the attacker chose while the header still reads as though it were serving a.pdf. Every byte in that string is legal in a header, so nothing in PHP, in a proxy, or in the browser objects to it. The CRLF variant is the weaker one on PHP 8.5: header() drops the whole Content-Disposition, and the file is then served inline with whatever content type applies rather than as an attachment - which is its own problem for an HTML or SVG payload. Constrain the filename to a character class rather than filtering characters out of it.

Secure Patterns

Allowlist-Based Redirect

<?php
declare(strict_types=1);

$allowedRedirects = [
    'home'      => '/home',
    'dashboard' => '/dashboard',
    'profile'   => '/profile',
];

$key = $_GET['redirect'] ?? 'home';
$target = $allowedRedirects[$key] ?? '/home';

// SECURE - target is always a hardcoded string from the allowlist
header("Location: $target");
http_response_code(302);
exit;

Why this works:

  • The user provides a key (home, dashboard), not the URL itself, so the destination is always one of three strings written in the source. A CRLF payload, an absolute https://evil.example and a protocol-relative //evil.example are all simply keys that are not in the array - there is no character to filter, and no header grammar to reason about.
  • Falling back to /home on an unknown key is substitution, not the repair this page warns against: it discards the input and uses a value the application defined, rather than deriving a third value from what the attacker sent. Where the endpoint has no sensible default, answer 400 instead so the attempt is visible.

Validate the Filename Against What the Header Allows

<?php
declare(strict_types=1);

// What a filename may be inside a quoted Content-Disposition parameter.
// \A and \z, not ^ and $: in PCRE, $ also matches before a trailing newline.
const SAFE_FILENAME = '/\A[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}\z/';

$filename = $_GET['filename'] ?? 'report.pdf';

// SECURE - reject rather than repair
if (!preg_match(SAFE_FILENAME, $filename)) {
    http_response_code(400);
    exit;
}

header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
readfile(__DIR__ . '/downloads/report.pdf');

Why this works:

  • The allowlist is defined by what is legal inside a quoted parameter rather than by a list of characters to remove, so ", ; and \ are excluded along with CR and LF - and so is /, which is what basename() was doing. Nothing has to be enumerated for it to hold, which is what makes an allowlist survive a grammar the author has not read.
  • \A and \z are load-bearing. PCRE's $ also matches immediately before a trailing newline, so /^[A-Za-z0-9 ._-]+$/ accepts report.pdf\n - measured on PHP 8.5.8, where preg_match('#^/[a-zA-Z0-9/_-]*$#', "/home\n") returns 1 and both #...$#D and #\A...\z# return 0. The D modifier is the other correct spelling. This is the reverse of Python, where \z does not exist below 3.14 and re.fullmatch() is the right answer instead.
  • Rejecting with 400 is the point of validating rather than leaving it to header(). Left to header(), a CRLF drops the Content-Disposition silently and the file is served inline; here the request that carried the payload is a rejection with a status code.
  • Length is bounded in the same expression. A header value has no length limit in PHP, and proxies disagree about where they stop accepting one.
<?php
declare(strict_types=1);

const ALLOWED_THEMES = ['light', 'dark', 'system'];

$theme = $_GET['theme'] ?? 'light';

// SECURE - the value is enumerated, so membership is the whole check
if (!in_array($theme, ALLOWED_THEMES, true)) {
    http_response_code(400);
    exit;
}

setcookie('theme', $theme, [
    'expires'  => time() + 86400,
    'path'     => '/',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Strict',
]);

Why this works:

  • theme is an enumerated value, so the set of legal inputs is three strings and in_array(..., true) settles it. Where a value is enumerated, membership beats any amount of character filtering, because nothing about encoding or cookie grammar has to be reasoned about at all. The strict true matters: without it PHP's loose comparison brings its own surprises to a security check.
  • The options array form of setcookie() is what gets SameSite, Secure and HttpOnly onto the cookie, which hand-built header('Set-Cookie: ...') strings routinely omit. setcookie() also URL-encodes the value, so a ; in it cannot start an attribute.
  • Rejecting rather than filtering is the difference between a 400 in the log and a cookie quietly storing a value nobody chose. The older form of this pattern used preg_replace('/[^\w\-]/', '', $raw), which turns dark; Domain=example.com into darkDomainevilexample and stores it as though the user had asked for it.

Testing

Re-running the scanner is not verification here. On PHP 8.5 a CRLF makes the header disappear rather than appear, so a scanner asking "did an extra header show up" reports success against a fixed endpoint and an unfixed one alike - and on the unfixed one the endpoint is now silently broken. Assert on the status code and on the header being present.

  • The accept, first. Request every redirect key, filename and cookie value the application is supposed to allow, and assert the header is there and correct: 302 with the expected Location, 200 with Content-Disposition: attachment; filename="q3 report 2026.pdf", a Set-Cookie carrying Secure, HttpOnly and SameSite. An allowlist tight enough to exclude CRLF is also tight enough to exclude a space or a hyphen somebody forgot to permit.
  • A rejected value is a 400 with no header: send filename=report.pdf%0d%0aContent-Type:%20text/html and assert 400. The failure to look for is 200 with no Content-Disposition at all - that is header() dropping it, which means the validation did not run.
  • A trailing newline on its own: filename=report.pdf%0a. Assert 400. This is the input that a ^...$-anchored PCRE pattern accepts and \A...\z or the D modifier rejects.
  • The payloads that carry no newline: filename=a.pdf%22;%20filename*=UTF-8%27%27evil.html and theme=dark;%20Domain=example.com. Assert 400. Neither involves a control character, so nothing in PHP will object to them for you.

Common Pitfalls

  • Reading header()'s behaviour as a rejection: it is not one. Measured on PHP 8.5.8, a CRLF in the value produces a warning and the header is discarded entirely - the script keeps running, so header("Location: $t"); exit; becomes an exit with no redirect and a 200. The injection is closed; what replaces it is an endpoint an attacker can silently disable on every request. The check also fires only at the header() call itself, so a framework response object, a custom emitter, a logging layer, or a queue message later turned into headers does not have it.
  • Filtering %0d/%0a out of an already-decoded value: $_GET and $_COOKIE have decoded once already, so a percent sequence still present in the value is literal text - removing it rewrites q3%0d-report.pdf into q3-report.pdf and stops nothing. The case that is real is a second decode downstream: a proxy or another framework layer calling urldecode() again turns %250d%250a into %0d%0a and then into a raw CRLF, after your check has run. That decode is the sink to fix, not this one.
  • Sanitising in application code while a header_register_callback hook rewrites headers afterwards: A callback registered in php.ini or a bootstrap file runs after your code has finished and can reintroduce attacker-controlled text into a header you had already cleaned. Check for one before concluding the output path is safe.

Additional Resources