Skip to content

Commit b4f22ed

Browse files
author
Chekote
committed
Implement multipart/form-data support for PUT, DELETE and PATCH requests.
PHP, and by extension, Symfony does not support multipart/form-data requests when using any request method other than POST. This limits the ability to implement RESTful architectures using Symfony. This is a follow up to Pull Request symfony#849 I made a few years ago, and addresses concerns brought up by Saldaek at the time. I have implemented this functionality by manually decoding the php://input stream when the request type is PUT, DELETE or PATCH and the Content-Type header is mutlipart/form-data. The implementation is based on an example by [netcoder at stackoverflow](http://stackoverflow.com/a/9469615). This is necessary due to an underlying limitation of PHP, as discussed here: https://bugs.php.net/bug.php?id=55815. This fixes symfony#9226. __Security Concerns__ The main concern I had while implementing this feature, was working with PHP's assumptions as to what constituted an uploaded file. Since the files that this commit creates are not done so through PHP's usual uploaded file mechanisms, none of the uploaded file functions (such as is_uploaded_file, move_uploaded_file) will work with them. As such, I had to implement a mechanism for recording the uploaded files as they were created, so that Symfony's UploadedFile class could be sure that it was not moving non-uploaded files. To achieve this, I added a static array to the UploadedFile class called $files. Any uploaded files that are created outside of PHP's normal mechanism can be recorded here. UploadedFile can then check this list to ensure that if is_uploaded_file returns false, that the file being moved is in fact an uploaded file. This results in two changes to UploadedFile: Firstly, the is_uploaded_file check had to be expanded to also check if the file was recorded in UploadedFile::$files. Secondly, the move_uploaded_file needed to be changed to rename. The change of move_uploaded_file to rename might raise a few red flags, but I do not believe this is a problem. Before rename is called, UploadedFile calls isValid, which checks that the file either is_uploaded_file, or is a member of UploadedFile::$files. Therefore the use of move_uploaded_file is an unnecessary double check (even without these changes). Fix symfony#9226.
1 parent b3cf27a commit b4f22ed

File tree

2 files changed

+105
-8
lines changed

2 files changed

+105
-8
lines changed

src/Symfony/Component/HttpFoundation/File/UploadedFile.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
*/
2727
class UploadedFile extends File
2828
{
29+
30+
/**
31+
* List of files that were uploaded outside of PHP's standard POST multipart/form-data method.
32+
*
33+
* @var array
34+
*/
35+
public static $files = array();
36+
2937
/**
3038
* Whether the test mode is activated.
3139
*
@@ -218,7 +226,7 @@ public function isValid()
218226
{
219227
$isOk = $this->error === UPLOAD_ERR_OK;
220228

221-
return $this->test ? $isOk : $isOk && is_uploaded_file($this->getPathname());
229+
return $this->test ? $isOk : $isOk && (is_uploaded_file($this->getPathname()) || in_array($this->getPathname(), self::$files));
222230
}
223231

224232
/**
@@ -242,7 +250,7 @@ public function move($directory, $name = null)
242250

243251
$target = $this->getTargetFile($directory, $name);
244252

245-
if (!@move_uploaded_file($this->getPathname(), $target)) {
253+
if (!@rename($this->getPathname(), $target)) {
246254
$error = error_get_last();
247255
throw new FileException(sprintf('Could not move the file "%s" to "%s" (%s)', $this->getPathname(), $target, strip_tags($error['message'])));
248256
}

src/Symfony/Component/HttpFoundation/Request.php

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\HttpFoundation;
1313

14+
use Symfony\Component\HttpFoundation\File\UploadedFile;
1415
use Symfony\Component\HttpFoundation\Session\SessionInterface;
1516

1617
/**
@@ -252,7 +253,8 @@ public function initialize(array $query = array(), array $request = array(), arr
252253
}
253254

254255
/**
255-
* Creates a new request with values from PHP's super globals.
256+
* Creates a new request with values from PHP's super globals, or by analyzing php://input content if the
257+
* request method is PUT, DELETE or PATCH.
256258
*
257259
* @return Request A new request
258260
*
@@ -262,16 +264,103 @@ public static function createFromGlobals()
262264
{
263265
$request = new static($_GET, $_POST, array(), $_COOKIE, $_FILES, $_SERVER);
264266

265-
if (0 === strpos($request->headers->get('CONTENT_TYPE'), 'application/x-www-form-urlencoded')
266-
&& in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), array('PUT', 'DELETE', 'PATCH'))
267-
) {
268-
parse_str($request->getContent(), $data);
269-
$request->request = new ParameterBag($data);
267+
if (in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), array('PUT', 'DELETE', 'PATCH'))) {
268+
$contentType = $request->headers->get('CONTENT_TYPE');
269+
270+
if (0 === strpos($contentType, 'application/x-www-form-urlencoded')) {
271+
parse_str($request->getContent(), $data);
272+
$request->request = new ParameterBag($data);
273+
} else if (0 === strpos($contentType, 'multipart/form-data')) {
274+
$request->decodeMultiPartFormData();
275+
}
270276
}
271277

272278
return $request;
273279
}
274280

281+
/**
282+
* Decodes a multipart/form-data request, and injects the data into the request object. This method is used
283+
* whenever a multipart/form-data request is submitted with a PUT, DELETE or PATCH method.
284+
*
285+
* The files array is structred such that it mimics PHP's $_FILES superglobal, allowing it to be passed on
286+
* to the Request constructor.
287+
*
288+
* This implementation is based on an example by netcoder at http://stackoverflow.com/a/9469615.
289+
*
290+
* @param Request $request The Request object whos content should be decoded and places into it's files
291+
* and request properties.
292+
*/
293+
protected function decodeMultiPartFormData() {
294+
$files = array();
295+
$data = array();
296+
297+
// Fetch content and determine boundary
298+
$rawData = file_get_contents('php://input');
299+
$boundary = substr($rawData, 0, strpos($rawData, "\r\n"));
300+
301+
// Fetch and process each part
302+
$parts = array_slice(explode($boundary, $rawData), 1);
303+
foreach ($parts as $part) {
304+
// If this is the last part, break
305+
if ($part == "--\r\n") {
306+
break;
307+
}
308+
309+
// Separate content from headers
310+
$part = ltrim($part, "\r\n");
311+
list($rawHeaders, $content) = explode("\r\n\r\n", $part, 2);
312+
$content = substr($content, 0, strlen($content) - 2);
313+
314+
// Parse the headers list
315+
$rawHeaders = explode("\r\n", $rawHeaders);
316+
$headers = array();
317+
foreach ($rawHeaders as $header) {
318+
list($name, $value) = explode(':', $header);
319+
$headers[strtolower($name)] = ltrim($value, ' ');
320+
}
321+
322+
// Parse the Content-Disposition to get the field name, etc.
323+
if (isset($headers['content-disposition'])) {
324+
$filename = null;
325+
preg_match(
326+
'/^form-data; *name="([^"]+)"(; *filename="([^"]+)")?/',
327+
$headers['content-disposition'],
328+
$matches
329+
);
330+
331+
$fieldName = $matches[1];
332+
$fileName = (isset($matches[3]) ? $matches[3] : null);
333+
334+
// If we have a file, save it. Otherwise, save the data.
335+
if ($fileName !== null) {
336+
$localFileName = tempnam(sys_get_temp_dir(), 'sfy');
337+
file_put_contents($localFileName, $content);
338+
339+
$files[$fieldName] = array(
340+
'name' => $fileName,
341+
'type' => $headers['content-type'],
342+
'tmp_name' => $localFileName,
343+
'error' => 0,
344+
'size' => filesize($localFileName)
345+
);
346+
347+
// record the file path so that it can be verified as an uploaded file later
348+
UploadedFile::$files[] = $localFileName;
349+
350+
// register a shutdown function to cleanup the temporary file
351+
register_shutdown_function(function() {
352+
unlink($localFileName);
353+
});
354+
} else {
355+
$data[$fieldName] = $content;
356+
}
357+
}
358+
}
359+
360+
$this->request = new ParameterBag($data);
361+
$this->files = new FileBag($files);
362+
}
363+
275364
/**
276365
* Creates a Request based on a given URI and configuration.
277366
*

0 commit comments

Comments
 (0)