├── LICENSE ├── README.md ├── composer.json └── src ├── Cors.php └── CorsService.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2017 Alexander 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stack/Cors 2 | 3 | Library and middleware enabling cross-origin resource sharing for your 4 | http-{foundation,kernel} using application. It attempts to implement the 5 | [W3C Recommendation] for cross-origin resource sharing. 6 | 7 | [W3C Recommendation]: http://www.w3.org/TR/cors/ 8 | 9 | Build status: ![.github/workflows/run-tests.yml](https://github.com/asm89/stack-cors/workflows/.github/workflows/run-tests.yml/badge.svg) 10 | 11 | ## Installation 12 | 13 | Require `asm89/stack-cors` using composer. 14 | 15 | ## Usage 16 | 17 | This package can be used as a library or as [stack middleware]. 18 | 19 | [stack middleware]: http://stackphp.com/ 20 | 21 | ### Options 22 | 23 | | Option | Description | Default value | 24 | |------------------------|------------------------------------------------------------|---------------| 25 | | `allowedMethods` | Matches the request method. | `[]` | 26 | | `allowedOrigins` | Matches the request origin. | `[]` | 27 | | `allowedOriginsPatterns` | Matches the request origin with `preg_match`. | `[]` | 28 | | `allowedHeaders` | Sets the Access-Control-Allow-Headers response header. | `[]` | 29 | | `exposedHeaders` | Sets the Access-Control-Expose-Headers response header. | `false` | 30 | | `maxAge` | Sets the Access-Control-Max-Age response header.
Set to `null` to omit the header/use browser default. | `0` | 31 | | `supportsCredentials` | Sets the Access-Control-Allow-Credentials header. | `false` | 32 | 33 | The _allowedMethods_ and _allowedHeaders_ options are case-insensitive. 34 | 35 | You don't need to provide both _allowedOrigins_ and _allowedOriginsPatterns_. If one of the strings passed matches, it is considered a valid origin. 36 | 37 | If `['*']` is provided to _allowedMethods_, _allowedOrigins_ or _allowedHeaders_ all methods / origins / headers are allowed. 38 | 39 | If _supportsCredentials_ is `true`, you must [explicitly set](https://fetch.spec.whatwg.org/#cors-protocol-and-credentials) `allowedHeaders` for any headers which are not CORS safelisted. 40 | 41 | ### Example: using the library 42 | 43 | ```php 44 | ['x-allowed-header', 'x-other-allowed-header'], 50 | 'allowedMethods' => ['DELETE', 'GET', 'POST', 'PUT'], 51 | 'allowedOrigins' => ['http://localhost'], 52 | 'allowedOriginsPatterns' => ['/localhost:\d/'], 53 | 'exposedHeaders' => false, 54 | 'maxAge' => 600, 55 | 'supportsCredentials' => true, 56 | ]); 57 | 58 | $cors->addActualRequestHeaders(Response $response, $origin); 59 | $cors->handlePreflightRequest(Request $request); 60 | $cors->isActualRequestAllowed(Request $request); 61 | $cors->isCorsRequest(Request $request); 62 | $cors->isPreflightRequest(Request $request); 63 | ``` 64 | 65 | ## Example: using the stack middleware 66 | 67 | ```php 68 | ['x-allowed-header', 'x-other-allowed-header'], 75 | // you can use ['*'] to allow any methods 76 | 'allowedMethods' => ['DELETE', 'GET', 'POST', 'PUT'], 77 | // you can use ['*'] to allow requests from any origin 78 | 'allowedOrigins' => ['localhost'], 79 | // you can enter regexes that are matched to the origin request header 80 | 'allowedOriginsPatterns' => ['/localhost:\d/'], 81 | 'exposedHeaders' => false, 82 | 'maxAge' => 600, 83 | 'supportsCredentials' => false, 84 | ]); 85 | ``` 86 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asm89/stack-cors", 3 | "description": "Cross-origin resource sharing library and stack middleware", 4 | "keywords": ["stack", "cors"], 5 | "homepage": "https://github.com/asm89/stack-cors", 6 | "type": "library", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Alexander", 11 | "email": "iam.asm89@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": "^7.3|^8.0", 16 | "symfony/http-foundation": "^5.3|^6|^7", 17 | "symfony/http-kernel": "^5.3|^6|^7" 18 | }, 19 | "require-dev": { 20 | "phpunit/phpunit": "^9", 21 | "squizlabs/php_codesniffer": "^3.5" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Asm89\\Stack\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Asm89\\Stack\\Tests\\": "tests/" 31 | } 32 | }, 33 | "scripts": { 34 | "test": "phpunit", 35 | "check-style": "phpcs -p --standard=PSR12 --exclude=Generic.Files.LineLength --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src", 36 | "fix-style": "phpcbf -p --standard=PSR12 --exclude=Generic.Files.LineLength --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src" 37 | }, 38 | "extra": { 39 | "branch-alias": { 40 | "dev-master": "2.2-dev" 41 | } 42 | }, 43 | "minimum-stability": "beta", 44 | "prefer-stable": true 45 | } 46 | -------------------------------------------------------------------------------- /src/Cors.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Asm89\Stack; 13 | 14 | use Symfony\Component\HttpFoundation\Response; 15 | use Symfony\Component\HttpKernel\HttpKernelInterface; 16 | use Symfony\Component\HttpFoundation\Request; 17 | 18 | class Cors implements HttpKernelInterface 19 | { 20 | /** 21 | * @var \Symfony\Component\HttpKernel\HttpKernelInterface 22 | */ 23 | private $app; 24 | 25 | /** 26 | * @var \Asm89\Stack\CorsService 27 | */ 28 | private $cors; 29 | 30 | private $defaultOptions = [ 31 | 'allowedHeaders' => [], 32 | 'allowedMethods' => [], 33 | 'allowedOrigins' => [], 34 | 'allowedOriginsPatterns' => [], 35 | 'exposedHeaders' => [], 36 | 'maxAge' => 0, 37 | 'supportsCredentials' => false, 38 | ]; 39 | 40 | public function __construct(HttpKernelInterface $app, array $options = []) 41 | { 42 | $this->app = $app; 43 | $this->cors = new CorsService(array_merge($this->defaultOptions, $options)); 44 | } 45 | 46 | public function handle(Request $request, int $type = HttpKernelInterface::MAIN_REQUEST, bool $catch = true): Response 47 | { 48 | if ($this->cors->isPreflightRequest($request)) { 49 | $response = $this->cors->handlePreflightRequest($request); 50 | return $this->cors->varyHeader($response, 'Access-Control-Request-Method'); 51 | } 52 | 53 | $response = $this->app->handle($request, $type, $catch); 54 | 55 | if ($request->getMethod() === 'OPTIONS') { 56 | $this->cors->varyHeader($response, 'Access-Control-Request-Method'); 57 | } 58 | 59 | return $this->cors->addActualRequestHeaders($response, $request); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/CorsService.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Asm89\Stack; 13 | 14 | use Symfony\Component\HttpFoundation\Request; 15 | use Symfony\Component\HttpFoundation\Response; 16 | 17 | class CorsService 18 | { 19 | private $options; 20 | 21 | public function __construct(array $options = []) 22 | { 23 | $this->options = $this->normalizeOptions($options); 24 | } 25 | 26 | private function normalizeOptions(array $options = []): array 27 | { 28 | $options += [ 29 | 'allowedOrigins' => [], 30 | 'allowedOriginsPatterns' => [], 31 | 'supportsCredentials' => false, 32 | 'allowedHeaders' => [], 33 | 'exposedHeaders' => [], 34 | 'allowedMethods' => [], 35 | 'maxAge' => 0, 36 | ]; 37 | 38 | // normalize array('*') to true 39 | if (in_array('*', $options['allowedOrigins'])) { 40 | $options['allowedOrigins'] = true; 41 | } 42 | if (in_array('*', $options['allowedHeaders'])) { 43 | $options['allowedHeaders'] = true; 44 | } else { 45 | $options['allowedHeaders'] = array_map('strtolower', $options['allowedHeaders']); 46 | } 47 | 48 | if (in_array('*', $options['allowedMethods'])) { 49 | $options['allowedMethods'] = true; 50 | } else { 51 | $options['allowedMethods'] = array_map('strtoupper', $options['allowedMethods']); 52 | } 53 | 54 | return $options; 55 | } 56 | 57 | /** 58 | * @deprecated use isOriginAllowed 59 | */ 60 | public function isActualRequestAllowed(Request $request): bool 61 | { 62 | return $this->isOriginAllowed($request); 63 | } 64 | 65 | public function isCorsRequest(Request $request): bool 66 | { 67 | return $request->headers->has('Origin'); 68 | } 69 | 70 | public function isPreflightRequest(Request $request): bool 71 | { 72 | return $request->getMethod() === 'OPTIONS' && $request->headers->has('Access-Control-Request-Method'); 73 | } 74 | 75 | public function handlePreflightRequest(Request $request): Response 76 | { 77 | $response = new Response(); 78 | 79 | $response->setStatusCode(204); 80 | 81 | return $this->addPreflightRequestHeaders($response, $request); 82 | } 83 | 84 | public function addPreflightRequestHeaders(Response $response, Request $request): Response 85 | { 86 | $this->configureAllowedOrigin($response, $request); 87 | 88 | if ($response->headers->has('Access-Control-Allow-Origin')) { 89 | $this->configureAllowCredentials($response, $request); 90 | 91 | $this->configureAllowedMethods($response, $request); 92 | 93 | $this->configureAllowedHeaders($response, $request); 94 | 95 | $this->configureMaxAge($response, $request); 96 | } 97 | 98 | return $response; 99 | } 100 | 101 | public function isOriginAllowed(Request $request): bool 102 | { 103 | if ($this->options['allowedOrigins'] === true) { 104 | return true; 105 | } 106 | 107 | if (!$request->headers->has('Origin')) { 108 | return false; 109 | } 110 | 111 | $origin = $request->headers->get('Origin'); 112 | 113 | if (in_array($origin, $this->options['allowedOrigins'])) { 114 | return true; 115 | } 116 | 117 | foreach ($this->options['allowedOriginsPatterns'] as $pattern) { 118 | if (preg_match($pattern, $origin)) { 119 | return true; 120 | } 121 | } 122 | 123 | return false; 124 | } 125 | 126 | public function addActualRequestHeaders(Response $response, Request $request): Response 127 | { 128 | $this->configureAllowedOrigin($response, $request); 129 | 130 | if ($response->headers->has('Access-Control-Allow-Origin')) { 131 | $this->configureAllowCredentials($response, $request); 132 | 133 | $this->configureExposedHeaders($response, $request); 134 | } 135 | 136 | return $response; 137 | } 138 | 139 | private function configureAllowedOrigin(Response $response, Request $request) 140 | { 141 | if ($this->options['allowedOrigins'] === true && !$this->options['supportsCredentials']) { 142 | // Safe+cacheable, allow everything 143 | $response->headers->set('Access-Control-Allow-Origin', '*'); 144 | } elseif ($this->isSingleOriginAllowed()) { 145 | // Single origins can be safely set 146 | $response->headers->set('Access-Control-Allow-Origin', array_values($this->options['allowedOrigins'])[0]); 147 | } else { 148 | // For dynamic headers, set the requested Origin header when set and allowed 149 | if ($this->isCorsRequest($request) && $this->isOriginAllowed($request)) { 150 | $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); 151 | } 152 | 153 | $this->varyHeader($response, 'Origin'); 154 | } 155 | } 156 | 157 | private function isSingleOriginAllowed(): bool 158 | { 159 | if ($this->options['allowedOrigins'] === true || !empty($this->options['allowedOriginsPatterns'])) { 160 | return false; 161 | } 162 | 163 | return count($this->options['allowedOrigins']) === 1; 164 | } 165 | 166 | private function configureAllowedMethods(Response $response, Request $request) 167 | { 168 | if ($this->options['allowedMethods'] === true) { 169 | $allowMethods = strtoupper($request->headers->get('Access-Control-Request-Method')); 170 | $this->varyHeader($response, 'Access-Control-Request-Method'); 171 | } else { 172 | $allowMethods = implode(', ', $this->options['allowedMethods']); 173 | } 174 | 175 | $response->headers->set('Access-Control-Allow-Methods', $allowMethods); 176 | } 177 | 178 | private function configureAllowedHeaders(Response $response, Request $request) 179 | { 180 | if ($this->options['allowedHeaders'] === true) { 181 | $allowHeaders = $request->headers->get('Access-Control-Request-Headers'); 182 | $this->varyHeader($response, 'Access-Control-Request-Headers'); 183 | } else { 184 | $allowHeaders = implode(', ', $this->options['allowedHeaders']); 185 | } 186 | $response->headers->set('Access-Control-Allow-Headers', $allowHeaders); 187 | } 188 | 189 | private function configureAllowCredentials(Response $response, Request $request) 190 | { 191 | if ($this->options['supportsCredentials']) { 192 | $response->headers->set('Access-Control-Allow-Credentials', 'true'); 193 | } 194 | } 195 | 196 | private function configureExposedHeaders(Response $response, Request $request) 197 | { 198 | if ($this->options['exposedHeaders']) { 199 | $response->headers->set('Access-Control-Expose-Headers', implode(', ', $this->options['exposedHeaders'])); 200 | } 201 | } 202 | 203 | private function configureMaxAge(Response $response, Request $request) 204 | { 205 | if ($this->options['maxAge'] !== null) { 206 | $response->headers->set('Access-Control-Max-Age', (int) $this->options['maxAge']); 207 | } 208 | } 209 | 210 | public function varyHeader(Response $response, $header): Response 211 | { 212 | $vary = $response->getVary(); 213 | if (!in_array($header, $vary, true)) { 214 | if (count($response->headers->all('Vary')) === 1) { 215 | $response->setVary($response->headers->get('Vary') . ', ' . $header, true); 216 | } else { 217 | $response->setVary($header, false); 218 | } 219 | } 220 | 221 | return $response; 222 | } 223 | 224 | private function isSameHost(Request $request): bool 225 | { 226 | return $request->headers->get('Origin') === $request->getSchemeAndHttpHost(); 227 | } 228 | } 229 | --------------------------------------------------------------------------------