Skip to content

Commit 5896f81

Browse files
committed
Move parsing incoming HTTP request message to ServerRequest
1 parent 0638dcd commit 5896f81

File tree

4 files changed

+270
-129
lines changed

4 files changed

+270
-129
lines changed

Diff for: src/Io/AbstractMessage.php

+8
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@
1313
*/
1414
abstract class AbstractMessage implements MessageInterface
1515
{
16+
/**
17+
* [Internal] Regex used to match all request header fields into an array, thanks to @kelunik for checking the HTTP specs and coming up with this regex
18+
*
19+
* @internal
20+
* @var string
21+
*/
22+
const REGEX_HEADERS = '/^([^()<>@,;:\\\"\/\[\]?={}\x01-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m';
23+
1624
/** @var array<string,string[]> */
1725
private $headers = array();
1826

Diff for: src/Io/RequestHeaderParser.php

+1-129
Original file line numberDiff line numberDiff line change
@@ -128,39 +128,6 @@ public function handle(ConnectionInterface $conn)
128128
*/
129129
public function parseRequest($headers, ConnectionInterface $connection)
130130
{
131-
// additional, stricter safe-guard for request line
132-
// because request parser doesn't properly cope with invalid ones
133-
$start = array();
134-
if (!\preg_match('#^(?<method>[^ ]+) (?<target>[^ ]+) HTTP/(?<version>\d\.\d)#m', $headers, $start)) {
135-
throw new \InvalidArgumentException('Unable to parse invalid request-line');
136-
}
137-
138-
// only support HTTP/1.1 and HTTP/1.0 requests
139-
if ($start['version'] !== '1.1' && $start['version'] !== '1.0') {
140-
throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED);
141-
}
142-
143-
// match all request header fields into array, thanks to @kelunik for checking the HTTP specs and coming up with this regex
144-
$matches = array();
145-
$n = \preg_match_all('/^([^()<>@,;:\\\"\/\[\]?={}\x01-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m', $headers, $matches, \PREG_SET_ORDER);
146-
147-
// check number of valid header fields matches number of lines + request line
148-
if (\substr_count($headers, "\n") !== $n + 1) {
149-
throw new \InvalidArgumentException('Unable to parse invalid request header fields');
150-
}
151-
152-
// format all header fields into associative array
153-
$host = null;
154-
$fields = array();
155-
foreach ($matches as $match) {
156-
$fields[$match[1]][] = $match[2];
157-
158-
// match `Host` request header
159-
if ($host === null && \strtolower($match[1]) === 'host') {
160-
$host = $match[2];
161-
}
162-
}
163-
164131
// reuse same connection params for all server params for this connection
165132
$cid = \PHP_VERSION_ID < 70200 ? \spl_object_hash($connection) : \spl_object_id($connection);
166133
if (isset($this->connectionParams[$cid])) {
@@ -207,101 +174,6 @@ public function parseRequest($headers, ConnectionInterface $connection)
207174
$serverParams['REQUEST_TIME'] = (int) ($now = $this->clock->now());
208175
$serverParams['REQUEST_TIME_FLOAT'] = $now;
209176

210-
// scheme is `http` unless TLS is used
211-
$scheme = isset($serverParams['HTTPS']) ? 'https://' : 'http://';
212-
213-
// default host if unset comes from local socket address or defaults to localhost
214-
$hasHost = $host !== null;
215-
if ($host === null) {
216-
$host = isset($serverParams['SERVER_ADDR'], $serverParams['SERVER_PORT']) ? $serverParams['SERVER_ADDR'] . ':' . $serverParams['SERVER_PORT'] : '127.0.0.1';
217-
}
218-
219-
if ($start['method'] === 'OPTIONS' && $start['target'] === '*') {
220-
// support asterisk-form for `OPTIONS *` request line only
221-
$uri = $scheme . $host;
222-
} elseif ($start['method'] === 'CONNECT') {
223-
$parts = \parse_url('tcp://' . $start['target']);
224-
225-
// check this is a valid authority-form request-target (host:port)
226-
if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) {
227-
throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target');
228-
}
229-
$uri = $scheme . $start['target'];
230-
} else {
231-
// support absolute-form or origin-form for proxy requests
232-
if ($start['target'][0] === '/') {
233-
$uri = $scheme . $host . $start['target'];
234-
} else {
235-
// ensure absolute-form request-target contains a valid URI
236-
$parts = \parse_url($start['target']);
237-
238-
// make sure value contains valid host component (IP or hostname), but no fragment
239-
if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) {
240-
throw new \InvalidArgumentException('Invalid absolute-form request-target');
241-
}
242-
243-
$uri = $start['target'];
244-
}
245-
}
246-
247-
$request = new ServerRequest(
248-
$start['method'],
249-
$uri,
250-
$fields,
251-
'',
252-
$start['version'],
253-
$serverParams
254-
);
255-
256-
// only assign request target if it is not in origin-form (happy path for most normal requests)
257-
if ($start['target'][0] !== '/') {
258-
$request = $request->withRequestTarget($start['target']);
259-
}
260-
261-
if ($hasHost) {
262-
// Optional Host request header value MUST be valid (host and optional port)
263-
$parts = \parse_url('http://' . $request->getHeaderLine('Host'));
264-
265-
// make sure value contains valid host component (IP or hostname)
266-
if (!$parts || !isset($parts['scheme'], $parts['host'])) {
267-
$parts = false;
268-
}
269-
270-
// make sure value does not contain any other URI component
271-
if (\is_array($parts)) {
272-
unset($parts['scheme'], $parts['host'], $parts['port']);
273-
}
274-
if ($parts === false || $parts) {
275-
throw new \InvalidArgumentException('Invalid Host header value');
276-
}
277-
} elseif (!$hasHost && $start['version'] === '1.1' && $start['method'] !== 'CONNECT') {
278-
// require Host request header for HTTP/1.1 (except for CONNECT method)
279-
throw new \InvalidArgumentException('Missing required Host request header');
280-
} elseif (!$hasHost) {
281-
// remove default Host request header for HTTP/1.0 when not explicitly given
282-
$request = $request->withoutHeader('Host');
283-
}
284-
285-
// ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers
286-
if ($request->hasHeader('Transfer-Encoding')) {
287-
if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') {
288-
throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', Response::STATUS_NOT_IMPLEMENTED);
289-
}
290-
291-
// Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time
292-
// as per https://tools.ietf.org/html/rfc7230#section-3.3.3
293-
if ($request->hasHeader('Content-Length')) {
294-
throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', Response::STATUS_BAD_REQUEST);
295-
}
296-
} elseif ($request->hasHeader('Content-Length')) {
297-
$string = $request->getHeaderLine('Content-Length');
298-
299-
if ((string)(int)$string !== $string) {
300-
// Content-Length value is not an integer or not a single integer
301-
throw new \InvalidArgumentException('The value of `Content-Length` is not valid', Response::STATUS_BAD_REQUEST);
302-
}
303-
}
304-
305-
return $request;
177+
return ServerRequest::parseMessage($headers, $serverParams);
306178
}
307179
}

Diff for: src/Message/ServerRequest.php

+139
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,143 @@ private function parseCookie($cookie)
189189

190190
return $result;
191191
}
192+
193+
/**
194+
* [Internal] Parse incoming HTTP protocol message
195+
*
196+
* @internal
197+
* @param string $message
198+
* @param array<string,string|int|float> $serverParams
199+
* @return self
200+
* @throws \InvalidArgumentException if given $message is not a valid HTTP request message
201+
*/
202+
public static function parseMessage($message, array $serverParams)
203+
{
204+
// parse request line like "GET /path HTTP/1.1"
205+
$start = array();
206+
if (!\preg_match('#^(?<method>[^ ]+) (?<target>[^ ]+) HTTP/(?<version>\d\.\d)#m', $message, $start)) {
207+
throw new \InvalidArgumentException('Unable to parse invalid request-line');
208+
}
209+
210+
// only support HTTP/1.1 and HTTP/1.0 requests
211+
if ($start['version'] !== '1.1' && $start['version'] !== '1.0') {
212+
throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED);
213+
}
214+
215+
// check number of valid header fields matches number of lines + request line
216+
$matches = array();
217+
$n = \preg_match_all(self::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER);
218+
if (\substr_count($message, "\n") !== $n + 1) {
219+
throw new \InvalidArgumentException('Unable to parse invalid request header fields');
220+
}
221+
222+
// format all header fields into associative array
223+
$host = null;
224+
$headers = array();
225+
foreach ($matches as $match) {
226+
$headers[$match[1]][] = $match[2];
227+
228+
// match `Host` request header
229+
if ($host === null && \strtolower($match[1]) === 'host') {
230+
$host = $match[2];
231+
}
232+
}
233+
234+
// scheme is `http` unless TLS is used
235+
$scheme = isset($serverParams['HTTPS']) ? 'https://' : 'http://';
236+
237+
// default host if unset comes from local socket address or defaults to localhost
238+
$hasHost = $host !== null;
239+
if ($host === null) {
240+
$host = isset($serverParams['SERVER_ADDR'], $serverParams['SERVER_PORT']) ? $serverParams['SERVER_ADDR'] . ':' . $serverParams['SERVER_PORT'] : '127.0.0.1';
241+
}
242+
243+
if ($start['method'] === 'OPTIONS' && $start['target'] === '*') {
244+
// support asterisk-form for `OPTIONS *` request line only
245+
$uri = $scheme . $host;
246+
} elseif ($start['method'] === 'CONNECT') {
247+
$parts = \parse_url('tcp://' . $start['target']);
248+
249+
// check this is a valid authority-form request-target (host:port)
250+
if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) {
251+
throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target');
252+
}
253+
$uri = $scheme . $start['target'];
254+
} else {
255+
// support absolute-form or origin-form for proxy requests
256+
if ($start['target'][0] === '/') {
257+
$uri = $scheme . $host . $start['target'];
258+
} else {
259+
// ensure absolute-form request-target contains a valid URI
260+
$parts = \parse_url($start['target']);
261+
262+
// make sure value contains valid host component (IP or hostname), but no fragment
263+
if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) {
264+
throw new \InvalidArgumentException('Invalid absolute-form request-target');
265+
}
266+
267+
$uri = $start['target'];
268+
}
269+
}
270+
271+
$request = new self(
272+
$start['method'],
273+
$uri,
274+
$headers,
275+
'',
276+
$start['version'],
277+
$serverParams
278+
);
279+
280+
// only assign request target if it is not in origin-form (happy path for most normal requests)
281+
if ($start['target'][0] !== '/') {
282+
$request = $request->withRequestTarget($start['target']);
283+
}
284+
285+
if ($hasHost) {
286+
// Optional Host request header value MUST be valid (host and optional port)
287+
$parts = \parse_url('http://' . $request->getHeaderLine('Host'));
288+
289+
// make sure value contains valid host component (IP or hostname)
290+
if (!$parts || !isset($parts['scheme'], $parts['host'])) {
291+
$parts = false;
292+
}
293+
294+
// make sure value does not contain any other URI component
295+
if (\is_array($parts)) {
296+
unset($parts['scheme'], $parts['host'], $parts['port']);
297+
}
298+
if ($parts === false || $parts) {
299+
throw new \InvalidArgumentException('Invalid Host header value');
300+
}
301+
} elseif (!$hasHost && $start['version'] === '1.1' && $start['method'] !== 'CONNECT') {
302+
// require Host request header for HTTP/1.1 (except for CONNECT method)
303+
throw new \InvalidArgumentException('Missing required Host request header');
304+
} elseif (!$hasHost) {
305+
// remove default Host request header for HTTP/1.0 when not explicitly given
306+
$request = $request->withoutHeader('Host');
307+
}
308+
309+
// ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers
310+
if ($request->hasHeader('Transfer-Encoding')) {
311+
if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') {
312+
throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', Response::STATUS_NOT_IMPLEMENTED);
313+
}
314+
315+
// Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time
316+
// as per https://tools.ietf.org/html/rfc7230#section-3.3.3
317+
if ($request->hasHeader('Content-Length')) {
318+
throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', Response::STATUS_BAD_REQUEST);
319+
}
320+
} elseif ($request->hasHeader('Content-Length')) {
321+
$string = $request->getHeaderLine('Content-Length');
322+
323+
if ((string)(int)$string !== $string) {
324+
// Content-Length value is not an integer or not a single integer
325+
throw new \InvalidArgumentException('The value of `Content-Length` is not valid', Response::STATUS_BAD_REQUEST);
326+
}
327+
}
328+
329+
return $request;
330+
}
192331
}

0 commit comments

Comments
 (0)