Skip to content

Commit 2db0c91

Browse files
authored
Merge pull request #267 from clue-labs/multipart-gotchas
Fix handling invalid or incomplete multipart parts
2 parents d660095 + cd5d3c8 commit 2db0c91

3 files changed

Lines changed: 308 additions & 141 deletions

File tree

src/Io/MultipartParser.php

Lines changed: 75 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,16 @@
1212
* that resembles PHP's `$_POST` and `$_FILES` superglobals.
1313
*
1414
* @internal
15+
* @link https://tools.ietf.org/html/rfc7578
16+
* @link https://tools.ietf.org/html/rfc2046#section-5.1.1
1517
*/
1618
final class MultipartParser
1719
{
18-
/**
19-
* @var string
20-
*/
21-
protected $buffer = '';
22-
23-
/**
24-
* @var string
25-
*/
26-
protected $boundary;
27-
2820
/**
2921
* @var ServerRequestInterface
3022
*/
3123
protected $request;
3224

33-
/**
34-
* @var HttpBodyStream
35-
*/
36-
protected $body;
37-
38-
/**
39-
* @var callable
40-
*/
41-
protected $onDataCallable;
42-
4325
/**
4426
* @var int|null
4527
*/
@@ -58,138 +40,127 @@ private function __construct(ServerRequestInterface $request)
5840

5941
private function parse()
6042
{
61-
$this->buffer = (string)$this->request->getBody();
62-
63-
$this->determineStartMethod();
64-
65-
return $this->request;
66-
}
67-
68-
private function determineStartMethod()
69-
{
70-
if (!$this->request->hasHeader('content-type')) {
71-
$this->findBoundary();
72-
return;
73-
}
74-
7543
$contentType = $this->request->getHeaderLine('content-type');
76-
preg_match('/boundary="?(.*)"?$/', $contentType, $matches);
77-
if (isset($matches[1])) {
78-
$this->boundary = $matches[1];
79-
$this->parseBuffer();
80-
return;
44+
if(!preg_match('/boundary="?(.*)"?$/', $contentType, $matches)) {
45+
return $this->request;
8146
}
8247

83-
$this->findBoundary();
84-
}
48+
$this->parseBody('--' . $matches[1], (string)$this->request->getBody());
8549

86-
private function findBoundary()
87-
{
88-
if (substr($this->buffer, 0, 3) === '---' && strpos($this->buffer, "\r\n") !== false) {
89-
$boundary = substr($this->buffer, 2, strpos($this->buffer, "\r\n"));
90-
$boundary = substr($boundary, 0, -2);
91-
$this->boundary = $boundary;
92-
$this->parseBuffer();
93-
}
50+
return $this->request;
9451
}
9552

96-
private function parseBuffer()
53+
private function parseBody($boundary, $buffer)
9754
{
98-
$chunks = explode('--' . $this->boundary, $this->buffer);
99-
$this->buffer = array_pop($chunks);
100-
foreach ($chunks as $chunk) {
101-
$chunk = $this->stripTrailingEOL($chunk);
102-
$this->parseChunk($chunk);
55+
$len = strlen($boundary);
56+
57+
// ignore everything before initial boundary (SHOULD be empty)
58+
$start = strpos($buffer, $boundary . "\r\n");
59+
60+
while ($start !== false) {
61+
// search following boundary (preceded by newline)
62+
// ignore last if not followed by boundary (SHOULD end with "--")
63+
$start += $len + 2;
64+
$end = strpos($buffer, "\r\n" . $boundary, $start);
65+
if ($end === false) {
66+
break;
67+
}
68+
69+
// parse one part and continue searching for next
70+
$this->parsePart(substr($buffer, $start, $end - $start));
71+
$start = $end;
10372
}
10473
}
10574

106-
private function parseChunk($chunk)
75+
private function parsePart($chunk)
10776
{
108-
if ($chunk === '') {
77+
$pos = strpos($chunk, "\r\n\r\n");
78+
if ($pos === false) {
10979
return;
11080
}
11181

112-
list ($header, $body) = explode("\r\n\r\n", $chunk, 2);
113-
$headers = $this->parseHeaders($header);
82+
$headers = $this->parseHeaders((string)substr($chunk, 0, $pos));
83+
$body = (string)substr($chunk, $pos + 4);
11484

11585
if (!isset($headers['content-disposition'])) {
11686
return;
11787
}
11888

119-
if (!$this->headerContainsParameter($headers['content-disposition'], 'name')) {
89+
$name = $this->getParameterFromHeader($headers['content-disposition'], 'name');
90+
if ($name === null) {
12091
return;
12192
}
12293

123-
if ($this->headerContainsParameter($headers['content-disposition'], 'filename')) {
124-
$this->parseFile($headers, $body);
94+
$filename = $this->getParameterFromHeader($headers['content-disposition'], 'filename');
95+
if ($filename !== null) {
96+
$this->parseFile(
97+
$name,
98+
$filename,
99+
isset($headers['content-type'][0]) ? $headers['content-type'][0] : null,
100+
$body
101+
);
125102
} else {
126-
$this->parsePost($headers, $body);
103+
$this->parsePost($name, $body);
127104
}
128105
}
129106

130-
private function parseFile($headers, $body)
107+
private function parseFile($name, $filename, $contentType, $contents)
131108
{
132109
$this->request = $this->request->withUploadedFiles($this->extractPost(
133110
$this->request->getUploadedFiles(),
134-
$this->getParameterFromHeader($headers['content-disposition'], 'name'),
135-
$this->parseUploadedFile($headers, $body)
111+
$name,
112+
$this->parseUploadedFile($filename, $contentType, $contents)
136113
));
137114
}
138115

139-
private function parseUploadedFile($headers, $body)
116+
private function parseUploadedFile($filename, $contentType, $contents)
140117
{
141-
$filename = $this->getParameterFromHeader($headers['content-disposition'], 'filename');
142-
$bodyLength = strlen($body);
118+
$size = strlen($contents);
143119

144120
// no file selected (zero size and empty filename)
145-
if ($bodyLength === 0 && $filename === '') {
121+
if ($size === 0 && $filename === '') {
146122
return new UploadedFile(
147123
Psr7\stream_for(''),
148-
$bodyLength,
124+
$size,
149125
UPLOAD_ERR_NO_FILE,
150126
$filename,
151-
$headers['content-type'][0]
127+
$contentType
152128
);
153129
}
154130

155131
// file exceeds MAX_FILE_SIZE value
156-
if ($this->maxFileSize !== null && $bodyLength > $this->maxFileSize) {
132+
if ($this->maxFileSize !== null && $size > $this->maxFileSize) {
157133
return new UploadedFile(
158134
Psr7\stream_for(''),
159-
$bodyLength,
135+
$size,
160136
UPLOAD_ERR_FORM_SIZE,
161137
$filename,
162-
$headers['content-type'][0]
138+
$contentType
163139
);
164140
}
165141

166142
return new UploadedFile(
167-
Psr7\stream_for($body),
168-
$bodyLength,
143+
Psr7\stream_for($contents),
144+
$size,
169145
UPLOAD_ERR_OK,
170146
$filename,
171-
$headers['content-type'][0]
147+
$contentType
172148
);
173149
}
174150

175-
private function parsePost($headers, $body)
151+
private function parsePost($name, $value)
176152
{
177-
foreach ($headers['content-disposition'] as $part) {
178-
if (strpos($part, 'name') === 0) {
179-
preg_match('/name="?(.*)"$/', $part, $matches);
180-
$this->request = $this->request->withParsedBody($this->extractPost(
181-
$this->request->getParsedBody(),
182-
$matches[1],
183-
$body
184-
));
185-
186-
if (strtoupper($matches[1]) === 'MAX_FILE_SIZE') {
187-
$this->maxFileSize = (int)$body;
188-
189-
if ($this->maxFileSize === 0) {
190-
$this->maxFileSize = null;
191-
}
192-
}
153+
$this->request = $this->request->withParsedBody($this->extractPost(
154+
$this->request->getParsedBody(),
155+
$name,
156+
$value
157+
));
158+
159+
if (strtoupper($name) === 'MAX_FILE_SIZE') {
160+
$this->maxFileSize = (int)$value;
161+
162+
if ($this->maxFileSize === 0) {
163+
$this->maxFileSize = null;
193164
}
194165
}
195166
}
@@ -199,47 +170,29 @@ private function parseHeaders($header)
199170
$headers = array();
200171

201172
foreach (explode("\r\n", trim($header)) as $line) {
202-
list($key, $values) = explode(':', $line, 2);
203-
$key = trim($key);
204-
$key = strtolower($key);
205-
$values = explode(';', $values);
173+
$parts = explode(':', $line, 2);
174+
if (!isset($parts[1])) {
175+
continue;
176+
}
177+
178+
$key = strtolower(trim($parts[0]));
179+
$values = explode(';', $parts[1]);
206180
$values = array_map('trim', $values);
207181
$headers[$key] = $values;
208182
}
209183

210184
return $headers;
211185
}
212186

213-
private function headerContainsParameter(array $header, $parameter)
214-
{
215-
foreach ($header as $part) {
216-
if (strpos($part, $parameter . '=') === 0) {
217-
return true;
218-
}
219-
}
220-
221-
return false;
222-
}
223-
224187
private function getParameterFromHeader(array $header, $parameter)
225188
{
226189
foreach ($header as $part) {
227-
if (strpos($part, $parameter) === 0) {
228-
preg_match('/' . $parameter . '="?(.*)"$/', $part, $matches);
190+
if (preg_match('/' . $parameter . '="?(.*)"$/', $part, $matches)) {
229191
return $matches[1];
230192
}
231193
}
232194

233-
return '';
234-
}
235-
236-
private function stripTrailingEOL($chunk)
237-
{
238-
if (substr($chunk, -2) === "\r\n") {
239-
return substr($chunk, 0, -2);
240-
}
241-
242-
return $chunk;
195+
return null;
243196
}
244197

245198
private function extractPost($postFields, $key, $value)

0 commit comments

Comments
 (0)