diff --git a/CHANGELOG.md b/CHANGELOG.md index 740b2877..bfbd1938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## 2.1.1 - 2022-03-20 + +### Fixed + +- Validate header values properly + ## 2.1.0 - 2021-10-06 ### Changed diff --git a/composer.json b/composer.json index 885aa1da..b372582b 100644 --- a/composer.json +++ b/composer.json @@ -84,6 +84,9 @@ }, "config": { "preferred-install": "dist", - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "bamarni/composer-bin-plugin": true + } } } diff --git a/src/MessageTrait.php b/src/MessageTrait.php index 503c280b..809502c1 100644 --- a/src/MessageTrait.php +++ b/src/MessageTrait.php @@ -171,14 +171,14 @@ private function setHeaders(array $headers): void private function normalizeHeaderValue($value): array { 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); } /** @@ -195,7 +195,7 @@ private function normalizeHeaderValue($value): array * * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 */ - private function trimHeaderValues(array $values): array + private function trimAndValidateHeaderValues(array $values): array { return array_map(function ($value) { if (!is_scalar($value) && null !== $value) { @@ -205,7 +205,10 @@ private function trimHeaderValues(array $values): array )); } - return trim((string) $value, " \t"); + $trimmed = trim((string) $value, " \t"); + $this->assertValue($trimmed); + + return $trimmed; }, array_values($values)); } @@ -232,4 +235,32 @@ private function assertHeader($header): 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(string $value): void + { + // 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 9b124ccd..f8c04bf4 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -293,4 +293,53 @@ public function testAddsPortToHeaderAndReplacePreviousPort(): void $r = $r->withUri(new Uri('http://foo.com:8125/bar')); self::assertSame('foo.com:8125', $r->getHeaderLine('host')); } + + /** + * @dataProvider provideHeaderValuesContainingNotAllowedChars + */ + public function testContainsNotAllowedCharsOnHeaderValue(string $value): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('"%s" is not valid header value', $value)); + + $r = new Request( + 'GET', + 'http://foo.com/baz?bar=bam', + [ + 'testing' => $value + ] + ); + } + + public function provideHeaderValuesContainingNotAllowedChars(): iterable + { + // 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; + } }