10000 feature #24699 [HttpFoundation] Add HeaderUtils class (c960657) · symfony/symfony@cbc2376 · GitHub
[go: up one dir, main page]

Skip to content

Commit cbc2376

Browse files
committed
feature #24699 [HttpFoundation] Add HeaderUtils class (c960657)
This PR was merged into the 4.1-dev branch. Discussion ---------- [HttpFoundation] Add Hea 10000 derUtils 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 ------- b435e80 [HttpFoundation] Add HeaderUtility class
2 parents 9cb1f14 + b435e80 commit cbc2376

15 files changed

+330
-96
lines changed

src/Symfony/Component/HttpFoundation/AcceptHeader.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,17 @@ public static function fromString($headerValue)
5252
{
5353
$index = 0;
5454

55-
return new self(array_map(function ($itemValue) use (&$index) {
56-
$item = AcceptHeaderItem::fromString($itemValue);
55+
$parts = HeaderUtils::split((string) $headerValue, ',;=');
56+
57+
return new self(array_map(function ($subParts) use (&$index) {
58+
$part = array_shift($subParts);
59+
$attributes = HeaderUtils::combineParts($subParts);
60+
61+
$item = new AcceptHeaderItem($part[0], $attributes);
5762
$item->setIndex($index++);
5863

5964
return $item;
60-
}, preg_split('/\s*(?:,*("[^"]+"),*|,*(\'[^\']+\'),*|,+)\s*/', $headerValue, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE)));
65+
}, $parts));
6166
}
6267

6368
/**

src/Symfony/Component/HttpFoundation/AcceptHeaderItem.php

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,24 +40,12 @@ public function __construct(string $value, array $attributes = array())
4040
*/
4141
public static function fromString($itemValue)
4242
{
43-
$bits = preg_split('/\s*(?:;*("[^"]+");*|;*(\'[^\']+\');*|;+)\s*/', $itemValue, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
44-
$value = array_shift($bits);
45-
$attributes = array();
46-
47-
$lastNullAttribute = null;
48-
foreach ($bits as $bit) {
49-
if (($start = substr($bit, 0, 1)) === ($end = substr($bit, -1)) && ('"' === $start || '\'' === $start)) {
50-
$attributes[$lastNullAttribute] = substr($bit, 1, -1);
51-
} elseif ('=' === $end) {
52-
$lastNullAttribute = $bit = substr($bit, 0, -1);
53-
$attributes[$bit] = null;
54-
} else {
55-
$parts = explode('=', $bit);
56-
$attributes[$parts[0]] = isset($parts[1]) && strlen($parts[1]) > 0 ? $parts[1] : '';
57-
}
58-
}
43+
$parts = HeaderUtils::split($itemValue, ';=');
44+
45+
$part = array_shift($parts);
46+
$attributes = HeaderUtils::combineParts($parts, 1);
5947

60-
return new self(($start = substr($value, 0, 1)) === ($end = substr($value, -1)) && ('"' === $start || '\'' === $start) ? substr($value, 1, -1) : $value, $attributes);
48+
return new self($part[0], $attributes);
6149
}
6250

6351
/**
@@ -69,9 +57,7 @@ public function __toString()
6957
{
7058
$string = $this->value.($this->quality < 1 ? ';q='.$this->quality : '');
7159
if (count($this->attributes) > 0) {
72-
$string .= ';'.implode(';', array_map(function ($name, $value) {
73-
return sprintf(preg_match('/[,;=]/', $value) ? '%s="%s"' : '%s=%s', $name, $value);
74-
}, array_keys($this->attributes), $this->attributes));
60+
$string .= '; '.HeaderUtils::joinAssoc($this->attributes, ';');
7561
}
7662

7763
return $string;

src/Symfony/Component/HttpFoundation/BinaryFileResponse.php

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -218,17 +218,12 @@ public function prepare(Request $request)
218218
if ('x-accel-redirect' === strtolower($type)) {
219219
// Do X-Accel-Mapping substitutions.
220220
// @link http://wiki.nginx.org/X-accel#X-Accel-Redirect
221-
foreach (explode(',', $request->headers->get('X-Accel-Mapping', '')) as $mapping) {
222-
$mapping = explode('=', $mapping, 2);
223-
224-
if (2 === count($mapping)) {
225-
$pathPrefix = trim($mapping[0]);
226-
$location = trim($mapping[1]);
227-
228-
if (substr($path, 0, strlen($pathPrefix)) === $pathPrefix) {
229-
$path = $location.substr($path, strlen($pathPrefix));
230-
break;
231-
}
221+
$parts = HeaderUtils::split($request->headers->get('X-Accel-Mapping', ''), ',=');
222+
$mappings = HeaderUtils::combineParts($parts);
223+
foreach ($mappings as $pathPrefix => $location) {
224+
if (substr($path, 0, strlen($pathPrefix)) === $pathPrefix) {
225+
$path = $location.substr($path, strlen($pathPrefix));
226+
break;
232227
}
233228
}
234229
}

src/Symfony/Component/HttpFoundation/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ CHANGELOG
1616
`IniSizeFileException`, `NoFileException`, `NoTmpDirFileException`, `PartialFileException` to
1717
handle failed `UploadedFile`.
1818
* added `MigratingSessionHandler` for migrating between two session handlers without losing sessions
19+
* added `HeaderUtils`.
1920

2021
4.0.0
2122
-----

src/Symfony/Component/HttpFoundation/Cookie.php

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -50,34 +50,20 @@ public static function fromString($cookie, $decode = false)
5050
'raw' => !$decode,
5151
'samesite' => null,
5252
);
53-
foreach (explode(';', $cookie) as $part) {
54-
if (false === strpos($part, '=')) {
55-
$key = trim($part);
56-
$value = true;
57-
} else {
58-
list($key, $value) = explode('=', trim($part), 2);
59-
$key = trim($key);
60-
$value = trim($value);
61-
}
62-
if (!isset($data['name'])) {
63-
$data['name'] = $decode ? urldecode($key) : $key;
64-
$data['value'] = true === $value ? null : ($decode ? urldecode($value) : $value);
65-
continue;
66-
}
67-
switch ($key = strtolower($key)) {
68-
case 'name':
69-
case 'value':
70-
break;
71-
case 'max-age':
72-
$data['expires'] = time() + (int) $value;
73-
break;
74-
default:
75-
$data[$key] = $value;
76-
break;
77-
}
53+
5 2851 4+
$parts = HeaderUtils::split($cookie, ';=');
55+
$part = array_shift($parts);
56+
57+
$name = $decode ? urldecode($part[0]) : $part[0];
58+
$value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null;
59+
60+
$data = HeaderUtils::combineParts($parts) + $data;
61+
62+
if (isset($data['max-age'])) {
63+
$data['expires'] = time() + (int) $data['max-age'];
7864
}
7965

80-
return new static($data['name'], $data['value'], $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite']);
66+
return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite']);
8167
}
8268

8369
/**

src/Symfony/Component/HttpFoundation/HeaderBag.php

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -294,21 +294,9 @@ public function count()
294294

295295
protected function getCacheControlHeader()
296296
{
297-
$parts = array();
298297
ksort($this->cacheControl);
299-
foreach ($this->cacheControl as $key => $value) {
300-
if (true === $value) {
301-
$parts[] = $key;
302-
} else {
303-
if (preg_match('#[^a-zA-Z0-9._-]#', $value)) {
304-
$value = '"'.$value.'"';
305-
}
306298

307-
$parts[] = "$key=$value";
308-
}
309-
}
310-
311-
return implode(', ', $parts);
299+
return HeaderUtils::joinAssoc($this->cacheControl, ',');
312300
}
313301

314302
/**
@@ -320,12 +308,8 @@ protected function getCacheControlHeader()
320308
*/
321309
protected function parseCacheControl($header)
322310
{
323-
$cacheControl = array();
324-
preg_match_all('#([a-zA-Z][a-zA-Z_-]*)\s*(?:=(?:"([^"]*)"|([^ \t",;]*)))?#', $header, $matches, PREG_SET_ORDER);
325-
foreach ($matches as $match) {
326-
$cacheControl[strtolower($match[1])] = isset($match[3]) ? $match[3] : (isset($match[2]) ? $match[2] : true);
327-
}
311+
$parts = HeaderUtils::split($header, ',=');
328312

329-
return $cacheControl;
313+
return HeaderUtils::combineParts($parts);
330314
}
331315
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpFoundation;
13+
14+
/**
15+
* HTTP header utility functions.
16+
*
17+
* @author Christian Schmidt <github@chsc.dk>
18+
*/
19+
class HeaderUtils
20+
{
21+
/**
22+
* This class should not be instantiated.
23+
*/
24+
private function __construct()
25+
{
26+
}
27+
28+
/**
29+
* Splits an HTTP header by one or more separators.
30+
*
31+
* Example:
32+
*
33+
* HeaderUtils::split("da, en-gb;q=0.8", ",;")
34+
* // => array(array("da"), array("en-gb"), array("q", "0.8"))
35+
*
36+
* @param string $header HTTP header value
37+
* @param string $separators List of characters to split on, ordered by
38+
* precedence, e.g. ",", ";=", or ",;="
39+
*
40+
* @return array Nested array with as many levels as there are characters in
41+
* $separators
42+
*/
43+
public static function split(string $header, string $separators): array
44+
{
45+
$quotedSeparators = preg_quote($separators);
46+
47+
preg_match_all('
48+
/
49+
(?!\s)
50+
(?:
51+
# quoted-string
52+
"(?:[^"\\\\]|\\\\.)*(?:"|\\\\|$)
53+
|
54+
# token
55+
[^"'.$quotedSeparators.']+
56+
)+
57+
(?<!\s)
58+
|
59+
# separator
60+
\s*
61+
(?<separator>['.$quotedSeparators.'])
62+
\s*
63+
/x', trim($header), $matches, PREG_SET_ORDER);
64+
65+
return self::groupParts($matches, $separators);
66+
}
67+
68+
/**
69+
* Combines an array of arrays into one associative array.
70+
*
71+
* Each of the nested arrays should have one or two elements. The first
72+
* value will be used as the keys in the associative array, and the second
73+
* will be used as the values, or true if the nested array only contains one
74+
* element.
75+
*
76+
* Example:
77+
*
78+
* HeaderUtils::combineParts(array(array("foo", "abc"), array("bar")))
79+
* // => array("foo" => "abc", "bar" => true)
80+
*/
81+
public static function combineParts(array $parts): array
82+
{
83+
$assoc = array();
84+
foreach ($parts as $part) {
85+
$name = strtolower($part[0]);
86+
$value = $part[1] ?? true;
87+
$assoc[$name] = $value;
88+
}
89+
90+
return $assoc;
91+
}
92+
93+
/**
94+
* Joins an associative array into a string for use in an HTTP header.
95+
*
96+
* The key and value of each entry are joined with "=", and all entries
97+
* is joined with the specified separator and an additional space (for
98+
* readability). Values are quoted if necessary.
99+
*
100+
* Example:
101+
*
102+
* HeaderUtils::joinAssoc(array("foo" => "abc", "bar" => true, "baz" => "a b c"), ",")
103+
* // => 'foo=bar, baz, baz="a b c"'
104+
*/
105+
public static function joinAssoc(array $assoc, string $separator): string
106+
{
107+
$parts = array();
108+
foreach ($assoc as $name => $value) {
109+
if (true === $value) {
110+
$parts[] = $name;
111+
} else {
112+
$parts[] = $name.'='.self::quote($value);
113+
}
114+
}
115+
116+
return implode($separator.' ', $parts);
117+
}
118+
119+
/**
120+
* Encodes a string as a quoted string, if necessary.
121+
*
122+
* If a string contains characters not allowed by the "token" construct in
123+
* the HTTP specification, it is backslash-escaped and enclosed in quotes
124+
* to match the "quoted-string" construct.
125+
*/
126+
public static function quote(string $s): string
127+
{
128+
if (preg_match('/^[a-z0-9!#$%&\'*.^_`|~-]+$/i', $s)) {
129+
return $s;
130+
}
131+
132+
return '"'.addcslashes($s, '"\\"').'"';
133+
}
134+
135+
/**
136+
* Decodes a quoted string.
137+
*
138+
* If passed an unquoted string that matches the "token" construct (as
139+
* defined in the HTTP specification), it is passed through verbatimly.
140+
*/
141+
public static function unquote(string $s): string
142+
{
143+
return preg_replace('/\\\\(.)|"/', '$1', $s);
144+
}
145+
146+
private static function groupParts(array $matches, string $separators): array
147+
{
148+
$separator = $separators[0];
149+
$partSeparators = substr($separators, 1);
150+
151+
$i = 0;
152+
$partMatches = array();
153+
foreach ($matches as $match) {
154+
if (isset($match['separator']) && $match['separator'] === $separator) {
155+
++$i;
156+
} else {
157+
$partMatches[$i][] = $match;
158+
}
159+
}
160+
161+
$parts = array();
162+
if ($partSeparators) {
163+
foreach ($partMatches as $matches) {
164+
$parts[] = self::groupParts($matches, $partSeparators);
165+
}
166+
} else {
167+
foreach ($partMatches as $matches) {
168+
$parts[] = self::unquote($matches[0][0]);
169+
}
170+
}
171+
172+
return $parts;
173+
}
174+
}

src/Symfony/Component/HttpFoundation/Request.php

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,8 +1944,16 @@ private function getTrustedValues($type, $ip = null)
19441944
}
19451945

19461946
if ((self::$trustedHeaderSet & self::HEADER_FORWARDED) && $this->headers->has(self::$trustedHeaders[self::HEADER_FORWARDED])) {
1947-
$forwardedValues = $this->headers->get(self::$trustedHeaders[self::HEADER_FORWARDED]);
1948-
$forwardedValues = preg_match_all(sprintf('{(?:%s)=(?:"?\[?)([a-zA-Z0-9\.:_\-/]*+)}', self::$forwardedParams[$type]), $forwardedValues, $matches) ? $matches[1] : array();
1947+
$forwarded = $this->headers->get(self::$trustedHeaders[self::HEADER_FORWARDED]);
1948+
$parts = HeaderUtils::split($forwarded, ',;=');
1949+
$forwardedValues = array();
1950+
$param = self::$forwardedParams[$type];
1951+
foreach ($parts as $subParts) {
1952+
$assoc = HeaderUtils::combineParts($subParts);
1953+
if (isset($assoc[$param])) {
1954+
$forwardedValues[] = $assoc[$param];
1955+
}
1956+
}
19491957
}
19501958

19511959
if (null !== $ip) {
@@ -1978,9 +1986,17 @@ private function normalizeAndFilterClientIps(array $clientIps, $ip)
19781986
$firstTrustedIp = null;
19791987

19801988
foreach ($clientIps as $key => $clientIp) {
1981-
// Remove port (unfortunately, it does happen)
1982-
if (preg_match('{((?:\d+\.){3}\d+)\:\d+}', $clientIp, $match)) {
1983-
$clientIps[$key] = $clientIp = $match[1];
1989+
if (strpos($clientIp, '.')) {
1990+
// Strip :port from IPv4 addresses. This is allowed in Forwarded
1991+
// and may occur in X-Forwarded-For.
1992+
$i = strpos($clientIp, ':');
1993+
if ($i) {
1994+
$clientIps[$key] = $clientIp = substr($clientIp, 0, $i);
1995+
}
1996+
} elseif ('[' == $clientIp[0]) {
1997+
// Strip brackets and :port from IPv6 addresses.
1998+
$i = strpos($clientIp, ']', 1);
1999+
$clientIps[$key] = $clientIp = substr($clientIp, 1, $i - 1);
19842000
}
19852001

19862002
if (!filter_var($clientIp, FILTER_VALIDATE_IP)) {

0 commit comments

Comments
 (0)
0