├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── examples ├── 01-server-with-cors.php └── 02-server-with-cors-strict-checks.php ├── phpunit.xml.dist ├── src ├── CorsMiddleware.php ├── CorsMiddlewareAnalysisStrategy.php └── CorsMiddlewareConfiguration.php └── tests ├── CorsMiddlewareTest.php └── bootstrap.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | 4 | # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file 5 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 6 | composer.lock 7 | 8 | .idea 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | ## Cache composer bits 4 | cache: 5 | directories: 6 | - $HOME/.composer/cache/files 7 | 8 | php: 9 | - '5.6' 10 | - '7.0' 11 | - '7.1' 12 | - '7.2' 13 | 14 | ## Install or update dependencies 15 | install: 16 | - composer validate 17 | - composer install --prefer-dist 18 | - composer show 19 | 20 | ## Run the actual test 21 | script: 22 | - composer unit 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Christoph Kluge 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReactPHP Cors Middleware 2 | 3 | This middleware implements [Cross-origin resource sharing](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) for ReactPHP. This repository got mainly inspired by [tuupola/cors-middleware](https://github.com/tuupola/cors-middleware). 4 | Additional configuration ideas got taken from [barryvdh/laravel-cors](https://github.com/barryvdh/laravel-cors) and [nelmio/NelmioCorsBundle](https://github.com/nelmio/NelmioCorsBundle). The internal heavy lifting is done by [neomerx/cors-psr7](https://github.com/neomerx/cors-psr7) library. 5 | 6 | [![Build Status](https://travis-ci.org/christoph-kluge/reactphp-http-cors-middleware.svg?branch=master)](https://travis-ci.org/christoph-kluge/reactphp-http-cors-middleware) 7 | [![Total Downloads](https://poser.pugx.org/christoph-kluge/reactphp-http-cors-middleware/downloads)](https://packagist.org/packages/christoph-kluge/reactphp-http-cors-middleware) 8 | [![License](https://poser.pugx.org/christoph-kluge/reactphp-http-cors-middleware/license)](https://packagist.org/packages/christoph-kluge/reactphp-http-cors-middleware) 9 | 10 | # Install 11 | 12 | To install via [Composer](http://getcomposer.org/), use the command below, it will automatically detect the latest version and bind it with `^`. 13 | 14 | ``` 15 | composer require christoph-kluge/reactphp-http-cors-middleware 16 | ``` 17 | 18 | This middleware will detect CORS requests and will intercept the request if there is something invalid. 19 | 20 | # Usage 21 | 22 | ```php 23 | $server = new HttpServer( 24 | new CorsMiddleware(), 25 | function (ServerRequestInterface $request, callable $next) { 26 | return new Response(200, ['Content-Type' => 'text/html'], 'We test CORS'); 27 | }, 28 | ); 29 | ``` 30 | 31 | # Configuration 32 | 33 | The defaults for this middleware are mainly taken from [enable-cors.org](https://enable-cors.org). 34 | 35 | ## Available configuration options 36 | 37 | Thanks to [expressjs/cors#configuring-cors](https://github.com/expressjs/cors#configuring-cors). As I took most configuration descriptions from there. 38 | 39 | * `server_url`: can be used to set enable strict `Host` header checks to avoid malicious use of our server. (default: `null`) 40 | * `response_code`: can be used to set the HTTP-StatusCode on a successful `OPTIONS` / Pre-Flight-Request (default: `204`) 41 | * `allow_credentials`: Configures the `Access-Control-Allow-Credentials` CORS header. Expects an boolean (ex: `true` // to set the header) 42 | * `allow_origin`: Configures the `Access-Control-Allow-Origin` CORS header. Expects an array (ex: `['http://example.net', 'https://example.net']`). 43 | * `allow_origin_callback`: Will set `allow_origin` to an empty array `[]` and use the callback on a per-request base. The first parameter is an instance of `ParsedUrlInterface` and the callback is expected to return an `boolean`. 44 | * `allow_methods`: Configures the `Access-Control-Allow-Methods` CORS header. Expects an array (ex: `['GET', 'PUT', 'POST']`). 45 | * `allow_headers`: Configures the `Access-Control-Allow-Headers` CORS header. Expects an array (ex: `['Content-Type', 'Authorization']`). 46 | * `expose_headers`: Configures the `Access-Control-Expose-Headers` CORS header. Expects an array (ex: `['Content-Range', 'X-Content-Range']`). 47 | * `max_age`: Configures the `Access-Control-Max-Age` CORS header. Expects an integer representing seconds (ex: `1728000` // 20 days) 48 | 49 | ## Default Settings (Allow All CORS Requests) 50 | 51 | ```php 52 | $settings = [ 53 | 'allow_credentials' => true, 54 | 'allow_origin' => ['*'], 55 | 'allow_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'], 56 | 'allow_headers' => ['DNT','X-Custom-Header','Keep-Alive','User-Agent','X-Requested-With','If-Modified-Since','Cache-Control','Content-Type','Content-Range','Range'], 57 | 'expose_headers' => ['DNT','X-Custom-Header','Keep-Alive','User-Agent','X-Requested-With','If-Modified-Since','Cache-Control','Content-Type','Content-Range','Range'], 58 | 'max_age' => 60 * 60 * 24 * 20, // preflight request is valid for 20 days 59 | ]; 60 | ``` 61 | 62 | ## Allow specific origins (Origin requires scheme, host and optionally port) 63 | 64 | ```php 65 | $server = new HttpServer( 66 | new CorsMiddleware([ 67 | 'allow_origin' => [ 68 | 'http://www.example.net', 69 | 'https://www.example.net', 70 | 'http://www.example.net:8443', 71 | ], 72 | ]) 73 | ); 74 | ``` 75 | 76 | ## Allow origins on a per-request base (callback) 77 | 78 | ```php 79 | $server = new HttpServer( 80 | new CorsMiddleware([ 81 | 'allow_origin' => [], 82 | 'allow_origin_callback' => function(ParsedUrlInterface $origin) { 83 | // do some evaluation magic with origin .. 84 | return true; 85 | }, 86 | ]) 87 | ); 88 | ``` 89 | 90 | ## Use custom response code on pre-flight requests 91 | 92 | Some legacy browsers choke on 204. Thanks to [expressjs/cors#configuring-cors](https://github.com/expressjs/cors#configuring-cors) for that. 93 | 94 | ```php 95 | $server = new HttpServer( 96 | new CorsMiddleware([ 97 | 'response_code' => 200, 98 | ]) 99 | ); 100 | ``` 101 | 102 | ## Use strict host checking 103 | 104 | The default handling of this middleware will allow any "Host"-header. This means that you can use your server with 105 | any hostname you want. This might be a desired behavior but allows also the misuse of your server. 106 | 107 | To prevent such a behavior there is a `server_url` option which will enable strict host checking. In this scenario 108 | the server will return a `403` with the body `Origin not allowed`. 109 | 110 | ```php 111 | $server = new HttpServer( 112 | new CorsMiddleware([ 113 | 'server_url' => 'http://api.example.net:8080' 114 | ]) 115 | ); 116 | ``` 117 | 118 | # License 119 | 120 | The MIT License (MIT) 121 | 122 | Copyright (c) 2017 Christoph Kluge 123 | 124 | Permission is hereby granted, free of charge, to any person obtaining a copy 125 | of this software and associated documentation files (the "Software"), to deal 126 | in the Software without restriction, including without limitation the rights 127 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 128 | copies of the Software, and to permit persons to whom the Software is 129 | furnished to do so, subject to the following conditions: 130 | 131 | The above copyright notice and this permission notice shall be included in all 132 | copies or substantial portions of the Software. 133 | 134 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 135 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 136 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 137 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 138 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 139 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 140 | SOFTWARE. 141 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "christoph-kluge/reactphp-http-cors-middleware", 3 | "description": "A cors middleware for the ReactPHP HTTP-Server", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Christoph Kluge", 8 | "email": "work@christoph-kluge.eu" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Sikei\\React\\Http\\Middleware\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Sikei\\React\\Tests\\Http\\Middleware\\": "tests/" 19 | } 20 | }, 21 | "require": { 22 | "php": ">=5.6.0", 23 | "psr/http-message": "^1.0", 24 | "react/http": "^1.0.0", 25 | "react/promise": "^2.5", 26 | "neomerx/cors-psr7": "^1.0" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^4.8.10||^5.0", 30 | "ringcentral/psr7": "^1.2" 31 | }, 32 | "scripts": { 33 | "ensure-installed": "composer install --ansi -n -q", 34 | "unit": [ 35 | "@ensure-installed", 36 | "phpunit --colors=always -c phpunit.xml.dist" 37 | ] 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /examples/01-server-with-cors.php: -------------------------------------------------------------------------------- 1 | 'application/json'], json_encode([ 15 | 'some' => 'nice', 16 | 'json' => 'values', 17 | ])); 18 | } 19 | ); 20 | 21 | $socket = new SocketServer(isset($argv[1]) ? $argv[1] : '0.0.0.0:0'); 22 | $server->listen($socket); 23 | 24 | echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; 25 | -------------------------------------------------------------------------------- /examples/02-server-with-cors-strict-checks.php: -------------------------------------------------------------------------------- 1 | 'http://api.example.net:8080']), 13 | function (ServerRequestInterface $request) { 14 | return new Response(200, ['Content-Type' => 'application/json'], json_encode([ 15 | 'some' => 'nice', 16 | 'json' => 'values', 17 | ])); 18 | } 19 | ); 20 | 21 | $socket = new SocketServer(isset($argv[1]) ? $argv[1] : '0.0.0.0:8080'); 22 | $server->listen($socket); 23 | 24 | echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; 25 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./tests/ 17 | 18 | 19 | 20 | 21 | 22 | ./src/ 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/CorsMiddleware.php: -------------------------------------------------------------------------------- 1 | config = new Config($settings); 31 | $this->analyzer = Analyzer::instance(new Strategy($this->config)); 32 | } 33 | 34 | public function __invoke(ServerRequestInterface $request, $next) 35 | { 36 | $cors = $this->analyzer->analyze($request); 37 | switch ($cors->getRequestType()) { 38 | case AnalysisResultInterface::TYPE_REQUEST_OUT_OF_CORS_SCOPE: 39 | return $next($request); 40 | case AnalysisResultInterface::TYPE_PRE_FLIGHT_REQUEST: 41 | return new Response($this->config->getPreFlightResponseCode(), $cors->getResponseHeaders()); 42 | case AnalysisResultInterface::ERR_NO_HOST_HEADER: 43 | return new Response(400, [], 'No host header present'); 44 | case AnalysisResultInterface::ERR_HEADERS_NOT_SUPPORTED: 45 | return new Response(401, [], 'Headers not supported'); 46 | case AnalysisResultInterface::ERR_ORIGIN_NOT_ALLOWED: 47 | return new Response(403, [], 'Origin not allowed'); 48 | case AnalysisResultInterface::ERR_METHOD_NOT_SUPPORTED: 49 | return new Response(405, [], 'Method not supported'); 50 | } 51 | 52 | return resolve($next($request))->then(function (ResponseInterface $response) use ($cors) { 53 | foreach ($cors->getResponseHeaders() as $header => $value) { 54 | $response = $response->withHeader($header, $value); 55 | } 56 | return $response; 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/CorsMiddlewareAnalysisStrategy.php: -------------------------------------------------------------------------------- 1 | config = $config; 18 | 19 | $serverOrigin = $this->config->getServerOrigin(); 20 | if (!empty($serverOrigin)) { 21 | $this 22 | ->setCheckHost(true) 23 | ->setServerOrigin($serverOrigin); 24 | } 25 | 26 | $this 27 | ->setRequestCredentialsSupported($this->config->getRequestCredentialsSupported()) 28 | ->setRequestAllowedOrigins($this->config->getRequestAllowedOrigins()) 29 | ->setRequestAllowedMethods($this->config->getRequestAllowedMethods()) 30 | ->setRequestAllowedHeaders($this->config->getRequestAllowedHeaders()) 31 | ->setResponseExposedHeaders($this->config->getResponseExposedHeaders()) 32 | ->setPreFlightCacheMaxAge($this->config->getPreFlightCacheMaxAge()); 33 | } 34 | 35 | public function isRequestOriginAllowed(ParsedUrlInterface $requestOrigin) 36 | { 37 | if ($this->config->hasRequestAllowedOriginsCallback()) { 38 | $callback = $this->config->getRequestAllowedOriginsCallback(); 39 | 40 | $return = $callback($requestOrigin); 41 | if (is_bool($return)) { 42 | return $return; 43 | } 44 | return false; 45 | } 46 | 47 | return parent::isRequestOriginAllowed($requestOrigin); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/CorsMiddlewareConfiguration.php: -------------------------------------------------------------------------------- 1 | null, 10 | 'response_code' => 204, // Pre-Flight Status Code 11 | 'allow_credentials' => false, 12 | 'allow_origin' => [], 13 | 'allow_origin_callback' => null, 14 | 'allow_methods' => ['GET', 'POST', 'OPTIONS'], 15 | 'allow_headers' => [], 16 | 'expose_headers' => [], 17 | 'max_age' => 60 * 60 * 24 * 20, // preflight request is valid for 20 days 18 | ]; 19 | 20 | protected $serverOrigin = []; 21 | 22 | public function __construct(array $settings = []) 23 | { 24 | $this->settings = array_merge($this->settings, $settings); 25 | 26 | if (!is_null($this->settings['server_url'])) { 27 | $this->serverOrigin = parse_url($this->settings['server_url']); 28 | if (count(array_diff_key(['scheme' => '', 'host' => ''], $this->serverOrigin)) > 0) { 29 | throw new \InvalidArgumentException('Option "server_url" requires at least scheme and domain'); 30 | } 31 | } 32 | } 33 | 34 | public function getPreFlightResponseCode() 35 | { 36 | return (int)$this->settings['response_code']; 37 | } 38 | 39 | public function getServerOrigin() 40 | { 41 | return $this->serverOrigin; 42 | } 43 | 44 | public function getRequestCredentialsSupported() 45 | { 46 | return is_bool($this->settings['allow_credentials']) 47 | ? $this->settings['allow_credentials'] 48 | : false; 49 | } 50 | 51 | public function getRequestAllowedOrigins() 52 | { 53 | if (is_callable($this->settings['allow_origin'])) { 54 | return []; 55 | } 56 | 57 | if (is_string($this->settings['allow_origin']) && $this->settings['allow_origin'] == '*') { 58 | $this->settings['allow_origin'] = ['*']; 59 | } 60 | 61 | $origins = []; 62 | foreach ($this->settings['allow_origin'] as $origin) { 63 | $origins[$origin] = true; 64 | } 65 | 66 | return $origins; 67 | } 68 | 69 | public function hasRequestAllowedOriginsCallback() 70 | { 71 | return is_callable($this->settings['allow_origin_callback']); 72 | } 73 | 74 | public function getRequestAllowedOriginsCallback() 75 | { 76 | return $this->settings['allow_origin_callback']; 77 | } 78 | 79 | public function getRequestAllowedMethods() 80 | { 81 | $methods = []; 82 | foreach ($this->settings['allow_methods'] as $verb) { 83 | $methods[$verb] = true; 84 | } 85 | 86 | return $methods; 87 | } 88 | 89 | public function getPreFlightCacheMaxAge() 90 | { 91 | return (int)$this->settings['max_age']; 92 | } 93 | 94 | public function getRequestAllowedHeaders() 95 | { 96 | $headers = []; 97 | foreach ($this->settings['allow_headers'] as $header) { 98 | $headers[$header] = true; 99 | } 100 | 101 | return $headers; 102 | } 103 | 104 | public function getResponseExposedHeaders() 105 | { 106 | $headers = []; 107 | foreach ($this->settings['expose_headers'] as $header) { 108 | $headers[$header] = true; 109 | } 110 | 111 | return $headers; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/CorsMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | 'text/html'], 'Some response'); 20 | 21 | $middleware = new CorsMiddleware([ 22 | 'allow_origin' => '*', 23 | ]); 24 | 25 | /** @var PromiseInterface $result */ 26 | $result = $middleware($request, $this->getNextCallback($response)); 27 | $this->assertInstanceOf(Promise::class, $result); 28 | 29 | $result->then(function ($value) use (&$response) { 30 | $response = $value; 31 | }); 32 | $this->assertInstanceOf(Response::class, $response); 33 | } 34 | 35 | public function testNoHostHeaderResponse() 36 | { 37 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 38 | 'Origin' => 'https://www.example.net', 39 | 'Access-Control-Request-Method' => 'GET', 40 | 'Access-Control-Request-Headers' => 'Authorization', 41 | ]); 42 | $request = $request->withoutHeader('Host'); 43 | 44 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 45 | 46 | $middleware = new CorsMiddleware([ 47 | 'allow_origin' => '*', 48 | 'allow_methods' => ['GET'], 49 | ]); 50 | 51 | /** @var Response $result */ 52 | $response = $middleware($request, $this->getNextCallback($response)); 53 | $this->assertInstanceOf(Response::class, $response); 54 | $this->assertSame(401, $response->getStatusCode()); 55 | } 56 | 57 | public function testDefaultValuesShouldAllowRequest() 58 | { 59 | $request = new ServerRequest('GET', 'https://api.example.net/', [ 60 | 'Origin' => 'https://api.example.net/' 61 | ]); 62 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 63 | 64 | $middleware = new CorsMiddleware(['server_url' => 'https://api.example.net/']); 65 | 66 | /** @var PromiseInterface $promise */ 67 | $promise = $middleware($request, $this->getNextCallback($response)); 68 | $this->assertInstanceOf(Promise::class, $promise); 69 | $promise->then(function ($value) use (&$response) { 70 | $response = $value; 71 | }); 72 | $this->assertInstanceOf(Response::class, $response); 73 | $this->assertSame(200, $response->getStatusCode()); 74 | } 75 | 76 | public function testWrongHostShouldDenyRequest() 77 | { 78 | $request = new ServerRequest('GET', 'https://api.example.org/', [ 79 | 'Origin' => 'https://api.example.net/' 80 | ]); 81 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 82 | 83 | $middleware = new CorsMiddleware(['server_url' => 'https://api.example.net/']); 84 | 85 | /** @var PromiseInterface $promise */ 86 | $response = $middleware($request, $this->getNextCallback($response)); 87 | $this->assertInstanceOf(Response::class, $response); 88 | $this->assertSame(400, $response->getStatusCode()); 89 | } 90 | 91 | public function testDefaultValuesShouldDenyCrossOrigin() 92 | { 93 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 94 | 'Origin' => 'https://www.example.net', 95 | 'Access-Control-Request-Method' => 'GET', 96 | 'Access-Control-Request-Headers' => 'Authorization', 97 | ]); 98 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 99 | 100 | $middleware = new CorsMiddleware(); 101 | 102 | /** @var Response $response */ 103 | $response = $middleware($request, $this->getNextCallback($response)); 104 | $this->assertInstanceOf(Response::class, $response); 105 | $this->assertSame(403, $response->getStatusCode()); 106 | } 107 | 108 | public function testRequestInvalidRequestHeaders() 109 | { 110 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 111 | 'Origin' => 'https://www.example.net', 112 | 'Access-Control-Request-Method' => 'GET', 113 | 'Access-Control-Request-Headers' => 'Authorization', 114 | ]); 115 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 116 | 117 | $middleware = new CorsMiddleware([ 118 | 'allow_origin' => '*', 119 | 'allow_headers' => [], 120 | 'allow_methods' => ['GET', 'OPTIONS'], 121 | ]); 122 | 123 | /** @var Response $response */ 124 | $response = $middleware($request, $this->getNextCallback($response)); 125 | $this->assertInstanceOf(Response::class, $response); 126 | $this->assertSame(401, $response->getStatusCode()); 127 | } 128 | 129 | public function testRequestValidRequestHeaders() 130 | { 131 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 132 | 'Origin' => 'https://www.example.net', 133 | 'Access-Control-Request-Method' => 'GET', 134 | 'Access-Control-Request-Headers' => 'Authorization', 135 | ]); 136 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 137 | 138 | $middleware = new CorsMiddleware([ 139 | 'allow_origin' => '*', 140 | 'allow_headers' => ['Authorization'], 141 | 'allow_methods' => ['GET', 'OPTIONS'], 142 | ]); 143 | 144 | /** @var Response $response */ 145 | $response = $middleware($request, $this->getNextCallback($response)); 146 | $this->assertInstanceOf(Response::class, $response); 147 | $this->assertSame(204, $response->getStatusCode()); 148 | } 149 | 150 | public function testRequestInvalidMethods() 151 | { 152 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 153 | 'Origin' => 'https://www.example.net', 154 | 'Access-Control-Request-Method' => 'GET', 155 | ]); 156 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 157 | 158 | $middleware = new CorsMiddleware([ 159 | 'allow_origin' => '*', 160 | 'allow_methods' => [], 161 | ]); 162 | 163 | /** @var Response $response */ 164 | $response = $middleware($request, $this->getNextCallback($response)); 165 | $this->assertInstanceOf(Response::class, $response); 166 | $this->assertSame(405, $response->getStatusCode()); 167 | } 168 | 169 | public function testRequestValidMethods() 170 | { 171 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 172 | 'Origin' => 'https://www.example.net', 173 | 'Access-Control-Request-Method' => 'GET', 174 | ]); 175 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 176 | 177 | $middleware = new CorsMiddleware([ 178 | 'allow_origin' => '*', 179 | 'allow_methods' => ['GET', 'OPTIONS'], 180 | ]); 181 | 182 | /** @var Response $response */ 183 | $response = $middleware($request, $this->getNextCallback($response)); 184 | $this->assertInstanceOf(Response::class, $response); 185 | $this->assertSame(204, $response->getStatusCode()); 186 | } 187 | 188 | public function testRequestOriginByInvalidOrigin() 189 | { 190 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 191 | 'Origin' => 'https://www.example.net', 192 | 'Access-Control-Request-Method' => 'GET', 193 | ]); 194 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 195 | 196 | $middleware = new CorsMiddleware([ 197 | 'allow_origin' => [ 198 | 'https://invalid.example.net', 199 | ], 200 | 'allow_methods' => ['GET', 'OPTIONS'], 201 | ]); 202 | 203 | /** @var Response $response */ 204 | $response = $middleware($request, $this->getNextCallback($response)); 205 | $this->assertInstanceOf(Response::class, $response); 206 | $this->assertSame(403, $response->getStatusCode()); 207 | } 208 | 209 | public function testRequestOriginByValidOrigin() 210 | { 211 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 212 | 'Origin' => 'https://www.example.net', 213 | 'Access-Control-Request-Method' => 'GET', 214 | ]); 215 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 216 | 217 | $middleware = new CorsMiddleware([ 218 | 'allow_origin' => [ 219 | 'https://www.example.net', 220 | ], 221 | 'allow_methods' => ['GET', 'OPTIONS'], 222 | ]); 223 | 224 | /** @var Response $response */ 225 | $response = $middleware($request, $this->getNextCallback($response)); 226 | $this->assertInstanceOf(Response::class, $response); 227 | $this->assertSame(204, $response->getStatusCode()); 228 | } 229 | 230 | public function testRequestOriginByWildcard() 231 | { 232 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 233 | 'Origin' => 'https://www.example.net', 234 | 'Access-Control-Request-Method' => 'GET', 235 | ]); 236 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 237 | 238 | // -- test wildcard as string 239 | 240 | $middleware = new CorsMiddleware([ 241 | 'allow_origin' => '*', 242 | 'allow_methods' => ['GET', 'OPTIONS'], 243 | ]); 244 | 245 | /** @var Response $response */ 246 | $response = $middleware($request, $this->getNextCallback($response)); 247 | $this->assertInstanceOf(Response::class, $response); 248 | $this->assertSame(204, $response->getStatusCode()); 249 | 250 | // -- test wildcard as array 251 | 252 | $middleware = new CorsMiddleware([ 253 | 'allow_origin' => ['*'], 254 | 'allow_methods' => ['GET', 'OPTIONS'], 255 | ]); 256 | 257 | /** @var Response $response */ 258 | $response = $middleware($request, $this->getNextCallback($response)); 259 | $this->assertInstanceOf(Response::class, $response); 260 | $this->assertSame(204, $response->getStatusCode()); 261 | } 262 | 263 | public function testRequestOriginByPositiveCallback() 264 | { 265 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 266 | 'Origin' => 'https://www.example.net', 267 | 'Access-Control-Request-Method' => 'GET', 268 | ]); 269 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 270 | 271 | // -- test positive callback 272 | 273 | $middleware = new CorsMiddleware([ 274 | 'allow_origin' => [], 275 | 'allow_origin_callback' => function (ParsedUrlInterface $parsedUrl) { 276 | return true; 277 | }, 278 | 'allow_methods' => ['GET', 'OPTIONS'], 279 | ]); 280 | 281 | /** @var Response $response */ 282 | $response = $middleware($request, $this->getNextCallback($response)); 283 | $this->assertInstanceOf(Response::class, $response); 284 | $this->assertSame(204, $response->getStatusCode()); 285 | } 286 | 287 | public function testRequestOriginByNegativeCallback() 288 | { 289 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 290 | 'Origin' => 'https://www.example.net', 291 | 'Access-Control-Request-Method' => 'GET', 292 | ]); 293 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 294 | 295 | $middleware = new CorsMiddleware([ 296 | 'allow_origin' => [], 297 | 'allow_origin_callback' => function (ParsedUrlInterface $parsedUrl) { 298 | return false; 299 | }, 300 | 'allow_methods' => ['GET', 'OPTIONS'], 301 | ]); 302 | 303 | /** @var Response $response */ 304 | $response = $middleware($request, $this->getNextCallback($response)); 305 | $this->assertInstanceOf(Response::class, $response); 306 | $this->assertSame(403, $response->getStatusCode()); 307 | } 308 | 309 | public function testRequestOriginByInvalidCallbackReturn() 310 | { 311 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 312 | 'Origin' => 'https://www.example.net', 313 | 'Access-Control-Request-Method' => 'GET', 314 | ]); 315 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 316 | 317 | $middleware = new CorsMiddleware([ 318 | 'allow_origin' => [], 319 | 'allow_origin_callback' => function (ParsedUrlInterface $parsedUrl) { 320 | return null; 321 | }, 322 | 'allow_methods' => ['GET', 'OPTIONS'], 323 | ]); 324 | 325 | /** @var Response $response */ 326 | $response = $middleware($request, $this->getNextCallback($response)); 327 | $this->assertInstanceOf(Response::class, $response); 328 | $this->assertSame(403, $response->getStatusCode()); 329 | } 330 | 331 | public function testRequestCustomPreflightMaxAge() 332 | { 333 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 334 | 'Origin' => 'https://www.example.net', 335 | 'Access-Control-Request-Method' => 'GET', 336 | ]); 337 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 338 | 339 | $middleware = new CorsMiddleware([ 340 | 'allow_origin' => '*', 341 | 'allow_methods' => ['GET', 'OPTIONS'], 342 | 'max_age' => 3600, 343 | ]); 344 | 345 | /** @var Response $response */ 346 | $response = $middleware($request, $this->getNextCallback($response)); 347 | $this->assertInstanceOf(Response::class, $response); 348 | $this->assertSame(204, $response->getStatusCode()); 349 | $this->assertTrue($response->hasHeader('Access-Control-Max-Age')); 350 | $this->assertSame((string)3600, $response->getHeaderLine('Access-Control-Max-Age')); 351 | } 352 | 353 | public function testRequestCredentialsAllowed() 354 | { 355 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 356 | 'Origin' => 'https://www.example.net', 357 | 'Access-Control-Request-Method' => 'GET', 358 | ]); 359 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 360 | 361 | $middleware = new CorsMiddleware([ 362 | 'allow_credentials' => true, 363 | 'allow_origin' => '*', 364 | 'allow_methods' => ['GET', 'OPTIONS'], 365 | ]); 366 | 367 | /** @var Response $response */ 368 | $response = $middleware($request, $this->getNextCallback($response)); 369 | $this->assertInstanceOf(Response::class, $response); 370 | $this->assertSame(204, $response->getStatusCode()); 371 | $this->assertTrue($response->hasHeader('Access-Control-Allow-Credentials')); 372 | $this->assertSame('true', strtolower($response->getHeaderLine('Access-Control-Allow-Credentials'))); 373 | } 374 | 375 | public function testRequestCredentialsNotAllowed() 376 | { 377 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 378 | 'Origin' => 'https://valid.example.net', 379 | 'Access-Control-Request-Method' => 'GET', 380 | ]); 381 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 382 | 383 | $middleware = new CorsMiddleware([ 384 | 'allow_credentials' => false, 385 | 'allow_origin' => '*', 386 | 'allow_methods' => ['GET', 'OPTIONS'], 387 | ]); 388 | 389 | /** @var Response $response */ 390 | $response = $middleware($request, $this->getNextCallback($response)); 391 | $this->assertInstanceOf(Response::class, $response); 392 | $this->assertSame(204, $response->getStatusCode()); 393 | $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); 394 | } 395 | 396 | public function testRequestCredentialsInvalidValueFallbackToFalse() 397 | { 398 | $request = new ServerRequest('OPTIONS', 'https://api.example.net/', [ 399 | 'Origin' => 'https://www.example.net', 400 | 'Access-Control-Request-Method' => 'GET', 401 | ]); 402 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 403 | 404 | $middleware = new CorsMiddleware([ 405 | 'allow_credentials' => new \stdClass(), 406 | 'allow_origin' => '*', 407 | 'allow_methods' => ['GET', 'OPTIONS'], 408 | ]); 409 | 410 | /** @var Response $response */ 411 | $response = $middleware($request, $this->getNextCallback($response)); 412 | $this->assertInstanceOf(Response::class, $response); 413 | $this->assertSame(204, $response->getStatusCode()); 414 | $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); 415 | } 416 | 417 | 418 | public function testRequestExposedHeaderForResponseShouldBeHidden() 419 | { 420 | $request = new ServerRequest('GET', 'https://api.example.net/', [ 421 | 'Origin' => 'https://valid.example.net', 422 | 'Access-Control-Request-Method' => 'GET', 423 | ]); 424 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 425 | 426 | $middleware = new CorsMiddleware([ 427 | 'allow_origin' => '*', 428 | 'allow_methods' => ['GET'], 429 | 'expose_headers' => [], 430 | ]); 431 | 432 | /** @var PromiseInterface $result */ 433 | $result = $middleware($request, $this->getNextCallback($response)); 434 | $result->then(function ($value) use (&$response) { 435 | $response = $value; 436 | }); 437 | $this->assertInstanceOf(Response::class, $response); 438 | 439 | $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers')); 440 | } 441 | 442 | public function testRequestExposedHeaderForResponseShouldBeVisible() 443 | { 444 | $request = new ServerRequest('GET', 'https://api.example.net/', [ 445 | 'Origin' => 'https://www.example.net', 446 | 'Access-Control-Request-Method' => 'GET', 447 | ]); 448 | $response = new Response(200, ['Content-Type' => 'text/html'], 'Some response'); 449 | 450 | $middleware = new CorsMiddleware([ 451 | 'allow_origin' => '*', 452 | 'allow_methods' => ['GET'], 453 | 'expose_headers' => ['X-Custom-Header', 'X-Custom-Header-2'], 454 | ]); 455 | 456 | /** @var PromiseInterface $result */ 457 | $result = $middleware($request, $this->getNextCallback($response)); 458 | $result->then(function ($value) use (&$response) { 459 | $response = $value; 460 | }); 461 | $this->assertInstanceOf(Response::class, $response); 462 | 463 | $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers')); 464 | $this->assertContains('X-Custom-Header', $response->getHeaderLine('Access-Control-Expose-Headers')); 465 | $this->assertContains('X-Custom-Header-2', $response->getHeaderLine('Access-Control-Expose-Headers')); 466 | } 467 | 468 | public function getNextCallback(Response $response) 469 | { 470 | return function (ServerRequestInterface $request) use (&$response) { 471 | return new Promise(function ($resolve, $reject) use ($request, &$response) { 472 | return $resolve($response); 473 | }); 474 | }; 475 | } 476 | } 477 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |