Browse Source

Add Middleware for parsing body data based on content type.

This adds a middleware based option for the automatic request body
parsing that RequestComponentHelper provides. A middleware option is
required to enable other middleware (like authentication) access to
JSON/XML based request payloads.
Mark Story 8 years ago
parent
commit
fb507ee3c3

+ 184 - 0
src/Http/Middleware/BodyParserMiddleware.php

@@ -0,0 +1,184 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http\Middleware;
+
+use Cake\Http\Exception\BadRequestException;
+use Cake\Utility\Exception\XmlException;
+use Cake\Utility\Xml;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Parse encoded request body data.
+ *
+ * Enables JSON and XML request payloads to be parsed into the request's
+ * Provides CSRF protection & validation.
+ *
+ * You can also add your own request body parsers using the `addParser()` method.
+ */
+class BodyParserMiddleware
+{
+    /**
+     * Registered Parsers
+     *
+     * @var array
+     */
+    protected $parsers = [];
+
+    /**
+     * The HTTP methods to parse data on.
+     *
+     * @var array
+     */
+    protected $methods = ['PUT', 'POST', 'PATCH', 'DELETE'];
+
+    /**
+     * Constructor
+     *
+     * ### Options
+     *
+     * - `json` Set to false to disable json body parsing.
+     * - `xml` Set to true to enable XML parsing. Defaults to false, as XML
+     *   handling requires more care than JSON does.
+     * - `methods` The HTTP methods to parse on. Defaults to PUT, POST, PATCH DELETE.
+     *
+     * @param array $options The options to use. See above.
+     */
+    public function __construct(array $options = [])
+    {
+        $options += ['json' => true, 'xml' => false, 'methods' => null];
+        if ($options['json']) {
+            $this->addParser(
+                ['application/json', 'text/json'],
+                [$this, 'decodeJson']
+            );
+        }
+        if ($options['xml']) {
+            $this->addParser(
+                ['application/xml', 'text/xml'],
+                [$this, 'decodeXml']
+            );
+        }
+        if ($options['methods']) {
+            $this->setMethods($options['methods']);
+        }
+    }
+
+    /**
+     * Set the HTTP methods to parse request bodies on.
+     *
+     * @param array $methods The methods to parse data on.
+     * @return $this
+     */
+    public function setMethods(array $methods)
+    {
+        $this->methods = $methods;
+
+        return $this;
+    }
+
+    /**
+     * Add a parser.
+     *
+     * Map a set of content-type header values to be parsed by the $parser.
+     *
+     * ### Example
+     *
+     * An naive CSV request body parser could be built like so:
+     *
+     * ```
+     * $parser->addParser(['text/csv'], function ($body) {
+     *   return str_getcsv($body);
+     * });
+     * ```
+     *
+     * @param array $types An array of content-type header values to match. eg. application/json
+     * @param callable $parser The parser function. Must return an array of data to be inserted
+     *   into the request.
+     * @return $this
+     */
+    public function addParser(array $types, callable $parser)
+    {
+        foreach ($types as $type) {
+            $type = strtolower($type);
+            $this->parsers[$type] = $parser;
+        }
+
+        return $this;
+    }
+
+    /**
+     * Apply the middleware.
+     *
+     * Will modify the request adding a parsed body if the content-type is known.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
+     * @param \Psr\Http\Message\ResponseInterface $response The response.
+     * @param callable $next Callback to invoke the next middleware.
+     * @return \Cake\Http\Response A response
+     */
+    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next)
+    {
+        if (!in_array($request->getMethod(), $this->methods)) {
+            return $next($request, $response);
+        }
+        list($type) = explode(';', $request->getHeaderLine('Content-Type'));
+        $type = strtolower($type);
+        if (!isset($this->parsers[$type])) {
+            return $next($request, $response);
+        }
+
+        $parser = $this->parsers[$type];
+        $result = $parser($request->getBody()->getContents());
+        if (!is_array($result)) {
+            throw new BadRequestException();
+        }
+        $request = $request->withParsedBody($result);
+
+        return $next($request, $response);
+    }
+
+    /**
+     * Decode JSON into an array.
+     *
+     * @param string $body The request body to decode
+     * @return array
+     */
+    protected function decodeJson($body)
+    {
+        return json_decode($body, true);
+    }
+
+    /**
+     * Decode XML into an array.
+     *
+     * @param string $body The request body to decode
+     * @return array
+     */
+    protected function decodeXml($body)
+    {
+        try {
+            $xml = Xml::build($body, ['return' => 'domdocument', 'readFile' => false]);
+            // We might not get child nodes if there are nested inline entities.
+            if ($xml->childNodes->length > 0) {
+                return Xml::toArray($xml);
+            }
+
+            return [];
+        } catch (XmlException $e) {
+            return [];
+        }
+    }
+}

+ 345 - 0
tests/TestCase/Http/Middleware/BodyParserMiddlewareTest.php

@@ -0,0 +1,345 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.5.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Http\Middleware;
+
+use Cake\Http\Exception\BadRequestException;
+use Cake\Http\Middleware\BodyParserMiddleware;
+use Cake\Http\Response;
+use Cake\Http\ServerRequest;
+use Cake\TestSuite\TestCase;
+
+/**
+ * Test for BodyParser
+ */
+class BodyParserMiddlewareTest extends TestCase
+{
+
+    /**
+     * Data provider for HTTP method tests.
+     *
+     * HEAD and GET do not populate $_POST or request->data.
+     *
+     * @return array
+     */
+    public static function safeHttpMethodProvider()
+    {
+        return [
+            ['GET'],
+            ['HEAD'],
+        ];
+    }
+
+    /**
+     * Data provider for HTTP methods that can contain request bodies.
+     *
+     * @return array
+     */
+    public static function httpMethodProvider()
+    {
+        return [
+            ['PATCH'], ['PUT'], ['POST'], ['DELETE']
+        ];
+    }
+
+    /**
+     * test constructor options
+     *
+     * @return void
+     */
+    public function testConstructorMethodsOption()
+    {
+        $parser = new BodyParserMiddleware(['methods' => ['PUT']]);
+        $this->assertAttributeEquals(['PUT'], 'methods', $parser);
+    }
+
+    /**
+     * test constructor options
+     *
+     * @return void
+     */
+    public function testConstructorXmlOption()
+    {
+        $parser = new BodyParserMiddleware(['json' => false]);
+        $this->assertAttributeEquals([], 'parsers', $parser, 'Xml off by default');
+
+        $parser = new BodyParserMiddleware(['json' => false, 'xml' => false]);
+        $this->assertAttributeEquals([], 'parsers', $parser, 'No Xml types set.');
+
+        $parser = new BodyParserMiddleware(['json' => false, 'xml' => true]);
+        $expected = [
+            'application/xml' => [$parser, 'decodeXml'],
+            'text/xml' => [$parser, 'decodeXml'],
+        ];
+        $this->assertAttributeEquals($expected, 'parsers', $parser, 'Xml types are incorrect.');
+    }
+
+    /**
+     * test constructor options
+     *
+     * @return void
+     */
+    public function testConstructorJsonOption()
+    {
+        $parser = new BodyParserMiddleware(['json' => false]);
+        $this->assertAttributeEquals([], 'parsers', $parser, 'No JSON types set.');
+
+        $parser = new BodyParserMiddleware([]);
+        $expected = [
+            'application/json' => [$parser, 'decodeJson'],
+            'text/json' => [$parser, 'decodeJson'],
+        ];
+        $this->assertAttributeEquals($expected, 'parsers', $parser, 'JSON types are incorrect.');
+    }
+
+    /**
+     * test setMethods()
+     *
+     * @return void
+     */
+    public function testSetMethodsReturn()
+    {
+        $parser = new BodyParserMiddleware();
+        $this->assertSame($parser, $parser->setMethods(['PUT']));
+        $this->assertAttributeEquals(['PUT'], 'methods', $parser);
+    }
+
+    /**
+     * test addParser()
+     *
+     * @return void
+     */
+    public function testAddParserReturn()
+    {
+        $parser = new BodyParserMiddleware(['json' => false]);
+        $this->assertSame($parser, $parser->addParser(['application/json'], 'json_decode'));
+    }
+
+    /**
+     * test last parser defined wins
+     *
+     * @return void
+     */
+    public function testAddParserOverwrite()
+    {
+        $parser = new BodyParserMiddleware(['json' => false]);
+        $parser->addParser(['application/json'], 'json_decode');
+        $parser->addParser(['application/json'], 'strpos');
+
+        $this->assertAttributeEquals(['application/json' => 'strpos'], 'parsers', $parser);
+    }
+    /**
+     * test parsing on valid http method
+     *
+     * @dataProvider httpMethodProvider
+     * @return void
+     */
+    public function testInvokeCaseInsensitiveContentType($method)
+    {
+        $parser = new BodyParserMiddleware();
+
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method,
+                'CONTENT_TYPE' => 'ApPlIcAtIoN/JSoN',
+            ],
+            'input' => '{"title": "yay"}'
+        ]);
+        $response = new Response();
+        $next = function ($req, $res) {
+            $this->assertEquals(['title' => 'yay'], $req->getParsedBody());
+        };
+        $parser($request, $response, $next);
+    }
+
+    /**
+     * test parsing on valid http method
+     *
+     * @dataProvider httpMethodProvider
+     * @return void
+     */
+    public function testInvokeParse($method)
+    {
+        $parser = new BodyParserMiddleware();
+
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method,
+                'CONTENT_TYPE' => 'application/json',
+            ],
+            'input' => '{"title": "yay"}'
+        ]);
+        $response = new Response();
+        $next = function ($req, $res) {
+            $this->assertEquals(['title' => 'yay'], $req->getParsedBody());
+        };
+        $parser($request, $response, $next);
+    }
+
+    /**
+     * test parsing on ignored http method
+     *
+     * @dataProvider safeHttpMethodProvider
+     * @return void
+     */
+    public function testInvokeNoParseOnSafe($method)
+    {
+        $parser = new BodyParserMiddleware();
+
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => $method,
+                'CONTENT_TYPE' => 'application/json',
+            ],
+            'input' => '{"title": "yay"}'
+        ]);
+        $response = new Response();
+        $next = function ($req, $res) {
+            $this->assertEquals([], $req->getParsedBody());
+        };
+        $parser($request, $response, $next);
+    }
+
+    /**
+     * test parsing XML bodies.
+     *
+     * @return void
+     */
+    public function testInvokeXml()
+    {
+        $xml = <<<XML
+<?xml version="1.0" encoding="utf-8"?>
+<article>
+    <title>yay</title>
+</article>
+XML;
+
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => 'POST',
+                'CONTENT_TYPE' => 'application/xml',
+            ],
+            'input' => $xml
+        ]);
+        $response = new Response();
+        $next = function ($req, $res) {
+            $expected = [
+                'article' => ['title' => 'yay']
+            ];
+            $this->assertEquals($expected, $req->getParsedBody());
+        };
+
+        $parser = new BodyParserMiddleware(['xml' => true]);
+        $parser($request, $response, $next);
+    }
+
+    /**
+     * Test that CDATA is removed in XML data.
+     *
+     * @return void
+     */
+    public function testInvokeXmlCdata()
+    {
+        $xml = <<<XML
+<?xml version="1.0" encoding="utf-8"?>
+<article>
+    <id>1</id>
+    <title><![CDATA[first]]></title>
+</article>
+XML;
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => 'POST',
+                'CONTENT_TYPE' => 'application/xml',
+            ],
+            'input' => $xml
+        ]);
+        $response = new Response();
+        $next = function ($req, $res) {
+            $expected = [
+                'article' => [
+                    'id' => 1,
+                    'title' => 'first'
+                ]
+            ];
+            $this->assertEquals($expected, $req->getParsedBody());
+        };
+
+        $parser = new BodyParserMiddleware(['xml' => true]);
+        $parser($request, $response, $next);
+    }
+
+    /**
+     * Test that internal entity recursion is ignored.
+     *
+     * @return void
+     */
+    public function testInvokeXmlInternalEntities()
+    {
+        $xml = <<<XML
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE item [
+  <!ENTITY item "item">
+  <!ENTITY item1 "&item;&item;&item;&item;&item;&item;">
+  <!ENTITY item2 "&item1;&item1;&item1;&item1;&item1;&item1;&item1;&item1;&item1;">
+  <!ENTITY item3 "&item2;&item2;&item2;&item2;&item2;&item2;&item2;&item2;&item2;">
+  <!ENTITY item4 "&item3;&item3;&item3;&item3;&item3;&item3;&item3;&item3;&item3;">
+  <!ENTITY item5 "&item4;&item4;&item4;&item4;&item4;&item4;&item4;&item4;&item4;">
+  <!ENTITY item6 "&item5;&item5;&item5;&item5;&item5;&item5;&item5;&item5;&item5;">
+  <!ENTITY item7 "&item6;&item6;&item6;&item6;&item6;&item6;&item6;&item6;&item6;">
+  <!ENTITY item8 "&item7;&item7;&item7;&item7;&item7;&item7;&item7;&item7;&item7;">
+]>
+<item>
+  <description>&item8;</description>
+</item>
+XML;
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => 'POST',
+                'CONTENT_TYPE' => 'application/xml',
+            ],
+            'input' => $xml
+        ]);
+        $response = new Response();
+        $next = function ($req, $res) {
+            $this->assertEquals([], $req->getParsedBody());
+        };
+
+        $parser = new BodyParserMiddleware(['xml' => true]);
+        $parser($request, $response, $next);
+    }
+
+    /**
+     * test parsing fails will raise a bad request.
+     *
+     * @return void
+     */
+    public function testInvokeParseNoArray()
+    {
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => 'POST',
+                'CONTENT_TYPE' => 'application/json',
+            ],
+            'input' => 'lol'
+        ]);
+        $response = new Response();
+        $next = function ($req, $res) {
+        };
+
+        $this->expectException(BadRequestException::class);
+        $parser = new BodyParserMiddleware();
+        $parser($request, $response, $next);
+    }
+}