Skip to content

Commit b5a9e9c

Browse files
committed
Change parser to use single regular expression to match all headers
1 parent 658ca68 commit b5a9e9c

File tree

2 files changed

+95
-98
lines changed

2 files changed

+95
-98
lines changed

src/Io/RequestHeaderParser.php

Lines changed: 75 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -124,32 +124,34 @@ public function parseRequest($headers, $remoteSocketUri, $localSocketUri)
124124
{
125125
// additional, stricter safe-guard for request line
126126
// because request parser doesn't properly cope with invalid ones
127-
if (!\preg_match('#^[^ ]+ [^ ]+ HTTP/\d\.\d#m', $headers)) {
127+
$start = array();
128+
if (!\preg_match('#^(?<method>[^ ]+) (?<target>[^ ]+) HTTP/(?<version>\d\.\d)#m', $headers, $start)) {
128129
throw new \InvalidArgumentException('Unable to parse invalid request-line');
129130
}
130131

131-
// parser does not support asterisk-form and authority-form
132-
// remember original target and temporarily replace and re-apply below
133-
$originalTarget = null;
134-
if (\strncmp($headers, 'OPTIONS * ', 10) === 0) {
135-
$originalTarget = '*';
136-
$headers = 'OPTIONS / ' . \substr($headers, 10);
137-
} elseif (\strncmp($headers, 'CONNECT ', 8) === 0) {
138-
$parts = \explode(' ', $headers, 3);
139-
$uri = \parse_url('tcp://' . $parts[1]);
132+
// only support HTTP/1.1 and HTTP/1.0 requests
133+
if ($start['version'] !== '1.1' && $start['version'] !== '1.0') {
134+
throw new \InvalidArgumentException('Received request with invalid protocol version', 505);
135+
}
140136

141-
// check this is a valid authority-form request-target (host:port)
142-
if (isset($uri['scheme'], $uri['host'], $uri['port']) && count($uri) === 3) {
143-
$originalTarget = $parts[1];
144-
$parts[1] = 'http://' . $parts[1] . '/';
145-
$headers = implode(' ', $parts);
146-
} else {
147-
throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target');
148-
}
137+
$matches = array();
138+
$n = \preg_match_all('/^([^()<>@,;:\\\"\/\[\]?={}\x01-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+/m', $headers, $matches, \PREG_SET_ORDER);
139+
140+
if (\substr_count($headers, "\n") !== $n) {
141+
throw new \InvalidArgumentException('Unable to parse invalid request header fields');
149142
}
150143

151-
// parse request headers into obj implementing RequestInterface
152-
$request = g7\parse_request($headers);
144+
// format all header fields into associative array
145+
$host = null;
146+
$fields = array();
147+
foreach ($matches as $match) {
148+
$fields[$match[1]][] = $match[2];
149+
150+
// match `Host` request header
151+
if ($host === null && \strtolower($match[1]) === 'host') {
152+
$host = $match[2];
153+
}
154+
}
153155

154156
// create new obj implementing ServerRequestInterface by preserving all
155157
// previous properties and restoring original request-target
@@ -158,6 +160,48 @@ public function parseRequest($headers, $remoteSocketUri, $localSocketUri)
158160
'REQUEST_TIME_FLOAT' => \microtime(true)
159161
);
160162

163+
// scheme is `http` unless TLS is used
164+
$localParts = \parse_url($localSocketUri);
165+
if (isset($localParts['scheme']) && $localParts['scheme'] === 'tls') {
166+
$scheme = 'https://';
167+
$serverParams['HTTPS'] = 'on';
168+
} else {
169+
$scheme = 'http://';
170+
}
171+
172+
// default host if unset comes from local socket address or defaults to localhost
173+
if ($host === null) {
174+
$host = isset($localParts['host'], $localParts['port']) ? $localParts['host'] . ':' . $localParts['port'] : '127.0.0.1';
175+
}
176+
177+
if ($start['method'] === 'OPTIONS' && $start['target'] === '*') {
178+
// support asterisk-form for `OPTIONS *` request line only
179+
$uri = $scheme . $host;
180+
} elseif ($start['method'] === 'CONNECT') {
181+
$parts = \parse_url('tcp://' . $start['target']);
182+
183+
// check this is a valid authority-form request-target (host:port)
184+
if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) {
185+
throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target');
186+
}
187+
$uri = $scheme . $start['target'];
188+
} else {
189+
// support absolute-form or origin-form for proxy requests
190+
if ($start['target'][0] === '/') {
191+
$uri = $scheme . $host . $start['target'];
192+
} else {
193+
// ensure absolute-form request-target contains a valid URI
194+
$parts = \parse_url($start['target']);
195+
196+
// make sure value contains valid host component (IP or hostname), but no fragment
197+
if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) {
198+
throw new \InvalidArgumentException('Invalid absolute-form request-target');
199+
}
200+
201+
$uri = $start['target'];
202+
}
203+
}
204+
161205
// apply REMOTE_ADDR and REMOTE_PORT if source address is known
162206
// address should always be known, unless this is over Unix domain sockets (UDS)
163207
if ($remoteSocketUri !== null) {
@@ -169,51 +213,23 @@ public function parseRequest($headers, $remoteSocketUri, $localSocketUri)
169213
// apply SERVER_ADDR and SERVER_PORT if server address is known
170214
// address should always be known, even for Unix domain sockets (UDS)
171215
// but skip UDS as it doesn't have a concept of host/port.
172-
if ($localSocketUri !== null) {
173-
$localAddress = \parse_url($localSocketUri);
174-
if (isset($localAddress['host'], $localAddress['port'])) {
175-
$serverParams['SERVER_ADDR'] = $localAddress['host'];
176-
$serverParams['SERVER_PORT'] = $localAddress['port'];
177-
}
178-
if (isset($localAddress['scheme']) && $localAddress['scheme'] === 'tls') {
179-
$serverParams['HTTPS'] = 'on';
180-
}
216+
if ($localSocketUri !== null && isset($localParts['host'], $localParts['port'])) {
217+
$serverParams['SERVER_ADDR'] = $localParts['host'];
218+
$serverParams['SERVER_PORT'] = $localParts['port'];
181219
}
182220

183-
$target = $request->getRequestTarget();
184221
$request = new ServerRequest(
185-
$request->getMethod(),
186-
$request->getUri(),
187-
$request->getHeaders(),
188-
$request->getBody(),
189-
$request->getProtocolVersion(),
222+
$start['method'],
223+
$uri,
224+
$fields,
225+
null,
226+
$start['version'],
190227
$serverParams
191228
);
192-
$request = $request->withRequestTarget($target);
193-
194-
// re-apply actual request target from above
195-
if ($originalTarget !== null) {
196-
$request = $request->withUri(
197-
$request->getUri()->withPath(''),
198-
true
199-
)->withRequestTarget($originalTarget);
200-
}
201-
202-
// only support HTTP/1.1 and HTTP/1.0 requests
203-
$protocolVersion = $request->getProtocolVersion();
204-
if ($protocolVersion !== '1.1' && $protocolVersion !== '1.0') {
205-
throw new \InvalidArgumentException('Received request with invalid protocol version', 505);
206-
}
207-
208-
// ensure absolute-form request-target contains a valid URI
209-
$requestTarget = $request->getRequestTarget();
210-
if (\strpos($requestTarget, '://') !== false && \substr($requestTarget, 0, 1) !== '/') {
211-
$parts = \parse_url($requestTarget);
212229

213-
// make sure value contains valid host component (IP or hostname), but no fragment
214-
if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) {
215-
throw new \InvalidArgumentException('Invalid absolute-form request-target');
216-
}
230+
// only assign request target if it is not in origin-form (happy path for most normal requests)
231+
if ($start['target'][0] !== '/') {
232+
$request = $request->withRequestTarget($start['target']);
217233
}
218234

219235
// Optional Host header value MUST be valid (host and optional port)
@@ -252,44 +268,6 @@ public function parseRequest($headers, $remoteSocketUri, $localSocketUri)
252268
}
253269
}
254270

255-
// set URI components from socket address if not already filled via Host header
256-
if ($request->getUri()->getHost() === '') {
257-
$parts = \parse_url($localSocketUri);
258-
if (!isset($parts['host'], $parts['port'])) {
259-
$parts = array('host' => '127.0.0.1', 'port' => 80);
260-
}
261-
262-
$request = $request->withUri(
263-
$request->getUri()->withScheme('http')->withHost($parts['host'])->withPort($parts['port']),
264-
true
265-
);
266-
}
267-
268-
// Do not assume this is HTTPS when this happens to be port 443
269-
// detecting HTTPS is left up to the socket layer (TLS detection)
270-
if ($request->getUri()->getScheme() === 'https') {
271-
$request = $request->withUri(
272-
$request->getUri()->withScheme('http')->withPort(443),
273-
true
274-
);
275-
}
276-
277-
// Update request URI to "https" scheme if the connection is encrypted
278-
$parts = \parse_url($localSocketUri);
279-
if (isset($parts['scheme']) && $parts['scheme'] === 'tls') {
280-
// The request URI may omit default ports here, so try to parse port
281-
// from Host header field (if possible)
282-
$port = $request->getUri()->getPort();
283-
if ($port === null) {
284-
$port = \parse_url('tcp://' . $request->getHeaderLine('Host'), PHP_URL_PORT); // @codeCoverageIgnore
285-
}
286-
287-
$request = $request->withUri(
288-
$request->getUri()->withScheme('https')->withPort($port),
289-
true
290-
);
291-
}
292-
293271
// always sanitize Host header because it contains critical routing information
294272
$request = $request->withUri($request->getUri()->withUserInfo('u')->withUserInfo(''));
295273

tests/Io/RequestHeaderParserTest.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,25 @@ public function testInvalidMalformedRequestLineParseException()
340340
$this->assertSame('Unable to parse invalid request-line', $error->getMessage());
341341
}
342342

343+
public function testInvalidMalformedRequestHeadersThrowsParseException()
344+
{
345+
$error = null;
346+
347+
$parser = new RequestHeaderParser();
348+
$parser->on('headers', $this->expectCallableNever());
349+
$parser->on('error', function ($message) use (&$error) {
350+
$error = $message;
351+
});
352+
353+
$connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock();
354+
$parser->handle($connection);
355+
356+
$connection->emit('data', array("GET / HTTP/1.1\r\nHost : yes\r\n\r\n"));
357+
358+
$this->assertInstanceOf('InvalidArgumentException', $error);
359+
$this->assertSame('Unable to parse invalid request header fields', $error->getMessage());
360+
}
361+
343362
public function testInvalidAbsoluteFormSchemeEmitsError()
344363
{
345364
$error = null;
@@ -400,7 +419,7 @@ public function testUriStartingWithColonSlashSlashFails()
400419
$connection->emit('data', array("GET ://example.com:80/ HTTP/1.0\r\n\r\n"));
401420

402421
$this->assertInstanceOf('InvalidArgumentException', $error);
403-
$this->assertSame('Invalid request string', $error->getMessage());
422+
$this->assertSame('Invalid absolute-form request-target', $error->getMessage());
404423
}
405424

406425
public function testInvalidAbsoluteFormWithFragmentEmitsError()

0 commit comments

Comments
 (0)