VYPR
Medium severity5.3GHSA Advisory· Published Jun 11, 2026· Updated Jun 11, 2026

CVE-2026-49214

CVE-2026-49214

Description

CRLF injection in guzzlehttp/psr7 prior to 2.10.2 allows attackers to inject arbitrary HTTP headers via malformed host components in user-supplied URLs.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

CRLF injection in guzzlehttp/psr7 prior to 2.10.2 allows attackers to inject arbitrary HTTP headers via malformed host components in user-supplied URLs.

Vulnerability

In guzzlehttp/psr7 versions prior to 2.10.2, the library does not reject ASCII control characters (including CRLF), whitespace, or DEL in first-party URI host components. This affects PSR-7 Uri, Request, and other objects that store host values. The 1.x branch is end-of-life and will not receive a patch [1][2][3].

Exploitation

An attacker must supply a user-controlled URL containing CRLF or other header-unsafe characters in the host component. The application then uses this URL to construct a PSR-7 Uri or Request, and subsequently serializes the request (e.g., via Message::toString()) without setting an explicit Host header. During serialization, the malformed host is copied into the Host header, allowing the attacker to inject additional HTTP header lines. For example, a host of "example.com\r\nX-Injected: yes" results in a Host header spanning multiple lines [2][3].

Impact

Successful exploitation enables an attacker to inject arbitrary HTTP headers into a serialized request. This can lead to request smuggling, cache poisoning, or other header-based attacks depending on how downstream components (proxies, load balancers, etc.) parse the malformed request. The attacker gains the ability to influence the behavior of intermediate infrastructure, potentially compromising confidentiality and integrity [2][3].

Mitigation

The vulnerability is patched in version 2.10.2 and later; all users should upgrade immediately. For 1.x branches, no fix will be provided as they are end-of-life. As a workaround, validate and reject any untrusted URI strings that contain ASCII control characters, whitespace, or DEL before constructing PSR-7 objects. Ensure that HTTP clients or serializers independently reject invalid host data before writing requests to the network [2][3].

AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

3
c68fe44ea6b5

Reject malformed Host authorities (#717)

https://github.com/guzzle/psr7Graham CampbellMay 25, 2026Fixed in 2.10.2via ghsa-release-walk
8 files changed · +289 30
  • CHANGELOG.md+1 0 modified
    @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     ### Security
     
     - Reject control and whitespace characters in URI host components (GHSA-hq7v-mx3g-29hw)
    +- Reject malformed Host values when constructing request URIs (GHSA-34xg-wgjx-8xph)
     
     ### Fixed
     
    
  • src/Message.php+22 4 modified
    @@ -233,6 +233,23 @@ public static function parseMessage(string $message): array
          * @param array  $headers Array of headers (each value an array).
          */
         public static function parseRequestUri(string $path, array $headers): string
    +    {
    +        $host = self::getHostFromHeaders($headers);
    +
    +        // If no host is found, then a full URI cannot be constructed.
    +        if ($host === null) {
    +            return $path;
    +        }
    +
    +        $scheme = substr($host, -4) === ':443' ? 'https' : 'http';
    +
    +        return $scheme.'://'.$host.'/'.ltrim($path, '/');
    +    }
    +
    +    /**
    +     * @param array $headers Array of headers (each value an array).
    +     */
    +    private static function getHostFromHeaders(array $headers): ?string
         {
             $hostKey = array_filter(array_keys($headers), function ($k) {
                 // Numeric array keys are converted to int by PHP.
    @@ -241,15 +258,16 @@ public static function parseRequestUri(string $path, array $headers): string
                 return strtolower($k) === 'host';
             });
     
    -        // If no host is found, then a full URI cannot be constructed.
             if (!$hostKey) {
    -            return $path;
    +            return null;
             }
     
             $host = $headers[reset($hostKey)][0];
    -        $scheme = substr($host, -4) === ':443' ? 'https' : 'http';
    +        if (!is_string($host) || Rfc7230::parseHostHeader($host) === null) {
    +            throw new \InvalidArgumentException('Invalid request string');
    +        }
     
    -        return $scheme.'://'.$host.'/'.ltrim($path, '/');
    +        return $host;
         }
     
         /**
    
  • src/Rfc3986.php+25 0 added
    @@ -0,0 +1,25 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +namespace GuzzleHttp\Psr7;
    +
    +/**
    + * @internal
    + */
    +final class Rfc3986
    +{
    +    /**
    +     * Sub-delims for use in a regex.
    +     *
    +     * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
    +     */
    +    public const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
    +
    +    /**
    +     * Unreserved characters for use in a regex.
    +     *
    +     * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
    +     */
    +    public const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
    +}
    
  • src/Rfc7230.php+83 0 modified
    @@ -20,4 +20,87 @@ final class Rfc7230
          */
         public const HEADER_REGEX = "(^([^()<>@,;:\\\"/[\]?={}\x01-\x20\x7F]++):[ \t]*+((?:[ \t]*+[\x21-\x7E\x80-\xFF]++)*+)[ \t]*+\r?\n)m";
         public const HEADER_FOLD_REGEX = "(\r?\n[ \t]++)";
    +
    +    /**
    +     * @return array{0: string, 1: int|null}|null
    +     */
    +    public static function parseHostHeader(string $authority): ?array
    +    {
    +        if ($authority === '') {
    +            return null;
    +        }
    +
    +        $host = $authority;
    +        $port = null;
    +
    +        if ($authority[0] === '[') {
    +            $closingBracket = strpos($authority, ']');
    +            if ($closingBracket === false) {
    +                return null;
    +            }
    +
    +            $host = substr($authority, 0, $closingBracket + 1);
    +            $remainder = substr($authority, $closingBracket + 1);
    +            if ($remainder !== '') {
    +                if ($remainder[0] !== ':') {
    +                    return null;
    +                }
    +
    +                $port = self::parseAuthorityPort(substr($remainder, 1));
    +                if ($port === null) {
    +                    return null;
    +                }
    +            }
    +        } elseif (false !== ($colon = strpos($authority, ':'))) {
    +            $host = substr($authority, 0, $colon);
    +            $port = self::parseAuthorityPort(substr($authority, $colon + 1));
    +            if ($port === null) {
    +                return null;
    +            }
    +        }
    +
    +        if ($host === '' || !self::isValidHostHeaderHost($host)) {
    +            return null;
    +        }
    +
    +        return [$host, $port];
    +    }
    +
    +    private static function isValidHostHeaderHost(string $host): bool
    +    {
    +        if (preg_match('/[\x00-\x20\x7F\/\?#@\\\\]/', $host)) {
    +            return false;
    +        }
    +
    +        if (strpos($host, '[') !== false || strpos($host, ']') !== false) {
    +            if ($host[0] !== '[' || substr($host, -1) !== ']') {
    +                return false;
    +            }
    +
    +            $address = substr($host, 1, -1);
    +
    +            return filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) !== false
    +                || preg_match('/^v[0-9a-f]+\.['.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.':]+$/iD', $address) === 1;
    +        }
    +
    +        return strpos($host, ':') === false;
    +    }
    +
    +    private static function parseAuthorityPort(string $port): ?int
    +    {
    +        if ($port === '' || !ctype_digit($port)) {
    +            return null;
    +        }
    +
    +        $normalized = ltrim($port, '0');
    +        if ($normalized === '') {
    +            return 0;
    +        }
    +
    +        if (strlen($normalized) > 5 || (int) $normalized > 0xFFFF) {
    +            return null;
    +        }
    +
    +        return (int) $normalized;
    +    }
     }
    
  • src/ServerRequest.php+21 10 modified
    @@ -166,7 +166,7 @@ private static function normalizeNestedFileSpec(array $files = []): array
         public static function fromGlobals(): ServerRequestInterface
         {
             $method = self::getServerParam('REQUEST_METHOD') ?? 'GET';
    -        $headers = self::getAllHeaders();
    +        $headers = self::removeInvalidHostHeader(self::getAllHeaders());
             $uri = self::getUriFromGlobals();
             $body = new CachingStream(new LazyOpenStream('php://input', 'r+'));
             $serverProtocol = self::getServerParam('SERVER_PROTOCOL');
    @@ -213,20 +213,31 @@ private static function getServerParam(string $key): ?string
         }
     
         /**
    -     * @return array{0: string|null, 1: int|null}
    +     * @param array<array-key, string> $headers
    +     *
    +     * @return array<array-key, string>
          */
    -    private static function extractHostAndPortFromAuthority(string $authority): array
    +    private static function removeInvalidHostHeader(array $headers): array
         {
    -        $uri = 'http://'.$authority;
    -        $parts = parse_url($uri);
    -        if (!is_array($parts)) {
    -            return [null, null];
    +        foreach ($headers as $name => $value) {
    +            if (strtolower((string) $name) !== 'host') {
    +                continue;
    +            }
    +
    +            if (Rfc7230::parseHostHeader($value) === null) {
    +                unset($headers[$name]);
    +            }
             }
     
    -        $host = $parts['host'] ?? null;
    -        $port = $parts['port'] ?? null;
    +        return $headers;
    +    }
     
    -        return [$host, $port];
    +    /**
    +     * @return array{0: string|null, 1: int|null}
    +     */
    +    private static function extractHostAndPortFromAuthority(string $authority): array
    +    {
    +        return Rfc7230::parseHostHeader($authority) ?? [null, null];
         }
     
         /**
    
  • src/Uri.php+3 16 modified
    @@ -38,19 +38,6 @@ class Uri implements UriInterface, \JsonSerializable
             'ldap' => 389,
         ];
     
    -    /**
    -     * Unreserved characters for use in a regex.
    -     *
    -     * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
    -     */
    -    private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
    -
    -    /**
    -     * Sub-delims for use in a regex.
    -     *
    -     * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
    -     */
    -    private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
         private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26', '+' => '%2B'];
     
         /** @var string Uri scheme. */
    @@ -647,7 +634,7 @@ private function filterUserInfoComponent($component): string
             }
     
             return preg_replace_callback(
    -            '/(?:[^%'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/',
    +            '/(?:[^%'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/',
                 [$this, 'rawurlencodeMatchZero'],
                 $component
             );
    @@ -749,7 +736,7 @@ private function filterPath($path): string
             }
     
             return preg_replace_callback(
    -            '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/',
    +            '/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/',
                 [$this, 'rawurlencodeMatchZero'],
                 $path
             );
    @@ -769,7 +756,7 @@ private function filterQueryAndFragment($str): string
             }
     
             return preg_replace_callback(
    -            '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/',
    +            '/(?:[^'.Rfc3986::CHAR_UNRESERVED.Rfc3986::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/',
                 [$this, 'rawurlencodeMatchZero'],
                 $str
             );
    
  • tests/MessageTest.php+56 0 modified
    @@ -107,6 +107,62 @@ public function testParsesRequestMessagesWithUriWhenHostIsNotFirst(): void
             self::assertSame('http://foo.com/', (string) $request->getUri());
         }
     
    +    /**
    +     * @dataProvider invalidHostHeaderProvider
    +     */
    +    public function testParseRequestRejectsInvalidHostHeader(string $host): void
    +    {
    +        $this->expectException(\InvalidArgumentException::class);
    +
    +        Psr7\Message::parseRequest("GET / HTTP/1.1\r\nHost: {$host}\r\n\r\n");
    +    }
    +
    +    public static function invalidHostHeaderProvider(): iterable
    +    {
    +        yield 'userinfo delimiter' => ['trusted.example@evil.example'];
    +        yield 'path delimiter' => ['example.com/path'];
    +        yield 'query delimiter' => ['example.com?query'];
    +        yield 'fragment delimiter' => ['example.com#fragment'];
    +        yield 'backslash delimiter' => ['example.com\\evil'];
    +        yield 'space' => ['bad host'];
    +        yield 'tab' => ["bad\thost"];
    +        yield 'control character' => ['example'.chr(1).'com'];
    +        yield 'delete' => ['example'.chr(0x7F).'com'];
    +        yield 'multiple ports' => ['example.com:443:8443'];
    +        yield 'missing closing bracket' => ['[::1'];
    +        yield 'unexpected bracket suffix' => ['[::1]x'];
    +        yield 'invalid ip literal' => ['[bad]'];
    +        yield 'unexpected opening bracket' => ['foo[bar'];
    +        yield 'unexpected closing bracket' => ['foo]bar'];
    +    }
    +
    +    /**
    +     * @dataProvider validHostHeaderProvider
    +     */
    +    public function testParseRequestAcceptsValidHostHeader(string $host, string $expectedUri): void
    +    {
    +        $request = Psr7\Message::parseRequest("GET / HTTP/1.1\r\nHost: {$host}\r\n\r\n");
    +
    +        self::assertSame($host, $request->getHeaderLine('Host'));
    +        self::assertSame($expectedUri, (string) $request->getUri());
    +    }
    +
    +    public static function validHostHeaderProvider(): iterable
    +    {
    +        yield 'host' => ['foo.com', 'http://foo.com/'];
    +        yield 'https default port' => ['foo.com:443', 'https://foo.com/'];
    +        yield 'non-default port' => ['foo.com:8080', 'http://foo.com:8080/'];
    +        yield 'ipv6' => ['[::1]', 'http://[::1]/'];
    +        yield 'ipv6 port' => ['[::1]:443', 'https://[::1]/'];
    +    }
    +
    +    public function testParseRequestAcceptsMissingHostHeader(): void
    +    {
    +        $request = Psr7\Message::parseRequest("GET /abc HTTP/1.1\r\nFoo: bar\r\n\r\n");
    +
    +        self::assertSame('/abc', (string) $request->getUri());
    +    }
    +
         public function testParsesRequestMessagesWithFullUri(): void
         {
             $req = "GET https://www.google.com:443/search?q=foobar HTTP/1.1\r\nHost: www.google.com\r\n\r\n";
    
  • tests/ServerRequestTest.php+78 0 modified
    @@ -354,6 +354,46 @@ public static function dataGetUriFromGlobals(): iterable
                     'https://localhost/blog/article.php?id=10&user=foo',
                     array_merge($server, ['HTTP_HOST' => 'a:b']),
                 ],
    +            'Host header with userinfo delimiter' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'trusted.example@evil.example']),
    +            ],
    +            'Host header with path delimiter' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'example.com/path']),
    +            ],
    +            'Host header with query delimiter' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'example.com?x=1']),
    +            ],
    +            'Host header with fragment delimiter' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'example.com#frag']),
    +            ],
    +            'Host header with backslash delimiter' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'example.com\\evil']),
    +            ],
    +            'Host header with space' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'bad host']),
    +            ],
    +            'Host header with multiple ports' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'example.com:80:90']),
    +            ],
    +            'Host header with invalid ip literal' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => '[bad]']),
    +            ],
    +            'Host header with unexpected opening bracket' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'foo[bar']),
    +            ],
    +            'Host header with unexpected closing bracket' => [
    +                'https://localhost/blog/article.php?id=10&user=foo',
    +                array_merge($server, ['HTTP_HOST' => 'foo]bar']),
    +            ],
                 'Different port with SERVER_PORT' => [
                     'https://www.example.org:8324/blog/article.php?id=10&user=foo',
                     array_merge($server, ['SERVER_PORT' => '8324']),
    @@ -536,6 +576,44 @@ public function testFromGlobalsDefaultsNonStringMethodAndProtocol(): void
             self::assertSame('1.1', $server->getProtocolVersion());
         }
     
    +    /**
    +     * @dataProvider invalidHostHeaderFromGlobalsProvider
    +     */
    +    public function testFromGlobalsDropsInvalidHostHeaderWhenUriFallsBack(string $host): void
    +    {
    +        if (!\function_exists('getallheaders')) {
    +            self::markTestSkipped('getallheaders() is not available.');
    +        }
    +
    +        $_SERVER = [
    +            'REQUEST_URI' => '/',
    +            'HTTP_HOST' => $host,
    +            'SERVER_PORT' => '443',
    +            'HTTPS' => 'on',
    +        ];
    +
    +        $_COOKIE = $_POST = $_GET = $_FILES = [];
    +
    +        $request = ServerRequest::fromGlobals();
    +
    +        self::assertSame('localhost', $request->getUri()->getHost());
    +        self::assertSame('localhost', $request->getHeaderLine('Host'));
    +    }
    +
    +    public static function invalidHostHeaderFromGlobalsProvider(): iterable
    +    {
    +        yield 'userinfo delimiter' => ['trusted.example@evil.example'];
    +        yield 'path delimiter' => ['example.com/path'];
    +        yield 'query delimiter' => ['example.com?x=1'];
    +        yield 'fragment delimiter' => ['example.com#frag'];
    +        yield 'backslash delimiter' => ['example.com\\evil'];
    +        yield 'space' => ['bad host'];
    +        yield 'multiple ports' => ['example.com:80:90'];
    +        yield 'invalid ip literal' => ['[bad]'];
    +        yield 'unexpected opening bracket' => ['foo[bar'];
    +        yield 'unexpected closing bracket' => ['foo]bar'];
    +    }
    +
         public function testUploadedFiles(): void
         {
             $request1 = new ServerRequest('GET', '/');
    
12caca7f2302

Reject control characters in URI hosts (#715)

https://github.com/guzzle/psr7Graham CampbellMay 25, 2026Fixed in 2.10.2via ghsa-release-walk
5 files changed · +94 4
  • CHANGELOG.md+6 0 modified
    @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
     The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
     and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
     
    +## 2.10.2 - TBD
    +
    +### Security
    +
    +- Reject control and whitespace characters in URI host components (GHSA-hq7v-mx3g-29hw)
    +
     ## 2.10.1 - 2026-05-20
     
     ### Fixed
    
  • src/Request.php+4 0 modified
    @@ -132,10 +132,14 @@ private function updateHostFromUri(): void
                 return;
             }
     
    +        Uri::assertValidHost($host);
    +
             if (($port = $this->uri->getPort()) !== null) {
                 $host .= ':'.$port;
             }
     
    +        $this->assertValue($host);
    +
             if (isset($this->headerNames['host'])) {
                 $header = $this->headerNames['host'];
             } else {
    
  • src/Uri.php+35 4 modified
    @@ -81,7 +81,13 @@ public function __construct(string $uri = '')
                 if ($parts === false) {
                     throw new MalformedUriException("Unable to parse URI: $uri");
                 }
    -            $this->applyParts($parts);
    +            try {
    +                $this->applyParts($parts);
    +            } catch (MalformedUriException $e) {
    +                throw $e;
    +            } catch (\InvalidArgumentException $e) {
    +                throw new MalformedUriException($e->getMessage(), 0, $e);
    +            }
             }
         }
     
    @@ -390,12 +396,34 @@ public static function withQueryValues(UriInterface $uri, array $keyValueArray):
         public static function fromParts(array $parts): UriInterface
         {
             $uri = new self();
    -        $uri->applyParts($parts);
    -        $uri->validateState();
    +        try {
    +            $uri->applyParts($parts);
    +            $uri->validateState();
    +        } catch (MalformedUriException $e) {
    +            throw $e;
    +        } catch (\InvalidArgumentException $e) {
    +            throw new MalformedUriException($e->getMessage(), 0, $e);
    +        }
     
             return $uri;
         }
     
    +    /**
    +     * @throws \InvalidArgumentException If the host is invalid.
    +     *
    +     * @internal
    +     */
    +    public static function assertValidHost(string $host): void
    +    {
    +        if ($host === '') {
    +            return;
    +        }
    +
    +        if (preg_match('/[\x00-\x20\x7F]/', $host)) {
    +            throw new \InvalidArgumentException(sprintf('Invalid host: "%s"', $host));
    +        }
    +    }
    +
         public function getScheme(): string
         {
             return $this->scheme;
    @@ -636,7 +664,10 @@ private function filterHost($host): string
                 throw new \InvalidArgumentException('Host must be a string');
             }
     
    -        return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
    +        $host = \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
    +        self::assertValidHost($host);
    +
    +        return $host;
         }
     
         /**
    
  • tests/RequestTest.php+23 0 modified
    @@ -9,6 +9,7 @@
     use GuzzleHttp\Psr7\Uri;
     use PHPUnit\Framework\TestCase;
     use Psr\Http\Message\StreamInterface;
    +use Psr\Http\Message\UriInterface;
     
     /**
      * @covers \GuzzleHttp\Psr7\MessageTrait
    @@ -314,6 +315,28 @@ public function testAddsPortToHeaderAndReplacePreviousPort(): void
             self::assertSame('foo.com:8125', $r->getHeaderLine('host'));
         }
     
    +    public function testGeneratedHostHeaderRejectsInvalidUriHostFromCustomUri(): void
    +    {
    +        $uri = $this->createMock(UriInterface::class);
    +        $uri->method('getHost')->willReturn("foo\nbar");
    +        $uri->method('getPort')->willReturn(null);
    +
    +        $this->expectException(\InvalidArgumentException::class);
    +
    +        new Request('GET', $uri);
    +    }
    +
    +    public function testGeneratedHostHeaderValidatesAssembledHostWithPort(): void
    +    {
    +        $uri = $this->createMock(UriInterface::class);
    +        $uri->method('getHost')->willReturn('example.com');
    +        $uri->method('getPort')->willReturn(8080);
    +
    +        $request = new Request('GET', $uri);
    +
    +        self::assertSame('example.com:8080', $request->getHeaderLine('Host'));
    +    }
    +
         /**
          * @dataProvider provideHeaderValuesContainingNotAllowedChars
          */
    
  • tests/UriTest.php+26 0 modified
    @@ -214,6 +214,32 @@ public function testHostMustHaveCorrectType(): void
             (new Uri())->withHost([]);
         }
     
    +    /**
    +     * @dataProvider getInvalidHostsWithControlCharacters
    +     */
    +    public function testHostMustRejectControlCharacters(string $host): void
    +    {
    +        $this->expectException(\InvalidArgumentException::class);
    +
    +        (new Uri())->withHost($host);
    +    }
    +
    +    public static function getInvalidHostsWithControlCharacters(): iterable
    +    {
    +        for ($i = 0; $i <= 0x20; ++$i) {
    +            yield 'ascii 0x'.strtoupper(dechex($i)) => ['example'.chr($i).'com'];
    +        }
    +
    +        yield 'ascii 0x7F' => ['example'.chr(0x7F).'com'];
    +    }
    +
    +    public function testParseUriRejectsHostWithControlCharacter(): void
    +    {
    +        $this->expectException(MalformedUriException::class);
    +
    +        new Uri("http://example.com\r\nX-Injected:%20yes/");
    +    }
    +
         public function testPathMustHaveCorrectType(): void
         {
             $this->expectException(\InvalidArgumentException::class);
    
a0fda818b0f7

Normalize global header values (#718)

https://github.com/guzzle/psr7Graham CampbellMay 25, 2026Fixed in 2.10.2via ghsa-release-walk
3 files changed · +65 1
  • CHANGELOG.md+4 0 modified
    @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     - Reject control and whitespace characters in URI host components (GHSA-hq7v-mx3g-29hw)
     
    +### Fixed
    +
    +- Make `ServerRequest::fromGlobals()` robust against unexpected HTTP header value types in `$_SERVER`
    +
     ## 2.10.1 - 2026-05-20
     
     ### Fixed
    
  • src/ServerRequest.php+27 1 modified
    @@ -166,7 +166,7 @@ private static function normalizeNestedFileSpec(array $files = []): array
         public static function fromGlobals(): ServerRequestInterface
         {
             $method = self::getServerParam('REQUEST_METHOD') ?? 'GET';
    -        $headers = getallheaders();
    +        $headers = self::getAllHeaders();
             $uri = self::getUriFromGlobals();
             $body = new CachingStream(new LazyOpenStream('php://input', 'r+'));
             $serverProtocol = self::getServerParam('SERVER_PROTOCOL');
    @@ -181,6 +181,32 @@ public static function fromGlobals(): ServerRequestInterface
                 ->withUploadedFiles(self::normalizeFiles($_FILES));
         }
     
    +    /**
    +     * @return array<array-key, string>
    +     */
    +    private static function getAllHeaders(): array
    +    {
    +        return self::normalizeHeaderValues(getallheaders());
    +    }
    +
    +    /**
    +     * @param array<array-key, mixed> $headers
    +     *
    +     * @return array<array-key, string>
    +     */
    +    private static function normalizeHeaderValues(array $headers): array
    +    {
    +        $normalized = [];
    +
    +        foreach ($headers as $name => $value) {
    +            if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
    +                $normalized[$name] = (string) $value;
    +            }
    +        }
    +
    +        return $normalized;
    +    }
    +
         private static function getServerParam(string $key): ?string
         {
             return isset($_SERVER[$key]) && is_string($_SERVER[$key]) ? $_SERVER[$key] : null;
    
  • tests/ServerRequestTest.php+34 0 modified
    @@ -485,6 +485,40 @@ public function testFromGlobals(): void
             self::assertEquals($expectedFiles, $server->getUploadedFiles());
         }
     
    +    public function testFromGlobalsNormalizesUnexpectedHeaderValueTypes(): void
    +    {
    +        $_SERVER = [
    +            'REQUEST_URI' => '/',
    +            'HTTP_HOST' => 'www.example.org',
    +            'HTTP_X_INT' => 123,
    +            'HTTP_X_FLOAT' => 1.5,
    +            'HTTP_X_FALSE' => false,
    +            'HTTP_X_TRUE' => true,
    +            'HTTP_X_STRINGABLE' => new class {
    +                public function __toString(): string
    +                {
    +                    return 'stringable';
    +                }
    +            },
    +            'HTTP_X_ARRAY' => ['bad'],
    +            'HTTP_X_OBJECT' => new \stdClass(),
    +            'HTTP_123' => 'numeric header',
    +        ];
    +
    +        $_COOKIE = $_POST = $_GET = $_FILES = [];
    +
    +        $server = ServerRequest::fromGlobals();
    +
    +        self::assertSame('123', $server->getHeaderLine('X-Int'));
    +        self::assertSame('1.5', $server->getHeaderLine('X-Float'));
    +        self::assertSame([''], $server->getHeader('X-False'));
    +        self::assertSame('1', $server->getHeaderLine('X-True'));
    +        self::assertSame('stringable', $server->getHeaderLine('X-Stringable'));
    +        self::assertSame('numeric header', $server->getHeaderLine('123'));
    +        self::assertFalse($server->hasHeader('X-Array'));
    +        self::assertFalse($server->hasHeader('X-Object'));
    +    }
    +
         public function testFromGlobalsDefaultsNonStringMethodAndProtocol(): void
         {
             $_SERVER = [
    

Vulnerability mechanics

No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.

References

2

News mentions

0

No linked articles in our index yet.