From: Graham Campbell <GrahamCampbell@users.noreply.github.com>
Date: Sun, 20 Mar 2022 13:44:44 +0000
Subject: Release 1.8.4 (#486)
MIME-Version: 1.0
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 8bit

Co-authored-by: Tim Düsterhus <tim@bastelstu.be>

Origin: backport, https://github.com/guzzle/psr7/commit/902db15a551a4a415e732b622282e21ce1b508b4
---
 src/MessageTrait.php  | 66 +++++++++++++++++++++++++++++++++++++++++++++++----
 tests/RequestTest.php | 50 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 111 insertions(+), 5 deletions(-)

diff --git a/src/MessageTrait.php b/src/MessageTrait.php
index 99203bb..459b104 100644
--- a/src/MessageTrait.php
+++ b/src/MessageTrait.php
@@ -157,17 +157,22 @@ trait MessageTrait
         }
     }
 
+    /**
+     * @param mixed $value
+     *
+     * @return string[]
+     */
     private function normalizeHeaderValue($value)
     {
         if (!is_array($value)) {
-            return $this->trimHeaderValues([$value]);
+            return $this->trimAndValidateHeaderValues([$value]);
         }
 
         if (count($value) === 0) {
             throw new \InvalidArgumentException('Header value can not be an empty array.');
         }
 
-        return $this->trimHeaderValues($value);
+        return $this->trimAndValidateHeaderValues($value);
     }
 
     /**
@@ -178,13 +183,13 @@ trait MessageTrait
      * header-field = field-name ":" OWS field-value OWS
      * OWS          = *( SP / HTAB )
      *
-     * @param string[] $values Header values
+     * @param mixed[] $values Header values
      *
      * @return string[] Trimmed header values
      *
      * @see https://tools.ietf.org/html/rfc7230#section-3.2.4
      */
-    private function trimHeaderValues(array $values)
+    private function trimAndValidateHeaderValues(array $values)
     {
         return array_map(function ($value) {
             if (!is_scalar($value) && null !== $value) {
@@ -194,10 +199,20 @@ trait MessageTrait
                 ));
             }
 
-            return trim((string) $value, " \t");
+            $trimmed = trim((string) $value, " \t");
+            $this->assertValue($trimmed);
+
+            return $trimmed;
         }, array_values($values));
     }
 
+    /**
+     * @see https://tools.ietf.org/html/rfc7230#section-3.2
+     *
+     * @param mixed $header
+     *
+     * @return void
+     */
     private function assertHeader($header)
     {
         if (!is_string($header)) {
@@ -210,5 +225,46 @@ trait MessageTrait
         if ($header === '') {
             throw new \InvalidArgumentException('Header name can not be empty.');
         }
+
+        if (! preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/', $header)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    '"%s" is not valid header name',
+                    $header
+                )
+            );
+        }
+    }
+
+    /**
+     * @param string $value
+     *
+     * @return void
+     *
+     * @see https://tools.ietf.org/html/rfc7230#section-3.2
+     *
+     * field-value    = *( field-content / obs-fold )
+     * field-content  = field-vchar [ 1*( SP / HTAB ) field-vchar ]
+     * field-vchar    = VCHAR / obs-text
+     * VCHAR          = %x21-7E
+     * obs-text       = %x80-FF
+     * obs-fold       = CRLF 1*( SP / HTAB )
+     */
+    private function assertValue($value)
+    {
+        // The regular expression intentionally does not support the obs-fold production, because as
+        // per RFC 7230#3.2.4:
+        //
+        // A sender MUST NOT generate a message that includes
+        // line folding (i.e., that has any field-value that contains a match to
+        // the obs-fold rule) unless the message is intended for packaging
+        // within the message/http media type.
+        //
+        // Clients must not send a request with line folding and a server sending folded headers is
+        // likely very rare. Line folding is a fairly obscure feature of HTTP/1.1 and thus not accepting
+        // folding is not likely to break any legitimate use case.
+        if (! preg_match('/^(?:[\x21-\x7E\x80-\xFF](?:[\x20\x09]+[\x21-\x7E\x80-\xFF])?)*$/', $value)) {
+            throw new \InvalidArgumentException(sprintf('"%s" is not valid header value', $value));
+        }
     }
 }
diff --git a/tests/RequestTest.php b/tests/RequestTest.php
index cae5933..e2b9e69 100644
--- a/tests/RequestTest.php
+++ b/tests/RequestTest.php
@@ -229,4 +229,54 @@ class RequestTest extends BaseTest
         $r = $r->withUri(new Uri('http://foo.com:8125/bar'));
         $this->assertSame('foo.com:8125', $r->getHeaderLine('host'));
     }
+
+    /**
+     * @dataProvider provideHeaderValuesContainingNotAllowedChars
+     */
+    public function testContainsNotAllowedCharsOnHeaderValue($value)
+    {
+        $this->expectExceptionGuzzle('InvalidArgumentException', sprintf('"%s" is not valid header value', $value));
+        $r = new Request(
+            'GET',
+            'http://foo.com/baz?bar=bam',
+            [
+                'testing' => $value
+            ]
+        );
+    }
+
+    /**
+     * @return iterable
+     */
+    public function provideHeaderValuesContainingNotAllowedChars()
+    {
+        // Explicit tests for newlines as the most common exploit vector.
+        $tests = [
+            ["new\nline"],
+            ["new\r\nline"],
+            ["new\rline"],
+            // Line folding is technically allowed, but deprecated.
+            // We don't support it.
+            ["new\r\n line"],
+        ];
+
+        for ($i = 0; $i <= 0xff; $i++) {
+            if (\chr($i) == "\t") {
+                continue;
+            }
+            if (\chr($i) == " ") {
+                continue;
+            }
+            if ($i >= 0x21 && $i <= 0x7e) {
+                continue;
+            }
+            if ($i >= 0x80) {
+                continue;
+            }
+
+            $tests[] = ["foo" . \chr($i) . "bar"];
+        }
+
+        return $tests;
+    }
 }
