1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace React\Http\Io; |
4
|
|
|
|
5
|
|
|
use Evenement\EventEmitter; |
6
|
|
|
use Psr\Http\Message\ServerRequestInterface; |
7
|
|
|
use React\Http\Message\Response; |
8
|
|
|
use React\Http\Message\ServerRequest; |
9
|
|
|
use React\Socket\ConnectionInterface; |
10
|
|
|
use Exception; |
11
|
|
|
|
12
|
|
|
/** |
13
|
|
|
* [Internal] Parses an incoming request header from an input stream |
14
|
|
|
* |
15
|
|
|
* This is used internally to parse the request header from the connection and |
16
|
|
|
* then process the remaining connection as the request body. |
17
|
|
|
* |
18
|
|
|
* @event headers |
19
|
|
|
* @event error |
20
|
|
|
* |
21
|
|
|
* @internal |
22
|
|
|
*/ |
23
|
|
|
class RequestHeaderParser extends EventEmitter |
24
|
|
|
{ |
25
|
|
|
private $maxSize = 8192; |
26
|
|
|
|
27
|
|
|
public function handle(ConnectionInterface $conn) |
28
|
|
|
{ |
29
|
|
|
$buffer = ''; |
30
|
|
|
$maxSize = $this->maxSize; |
31
|
|
|
$that = $this; |
32
|
|
|
$conn->on('data', $fn = function ($data) use (&$buffer, &$fn, $conn, $maxSize, $that) { |
33
|
|
|
// append chunk of data to buffer and look for end of request headers |
34
|
|
|
$buffer .= $data; |
35
|
|
|
$endOfHeader = \strpos($buffer, "\r\n\r\n"); |
36
|
|
|
|
37
|
|
|
// reject request if buffer size is exceeded |
38
|
|
|
if ($endOfHeader > $maxSize || ($endOfHeader === false && isset($buffer[$maxSize]))) { |
39
|
|
|
$conn->removeListener('data', $fn); |
40
|
|
|
$fn = null; |
41
|
|
|
|
42
|
|
|
$that->emit('error', array( |
43
|
|
|
new \OverflowException("Maximum header size of {$maxSize} exceeded.", Response::STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE), |
44
|
|
|
$conn |
45
|
|
|
)); |
46
|
|
|
return; |
47
|
|
|
} |
48
|
|
|
|
49
|
|
|
// ignore incomplete requests |
50
|
|
|
if ($endOfHeader === false) { |
51
|
|
|
return; |
52
|
|
|
} |
53
|
|
|
|
54
|
|
|
// request headers received => try to parse request |
55
|
|
|
$conn->removeListener('data', $fn); |
56
|
|
|
$fn = null; |
57
|
|
|
|
58
|
|
|
try { |
59
|
|
|
$request = $that->parseRequest( |
60
|
|
|
(string)\substr($buffer, 0, $endOfHeader + 2), |
61
|
|
|
$conn->getRemoteAddress(), |
62
|
|
|
$conn->getLocalAddress() |
63
|
|
|
); |
64
|
|
|
} catch (Exception $exception) { |
65
|
|
|
$buffer = ''; |
66
|
|
|
$that->emit('error', array( |
67
|
|
|
$exception, |
68
|
|
|
$conn |
69
|
|
|
)); |
70
|
|
|
return; |
71
|
|
|
} |
72
|
|
|
|
73
|
|
|
$contentLength = 0; |
74
|
|
|
if ($request->hasHeader('Transfer-Encoding')) { |
75
|
|
|
$contentLength = null; |
76
|
|
|
} elseif ($request->hasHeader('Content-Length')) { |
77
|
|
|
$contentLength = (int)$request->getHeaderLine('Content-Length'); |
78
|
|
|
} |
79
|
|
|
|
80
|
|
|
if ($contentLength === 0) { |
81
|
|
|
// happy path: request body is known to be empty |
82
|
|
|
$stream = new EmptyBodyStream(); |
83
|
|
|
$request = $request->withBody($stream); |
84
|
|
|
} else { |
85
|
|
|
// otherwise body is present => delimit using Content-Length or ChunkedDecoder |
86
|
|
|
$stream = new CloseProtectionStream($conn); |
87
|
|
|
if ($contentLength !== null) { |
88
|
|
|
$stream = new LengthLimitedStream($stream, $contentLength); |
89
|
|
|
} else { |
90
|
|
|
$stream = new ChunkedDecoder($stream); |
91
|
|
|
} |
92
|
|
|
|
93
|
|
|
$request = $request->withBody(new HttpBodyStream($stream, $contentLength)); |
94
|
|
|
} |
95
|
|
|
|
96
|
|
|
$bodyBuffer = isset($buffer[$endOfHeader + 4]) ? \substr($buffer, $endOfHeader + 4) : ''; |
97
|
|
|
$buffer = ''; |
98
|
|
|
$that->emit('headers', array($request, $conn)); |
99
|
|
|
|
100
|
|
|
if ($bodyBuffer !== '') { |
101
|
|
|
$conn->emit('data', array($bodyBuffer)); |
102
|
|
|
} |
103
|
|
|
|
104
|
|
|
// happy path: request body is known to be empty => immediately end stream |
105
|
|
|
if ($contentLength === 0) { |
106
|
|
|
$stream->emit('end'); |
107
|
|
|
$stream->close(); |
108
|
|
|
} |
109
|
|
|
}); |
110
|
|
|
} |
111
|
|
|
|
112
|
|
|
/** |
113
|
|
|
* @param string $headers buffer string containing request headers only |
114
|
|
|
* @param ?string $remoteSocketUri |
115
|
|
|
* @param ?string $localSocketUri |
116
|
|
|
* @return ServerRequestInterface |
117
|
|
|
* @throws \InvalidArgumentException |
118
|
|
|
* @internal |
119
|
|
|
*/ |
120
|
|
|
public function parseRequest($headers, $remoteSocketUri, $localSocketUri) |
121
|
|
|
{ |
122
|
|
|
// additional, stricter safe-guard for request line |
123
|
|
|
// because request parser doesn't properly cope with invalid ones |
124
|
|
|
$start = array(); |
125
|
|
|
if (!\preg_match('#^(?<method>[^ ]+) (?<target>[^ ]+) HTTP/(?<version>\d\.\d)#m', $headers, $start)) { |
126
|
|
|
throw new \InvalidArgumentException('Unable to parse invalid request-line'); |
127
|
|
|
} |
128
|
|
|
|
129
|
|
|
// only support HTTP/1.1 and HTTP/1.0 requests |
130
|
|
|
if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { |
131
|
|
|
throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED); |
132
|
|
|
} |
133
|
|
|
|
134
|
|
|
// match all request header fields into array, thanks to @kelunik for checking the HTTP specs and coming up with this regex |
135
|
|
|
$matches = array(); |
136
|
|
|
$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); |
137
|
|
|
|
138
|
|
|
// check number of valid header fields matches number of lines + request line |
139
|
|
|
if (\substr_count($headers, "\n") !== $n + 1) { |
140
|
|
|
throw new \InvalidArgumentException('Unable to parse invalid request header fields'); |
141
|
|
|
} |
142
|
|
|
|
143
|
|
|
// format all header fields into associative array |
144
|
|
|
$host = null; |
145
|
|
|
$fields = array(); |
146
|
|
|
foreach ($matches as $match) { |
147
|
|
|
$fields[$match[1]][] = $match[2]; |
148
|
|
|
|
149
|
|
|
// match `Host` request header |
150
|
|
|
if ($host === null && \strtolower($match[1]) === 'host') { |
151
|
|
|
$host = $match[2]; |
152
|
|
|
} |
153
|
|
|
} |
154
|
|
|
|
155
|
|
|
// create new obj implementing ServerRequestInterface by preserving all |
156
|
|
|
// previous properties and restoring original request-target |
157
|
|
|
$serverParams = array( |
158
|
|
|
'REQUEST_TIME' => \time(), |
159
|
|
|
'REQUEST_TIME_FLOAT' => \microtime(true) |
160
|
|
|
); |
161
|
|
|
|
162
|
|
|
// scheme is `http` unless TLS is used |
163
|
|
|
$localParts = $localSocketUri === null ? array() : \parse_url($localSocketUri); |
164
|
|
|
if (isset($localParts['scheme']) && $localParts['scheme'] === 'tls') { |
165
|
|
|
$scheme = 'https://'; |
166
|
|
|
$serverParams['HTTPS'] = 'on'; |
167
|
|
|
} else { |
168
|
|
|
$scheme = 'http://'; |
169
|
|
|
} |
170
|
|
|
|
171
|
|
|
// default host if unset comes from local socket address or defaults to localhost |
172
|
|
|
$hasHost = $host !== null; |
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
|
|
|
|
205
|
|
|
// apply REMOTE_ADDR and REMOTE_PORT if source address is known |
206
|
|
|
// address should always be known, unless this is over Unix domain sockets (UDS) |
207
|
|
|
if ($remoteSocketUri !== null) { |
208
|
|
|
$remoteAddress = \parse_url($remoteSocketUri); |
209
|
|
|
$serverParams['REMOTE_ADDR'] = $remoteAddress['host']; |
210
|
|
|
$serverParams['REMOTE_PORT'] = $remoteAddress['port']; |
211
|
|
|
} |
212
|
|
|
|
213
|
|
|
// apply SERVER_ADDR and SERVER_PORT if server address is known |
214
|
|
|
// address should always be known, even for Unix domain sockets (UDS) |
215
|
|
|
// but skip UDS as it doesn't have a concept of host/port. |
216
|
|
|
if ($localSocketUri !== null && isset($localParts['host'], $localParts['port'])) { |
217
|
|
|
$serverParams['SERVER_ADDR'] = $localParts['host']; |
218
|
|
|
$serverParams['SERVER_PORT'] = $localParts['port']; |
219
|
|
|
} |
220
|
|
|
|
221
|
|
|
$request = new ServerRequest( |
222
|
|
|
$start['method'], |
223
|
|
|
$uri, |
224
|
|
|
$fields, |
225
|
|
|
'', |
226
|
|
|
$start['version'], |
227
|
|
|
$serverParams |
228
|
|
|
); |
229
|
|
|
|
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']); |
233
|
|
|
} |
234
|
|
|
|
235
|
|
|
if ($hasHost) { |
236
|
|
|
// Optional Host request header value MUST be valid (host and optional port) |
237
|
|
|
$parts = \parse_url('http://' . $request->getHeaderLine('Host')); |
238
|
|
|
|
239
|
|
|
// make sure value contains valid host component (IP or hostname) |
240
|
|
|
if (!$parts || !isset($parts['scheme'], $parts['host'])) { |
|
|
|
|
241
|
|
|
$parts = false; |
242
|
|
|
} |
243
|
|
|
|
244
|
|
|
// make sure value does not contain any other URI component |
245
|
|
|
if (\is_array($parts)) { |
246
|
|
|
unset($parts['scheme'], $parts['host'], $parts['port']); |
247
|
|
|
} |
248
|
|
|
if ($parts === false || $parts) { |
|
|
|
|
249
|
|
|
throw new \InvalidArgumentException('Invalid Host header value'); |
250
|
|
|
} |
251
|
|
|
} elseif (!$hasHost && $start['version'] === '1.1' && $start['method'] !== 'CONNECT') { |
|
|
|
|
252
|
|
|
// require Host request header for HTTP/1.1 (except for CONNECT method) |
253
|
|
|
throw new \InvalidArgumentException('Missing required Host request header'); |
254
|
|
|
} elseif (!$hasHost) { |
255
|
|
|
// remove default Host request header for HTTP/1.0 when not explicitly given |
256
|
|
|
$request = $request->withoutHeader('Host'); |
257
|
|
|
} |
258
|
|
|
|
259
|
|
|
// ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers |
260
|
|
|
if ($request->hasHeader('Transfer-Encoding')) { |
261
|
|
|
if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') { |
262
|
|
|
throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', Response::STATUS_NOT_IMPLEMENTED); |
263
|
|
|
} |
264
|
|
|
|
265
|
|
|
// Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time |
266
|
|
|
// as per https://tools.ietf.org/html/rfc7230#section-3.3.3 |
267
|
|
|
if ($request->hasHeader('Content-Length')) { |
268
|
|
|
throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', Response::STATUS_BAD_REQUEST); |
269
|
|
|
} |
270
|
|
|
} elseif ($request->hasHeader('Content-Length')) { |
271
|
|
|
$string = $request->getHeaderLine('Content-Length'); |
272
|
|
|
|
273
|
|
|
if ((string)(int)$string !== $string) { |
274
|
|
|
// Content-Length value is not an integer or not a single integer |
275
|
|
|
throw new \InvalidArgumentException('The value of `Content-Length` is not valid', Response::STATUS_BAD_REQUEST); |
276
|
|
|
} |
277
|
|
|
} |
278
|
|
|
|
279
|
|
|
return $request; |
280
|
|
|
} |
281
|
|
|
} |
282
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.