feature #24699 [HttpFoundation] Add HeaderUtils class (c960657)

This PR was merged into the 4.1-dev branch.

Discussion
----------

[HttpFoundation] Add HeaderUtils class

| Q             | A
| ------------- | ---
| Branch?       | 3.4
| Bug fix?      | yes
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets |
| License       | MIT
| Doc PR        |

In several places in HttpFoundation we parse HTTP header values using a variety of regular expressions. Some of them fail in various corner cases.

Parsing HTTP headers is not entirely trivial. We must be able to parse quoted strings with backslash escaping properly and ignore white-space in certain places.

In practice, our limitations in this respect may not be a big problem. We only care about a few different HTTP request headers, and they are usually restricted to a simple values without quoted strings etc. However, this is no excuse for not doing it right :-)

This PR introduces a new utility class for parsing headers. This allows Symfony itself and third-party code to parse HTTP headers in a robust way without using complex regular expressions that are difficult to write and error prone.

Commits
-------

b435e80cae [HttpFoundation] Add HeaderUtility class
This commit is contained in:
Fabien Potencier 2018-04-22 08:29:56 +02:00
commit cbc2376803
15 changed files with 329 additions and 95 deletions

View File

@ -52,12 +52,17 @@ class AcceptHeader
{
$index = 0;
return new self(array_map(function ($itemValue) use (&$index) {
$item = AcceptHeaderItem::fromString($itemValue);
$parts = HeaderUtils::split((string) $headerValue, ',;=');
return new self(array_map(function ($subParts) use (&$index) {
$part = array_shift($subParts);
$attributes = HeaderUtils::combineParts($subParts);
$item = new AcceptHeaderItem($part[0], $attributes);
$item->setIndex($index++);
return $item;
}, preg_split('/\s*(?:,*("[^"]+"),*|,*(\'[^\']+\'),*|,+)\s*/', $headerValue, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE)));
}, $parts));
}
/**

View File

@ -40,24 +40,12 @@ class AcceptHeaderItem
*/
public static function fromString($itemValue)
{
$bits = preg_split('/\s*(?:;*("[^"]+");*|;*(\'[^\']+\');*|;+)\s*/', $itemValue, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
$value = array_shift($bits);
$attributes = array();
$parts = HeaderUtils::split($itemValue, ';=');
$lastNullAttribute = null;
foreach ($bits as $bit) {
if (($start = substr($bit, 0, 1)) === ($end = substr($bit, -1)) && ('"' === $start || '\'' === $start)) {
$attributes[$lastNullAttribute] = substr($bit, 1, -1);
} elseif ('=' === $end) {
$lastNullAttribute = $bit = substr($bit, 0, -1);
$attributes[$bit] = null;
} else {
$parts = explode('=', $bit);
$attributes[$parts[0]] = isset($parts[1]) && strlen($parts[1]) > 0 ? $parts[1] : '';
}
}
$part = array_shift($parts);
$attributes = HeaderUtils::combineParts($parts, 1);
return new self(($start = substr($value, 0, 1)) === ($end = substr($value, -1)) && ('"' === $start || '\'' === $start) ? substr($value, 1, -1) : $value, $attributes);
return new self($part[0], $attributes);
}
/**
@ -69,9 +57,7 @@ class AcceptHeaderItem
{
$string = $this->value.($this->quality < 1 ? ';q='.$this->quality : '');
if (count($this->attributes) > 0) {
$string .= ';'.implode(';', array_map(function ($name, $value) {
return sprintf(preg_match('/[,;=]/', $value) ? '%s="%s"' : '%s=%s', $name, $value);
}, array_keys($this->attributes), $this->attributes));
$string .= '; '.HeaderUtils::joinAssoc($this->attributes, ';');
}
return $string;

View File

@ -218,17 +218,12 @@ class BinaryFileResponse extends Response
if ('x-accel-redirect' === strtolower($type)) {
// Do X-Accel-Mapping substitutions.
// @link http://wiki.nginx.org/X-accel#X-Accel-Redirect
foreach (explode(',', $request->headers->get('X-Accel-Mapping', '')) as $mapping) {
$mapping = explode('=', $mapping, 2);
if (2 === count($mapping)) {
$pathPrefix = trim($mapping[0]);
$location = trim($mapping[1]);
if (substr($path, 0, strlen($pathPrefix)) === $pathPrefix) {
$path = $location.substr($path, strlen($pathPrefix));
break;
}
$parts = HeaderUtils::split($request->headers->get('X-Accel-Mapping', ''), ',=');
$mappings = HeaderUtils::combineParts($parts);
foreach ($mappings as $pathPrefix => $location) {
if (substr($path, 0, strlen($pathPrefix)) === $pathPrefix) {
$path = $location.substr($path, strlen($pathPrefix));
break;
}
}
}

View File

@ -16,6 +16,7 @@ CHANGELOG
`IniSizeFileException`, `NoFileException`, `NoTmpDirFileException`, `PartialFileException` to
handle failed `UploadedFile`.
* added `MigratingSessionHandler` for migrating between two session handlers without losing sessions
* added `HeaderUtils`.
4.0.0
-----

View File

@ -50,34 +50,20 @@ class Cookie
'raw' => !$decode,
'samesite' => null,
);
foreach (explode(';', $cookie) as $part) {
if (false === strpos($part, '=')) {
$key = trim($part);
$value = true;
} else {
list($key, $value) = explode('=', trim($part), 2);
$key = trim($key);
$value = trim($value);
}
if (!isset($data['name'])) {
$data['name'] = $decode ? urldecode($key) : $key;
$data['value'] = true === $value ? null : ($decode ? urldecode($value) : $value);
continue;
}
switch ($key = strtolower($key)) {
case 'name':
case 'value':
break;
case 'max-age':
$data['expires'] = time() + (int) $value;
break;
default:
$data[$key] = $value;
break;
}
$parts = HeaderUtils::split($cookie, ';=');
$part = array_shift($parts);
$name = $decode ? urldecode($part[0]) : $part[0];
$value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null;
$data = HeaderUtils::combineParts($parts) + $data;
if (isset($data['max-age'])) {
$data['expires'] = time() + (int) $data['max-age'];
}
return new static($data['name'], $data['value'], $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite']);
return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite']);
}
/**

View File

@ -294,21 +294,9 @@ class HeaderBag implements \IteratorAggregate, \Countable
protected function getCacheControlHeader()
{
$parts = array();
ksort($this->cacheControl);
foreach ($this->cacheControl as $key => $value) {
if (true === $value) {
$parts[] = $key;
} else {
if (preg_match('#[^a-zA-Z0-9._-]#', $value)) {
$value = '"'.$value.'"';
}
$parts[] = "$key=$value";
}
}
return implode(', ', $parts);
return HeaderUtils::joinAssoc($this->cacheControl, ',');
}
/**
@ -320,12 +308,8 @@ class HeaderBag implements \IteratorAggregate, \Countable
*/
protected function parseCacheControl($header)
{
$cacheControl = array();
preg_match_all('#([a-zA-Z][a-zA-Z_-]*)\s*(?:=(?:"([^"]*)"|([^ \t",;]*)))?#', $header, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$cacheControl[strtolower($match[1])] = isset($match[3]) ? $match[3] : (isset($match[2]) ? $match[2] : true);
}
$parts = HeaderUtils::split($header, ',=');
return $cacheControl;
return HeaderUtils::combineParts($parts);
}
}

View File

@ -0,0 +1,174 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation;
/**
* HTTP header utility functions.
*
* @author Christian Schmidt <github@chsc.dk>
*/
class HeaderUtils
{
/**
* This class should not be instantiated.
*/
private function __construct()
{
}
/**
* Splits an HTTP header by one or more separators.
*
* Example:
*
* HeaderUtils::split("da, en-gb;q=0.8", ",;")
* // => array(array("da"), array("en-gb"), array("q", "0.8"))
*
* @param string $header HTTP header value
* @param string $separators List of characters to split on, ordered by
* precedence, e.g. ",", ";=", or ",;="
*
* @return array Nested array with as many levels as there are characters in
* $separators
*/
public static function split(string $header, string $separators): array
{
$quotedSeparators = preg_quote($separators);
preg_match_all('
/
(?!\s)
(?:
# quoted-string
"(?:[^"\\\\]|\\\\.)*(?:"|\\\\|$)
|
# token
[^"'.$quotedSeparators.']+
)+
(?<!\s)
|
# separator
\s*
(?<separator>['.$quotedSeparators.'])
\s*
/x', trim($header), $matches, PREG_SET_ORDER);
return self::groupParts($matches, $separators);
}
/**
* Combines an array of arrays into one associative array.
*
* Each of the nested arrays should have one or two elements. The first
* value will be used as the keys in the associative array, and the second
* will be used as the values, or true if the nested array only contains one
* element.
*
* Example:
*
* HeaderUtils::combineParts(array(array("foo", "abc"), array("bar")))
* // => array("foo" => "abc", "bar" => true)
*/
public static function combineParts(array $parts): array
{
$assoc = array();
foreach ($parts as $part) {
$name = strtolower($part[0]);
$value = $part[1] ?? true;
$assoc[$name] = $value;
}
return $assoc;
}
/**
* Joins an associative array into a string for use in an HTTP header.
*
* The key and value of each entry are joined with "=", and all entries
* is joined with the specified separator and an additional space (for
* readability). Values are quoted if necessary.
*
* Example:
*
* HeaderUtils::joinAssoc(array("foo" => "abc", "bar" => true, "baz" => "a b c"), ",")
* // => 'foo=bar, baz, baz="a b c"'
*/
public static function joinAssoc(array $assoc, string $separator): string
{
$parts = array();
foreach ($assoc as $name => $value) {
if (true === $value) {
$parts[] = $name;
} else {
$parts[] = $name.'='.self::quote($value);
}
}
return implode($separator.' ', $parts);
}
/**
* Encodes a string as a quoted string, if necessary.
*
* If a string contains characters not allowed by the "token" construct in
* the HTTP specification, it is backslash-escaped and enclosed in quotes
* to match the "quoted-string" construct.
*/
public static function quote(string $s): string
{
if (preg_match('/^[a-z0-9!#$%&\'*.^_`|~-]+$/i', $s)) {
return $s;
}
return '"'.addcslashes($s, '"\\"').'"';
}
/**
* Decodes a quoted string.
*
* If passed an unquoted string that matches the "token" construct (as
* defined in the HTTP specification), it is passed through verbatimly.
*/
public static function unquote(string $s): string
{
return preg_replace('/\\\\(.)|"/', '$1', $s);
}
private static function groupParts(array $matches, string $separators): array
{
$separator = $separators[0];
$partSeparators = substr($separators, 1);
$i = 0;
$partMatches = array();
foreach ($matches as $match) {
if (isset($match['separator']) && $match['separator'] === $separator) {
++$i;
} else {
$partMatches[$i][] = $match;
}
}
$parts = array();
if ($partSeparators) {
foreach ($partMatches as $matches) {
$parts[] = self::groupParts($matches, $partSeparators);
}
} else {
foreach ($partMatches as $matches) {
$parts[] = self::unquote($matches[0][0]);
}
}
return $parts;
}
}

View File

@ -1944,8 +1944,16 @@ class Request
}
if ((self::$trustedHeaderSet & self::HEADER_FORWARDED) && $this->headers->has(self::$trustedHeaders[self::HEADER_FORWARDED])) {
$forwardedValues = $this->headers->get(self::$trustedHeaders[self::HEADER_FORWARDED]);
$forwardedValues = preg_match_all(sprintf('{(?:%s)=(?:"?\[?)([a-zA-Z0-9\.:_\-/]*+)}', self::$forwardedParams[$type]), $forwardedValues, $matches) ? $matches[1] : array();
$forwarded = $this->headers->get(self::$trustedHeaders[self::HEADER_FORWARDED]);
$parts = HeaderUtils::split($forwarded, ',;=');
$forwardedValues = array();
$param = self::$forwardedParams[$type];
foreach ($parts as $subParts) {
$assoc = HeaderUtils::combineParts($subParts);
if (isset($assoc[$param])) {
$forwardedValues[] = $assoc[$param];
}
}
}
if (null !== $ip) {
@ -1978,9 +1986,17 @@ class Request
$firstTrustedIp = null;
foreach ($clientIps as $key => $clientIp) {
// Remove port (unfortunately, it does happen)
if (preg_match('{((?:\d+\.){3}\d+)\:\d+}', $clientIp, $match)) {
$clientIps[$key] = $clientIp = $match[1];
if (strpos($clientIp, '.')) {
// Strip :port from IPv4 addresses. This is allowed in Forwarded
// and may occur in X-Forwarded-For.
$i = strpos($clientIp, ':');
if ($i) {
$clientIps[$key] = $clientIp = substr($clientIp, 0, $i);
}
} elseif ('[' == $clientIp[0]) {
// Strip brackets and :port from IPv6 addresses.
$i = strpos($clientIp, ']', 1);
$clientIps[$key] = $clientIp = substr($clientIp, 1, $i - 1);
}
if (!filter_var($clientIp, FILTER_VALIDATE_IP)) {

View File

@ -290,13 +290,12 @@ class ResponseHeaderBag extends HeaderBag
throw new \InvalidArgumentException('The filename and the fallback cannot contain the "/" and "\\" characters.');
}
$output = sprintf('%s; filename="%s"', $disposition, str_replace('"', '\\"', $filenameFallback));
$params = array('filename' => $filenameFallback);
if ($filename !== $filenameFallback) {
$output .= sprintf("; filename*=utf-8''%s", rawurlencode($filename));
$params['filename*'] = "utf-8''".rawurlencode($filename);
}
return $output;
return $disposition.'; '.HeaderUtils::joinAssoc($params, ';');
}
/**

View File

@ -66,7 +66,7 @@ class AcceptHeaderItemTest extends TestCase
),
array(
'text/plain', array('charset' => 'utf-8', 'param' => 'this;should,not=matter', 'footnotes' => 'true'),
'text/plain;charset=utf-8;param="this;should,not=matter";footnotes=true',
'text/plain; charset=utf-8; param="this;should,not=matter"; footnotes=true',
),
);
}

View File

@ -32,7 +32,7 @@ class BinaryFileResponseTest extends ResponseTestCase
$response = BinaryFileResponse::create($file, 404, array(), true, ResponseHeaderBag::DISPOSITION_INLINE);
$this->assertEquals(404, $response->getStatusCode());
$this->assertFalse($response->headers->has('ETag'));
$this->assertEquals('inline; filename="README.md"', $response->headers->get('Content-Disposition'));
$this->assertEquals('inline; filename=README.md', $response->headers->get('Content-Disposition'));
}
public function testConstructWithNonAsciiFilename()
@ -66,7 +66,7 @@ class BinaryFileResponseTest extends ResponseTestCase
$response = new BinaryFileResponse(__FILE__);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'föö.html');
$this->assertSame('attachment; filename="f__.html"; filename*=utf-8\'\'f%C3%B6%C3%B6.html', $response->headers->get('Content-Disposition'));
$this->assertSame('attachment; filename=f__.html; filename*=utf-8\'\'f%C3%B6%C3%B6.html', $response->headers->get('Content-Disposition'));
}
public function testSetContentDispositionGeneratesSafeFallbackFilenameForWronglyEncodedFilename()
@ -77,7 +77,7 @@ class BinaryFileResponseTest extends ResponseTestCase
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $iso88591EncodedFilename);
// the parameter filename* is invalid in this case (rawurldecode('f%F6%F6') does not provide a UTF-8 string but an ISO-8859-1 encoded one)
$this->assertSame('attachment; filename="f__.html"; filename*=utf-8\'\'f%F6%F6.html', $response->headers->get('Content-Disposition'));
$this->assertSame('attachment; filename=f__.html; filename*=utf-8\'\'f%F6%F6.html', $response->headers->get('Content-Disposition'));
}
/**

View File

@ -201,6 +201,9 @@ class CookieTest extends TestCase
$cookie = Cookie::fromString('foo=bar', true);
$this->assertEquals(new Cookie('foo', 'bar', 0, '/', null, false, false), $cookie);
$cookie = Cookie::fromString('foo', true);
$this->assertEquals(new Cookie('foo', null, 0, '/', null, false, false), $cookie);
}
public function testFromStringWithHttpOnly()

View File

@ -0,0 +1,85 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\HeaderUtils;
class HeaderUtilsTest extends TestCase
{
public function testSplit()
{
$this->assertSame(array('foo=123', 'bar'), HeaderUtils::split('foo=123,bar', ','));
$this->assertSame(array('foo=123', 'bar'), HeaderUtils::split('foo=123, bar', ','));
$this->assertSame(array(array('foo=123', 'bar')), HeaderUtils::split('foo=123; bar', ',;'));
$this->assertSame(array(array('foo=123'), array('bar')), HeaderUtils::split('foo=123, bar', ',;'));
$this->assertSame(array('foo', '123, bar'), HeaderUtils::split('foo=123, bar', '='));
$this->assertSame(array('foo', '123, bar'), HeaderUtils::split(' foo = 123, bar ', '='));
$this->assertSame(array(array('foo', '123'), array('bar')), HeaderUtils::split('foo=123, bar', ',='));
$this->assertSame(array(array(array('foo', '123')), array(array('bar'), array('foo', '456'))), HeaderUtils::split('foo=123, bar; foo=456', ',;='));
$this->assertSame(array(array(array('foo', 'a,b;c=d'))), HeaderUtils::split('foo="a,b;c=d"', ',;='));
$this->assertSame(array('foo', 'bar'), HeaderUtils::split('foo,,,, bar', ','));
$this->assertSame(array('foo', 'bar'), HeaderUtils::split(',foo, bar,', ','));
$this->assertSame(array('foo', 'bar'), HeaderUtils::split(' , foo, bar, ', ','));
$this->assertSame(array('foo bar'), HeaderUtils::split('foo "bar"', ','));
$this->assertSame(array('foo bar'), HeaderUtils::split('"foo" bar', ','));
$this->assertSame(array('foo bar'), HeaderUtils::split('"foo" "bar"', ','));
// These are not a valid header values. We test that they parse anyway,
// and that both the valid and invalid parts are returned.
$this->assertSame(array(), HeaderUtils::split('', ','));
$this->assertSame(array(), HeaderUtils::split(',,,', ','));
$this->assertSame(array('foo', 'bar', 'baz'), HeaderUtils::split('foo, "bar", "baz', ','));
$this->assertSame(array('foo', 'bar, baz'), HeaderUtils::split('foo, "bar, baz', ','));
$this->assertSame(array('foo', 'bar, baz\\'), HeaderUtils::split('foo, "bar, baz\\', ','));
$this->assertSame(array('foo', 'bar, baz\\'), HeaderUtils::split('foo, "bar, baz\\\\', ','));
}
public function testCombineAssoc()
{
$this->assertSame(array('foo' => '123'), HeaderUtils::combineParts(array(array('foo', '123'))));
$this->assertSame(array('foo' => true), HeaderUtils::combineParts(array(array('foo'))));
$this->assertSame(array('foo' => true), HeaderUtils::combineParts(array(array('Foo'))));
$this->assertSame(array('foo' => '123', 'bar' => true), HeaderUtils::combineParts(array(array('foo', '123'), array('bar'))));
}
public function testJoinAssoc()
{
$this->assertSame('foo', HeaderUtils::joinAssoc(array('foo' => true), ','));
$this->assertSame('foo; bar', HeaderUtils::joinAssoc(array('foo' => true, 'bar' => true), ';'));
$this->assertSame('foo=123', HeaderUtils::joinAssoc(array('foo' => '123'), ','));
$this->assertSame('foo="1 2 3"', HeaderUtils::joinAssoc(array('foo' => '1 2 3'), ','));
$this->assertSame('foo="1 2 3", bar', HeaderUtils::joinAssoc(array('foo' => '1 2 3', 'bar' => true), ','));
}
public function testQuote()
{
$this->assertSame('foo', HeaderUtils::quote('foo'));
$this->assertSame('az09!#$%&\'*.^_`|~-', HeaderUtils::quote('az09!#$%&\'*.^_`|~-'));
$this->assertSame('"foo bar"', HeaderUtils::quote('foo bar'));
$this->assertSame('"foo [bar]"', HeaderUtils::quote('foo [bar]'));
$this->assertSame('"foo \"bar\""', HeaderUtils::quote('foo "bar"'));
$this->assertSame('"foo \\\\ bar"', HeaderUtils::quote('foo \\ bar'));
}
public function testUnquote()
{
$this->assertEquals('foo', HeaderUtils::unquote('foo'));
$this->assertEquals('az09!#$%&\'*.^_`|~-', HeaderUtils::unquote('az09!#$%&\'*.^_`|~-'));
$this->assertEquals('foo bar', HeaderUtils::unquote('"foo bar"'));
$this->assertEquals('foo [bar]', HeaderUtils::unquote('"foo [bar]"'));
$this->assertEquals('foo "bar"', HeaderUtils::unquote('"foo \"bar\""'));
$this->assertEquals('foo "bar"', HeaderUtils::unquote('"foo \"\b\a\r\""'));
$this->assertEquals('foo \\ bar', HeaderUtils::unquote('"foo \\\\ bar"'));
}
}

View File

@ -894,7 +894,7 @@ class RequestTest extends TestCase
array(array('127.0.0.1'), '127.0.0.1', 'for="_gazonk"', array('127.0.0.1')),
array(array('88.88.88.88'), '127.0.0.1', 'for="88.88.88.88:80"', array('127.0.0.1')),
array(array('192.0.2.60'), '::1', 'for=192.0.2.60;proto=http;by=203.0.113.43', array('::1')),
array(array('2620:0:1cfe:face:b00c::3', '192.0.2.43'), '::1', 'for=192.0.2.43, for=2620:0:1cfe:face:b00c::3', array('::1')),
array(array('2620:0:1cfe:face:b00c::3', '192.0.2.43'), '::1', 'for=192.0.2.43, for="[2620:0:1cfe:face:b00c::3]"', array('::1')),
array(array('2001:db8:cafe::17'), '::1', 'for="[2001:db8:cafe::17]:4711', array('::1')),
);
}

View File

@ -287,12 +287,12 @@ class ResponseHeaderBagTest extends TestCase
public function provideMakeDisposition()
{
return array(
array('attachment', 'foo.html', 'foo.html', 'attachment; filename="foo.html"'),
array('attachment', 'foo.html', '', 'attachment; filename="foo.html"'),
array('attachment', 'foo.html', 'foo.html', 'attachment; filename=foo.html'),
array('attachment', 'foo.html', '', 'attachment; filename=foo.html'),
array('attachment', 'foo bar.html', '', 'attachment; filename="foo bar.html"'),
array('attachment', 'foo "bar".html', '', 'attachment; filename="foo \\"bar\\".html"'),
array('attachment', 'foo%20bar.html', 'foo bar.html', 'attachment; filename="foo bar.html"; filename*=utf-8\'\'foo%2520bar.html'),
array('attachment', 'föö.html', 'foo.html', 'attachment; filename="foo.html"; filename*=utf-8\'\'f%C3%B6%C3%B6.html'),
array('attachment', 'föö.html', 'foo.html', 'attachment; filename=foo.html; filename*=utf-8\'\'f%C3%B6%C3%B6.html'),
);
}