├── LICENSE ├── README.md ├── composer.json └── src ├── CorsService.php └── Exceptions └── InvalidOptionException.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2017 Alexander 2 | Copyright (c) 2017-2022 Barryvdh 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished 9 | to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CORS for PHP (using the Symfony HttpFoundation) 2 | 3 | [![Unit Tests](https://github.com/fruitcake/php-cors/actions/workflows/run-tests.yml/badge.svg)](https://github.com/fruitcake/php-cors/actions) 4 | [![PHPStan Level 9](https://img.shields.io/badge/PHPStan-Level%2010-blue)](https://github.com/fruitcake/php-cors/actions) 5 | [![Code Coverage](https://img.shields.io/badge/CodeCoverage-100%25-brightgreen)](https://github.com/fruitcake/php-cors/actions/workflows/run-coverage.yml) 6 | [![Packagist License](https://img.shields.io/badge/Licence-MIT-blue)](http://choosealicense.com/licenses/mit/) 7 | [![Latest Stable Version](https://img.shields.io/packagist/v/fruitcake/php-cors?label=Stable)](https://packagist.org/packages/fruitcake/php-cors) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/fruitcake/php-cors?label=Downloads)](https://packagist.org/packages/fruitcake/php-cors) 9 | [![Fruitcake](https://img.shields.io/badge/Powered%20By-Fruitcake-b2bc35.svg)](https://fruitcake.nl/) 10 | 11 | Library and middleware enabling cross-origin resource sharing for your 12 | http-{foundation,kernel} using application. It attempts to implement the 13 | [W3C Recommendation] for cross-origin resource sharing. 14 | 15 | [W3C Recommendation]: http://www.w3.org/TR/cors/ 16 | 17 | > Note: This is a standalone fork of https://github.com/asm89/stack-cors and is compatible with the options for CorsService. 18 | ## Installation 19 | 20 | Require `fruitcake/php-cors` using composer. 21 | 22 | ## Usage 23 | 24 | This package can be used as a library. You can use it in your framework using: 25 | 26 | - [Stack middleware](http://stackphp.com/): https://github.com/asm89/stack-cors 27 | - [Laravel](https://laravel.com): https://github.com/fruitcake/laravel-cors 28 | 29 | 30 | ### Options 31 | 32 | | Option | Description | Default value | 33 | |------------------------|------------------------------------------------------------|---------------| 34 | | allowedMethods | Matches the request method. | `[]` | 35 | | allowedOrigins | Matches the request origin. | `[]` | 36 | | allowedOriginsPatterns | Matches the request origin with `preg_match`. | `[]` | 37 | | allowedHeaders | Sets the Access-Control-Allow-Headers response header. | `[]` | 38 | | exposedHeaders | Sets the Access-Control-Expose-Headers response header. | `[]` | 39 | | maxAge | Sets the Access-Control-Max-Age response header. | `0` | 40 | | supportsCredentials | Sets the Access-Control-Allow-Credentials header. | `false` | 41 | 42 | The _allowedMethods_ and _allowedHeaders_ options are case-insensitive. 43 | 44 | You don't need to provide both _allowedOrigins_ and _allowedOriginsPatterns_. If one of the strings passed matches, it is considered a valid origin. A wildcard in allowedOrigins will be converted to a pattern. 45 | 46 | If `['*']` is provided to _allowedMethods_, _allowedOrigins_ or _allowedHeaders_ all methods / origins / headers are allowed. 47 | 48 | > Note: Allowing a single static origin will improve cacheability. 49 | 50 | ### Example: using the library 51 | 52 | ```php 53 | ['x-allowed-header', 'x-other-allowed-header'], 59 | 'allowedMethods' => ['DELETE', 'GET', 'POST', 'PUT'], 60 | 'allowedOrigins' => ['http://localhost', 'https://*.example.com'], 61 | 'allowedOriginsPatterns' => ['/localhost:\d/'], 62 | 'exposedHeaders' => ['Content-Encoding'], 63 | 'maxAge' => 0, 64 | 'supportsCredentials' => false, 65 | ]); 66 | 67 | $cors->addActualRequestHeaders(Response $response, $origin); 68 | $cors->handlePreflightRequest(Request $request); 69 | $cors->isActualRequestAllowed(Request $request); 70 | $cors->isCorsRequest(Request $request); 71 | $cors->isPreflightRequest(Request $request); 72 | ``` 73 | 74 | ## License 75 | 76 | Released under the MIT License, see [LICENSE](LICENSE). 77 | 78 | > This package is split-off from https://github.com/asm89/stack-cors and developed as stand-alone library since 2022 79 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fruitcake/php-cors", 3 | "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", 4 | "keywords": ["cors", "symfony", "laravel"], 5 | "homepage": "https://github.com/fruitcake/php-cors", 6 | "type": "library", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fruitcake", 11 | "homepage": "https://fruitcake.nl" 12 | }, 13 | { 14 | "name": "Barryvdh", 15 | "email": "barryvdh@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.4|^8.0", 20 | "symfony/http-foundation": "^4.4|^5.4|^6|^7" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "^9", 24 | "squizlabs/php_codesniffer": "^3.5", 25 | "phpstan/phpstan": "^2" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Fruitcake\\Cors\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Fruitcake\\Cors\\Tests\\": "tests/" 35 | } 36 | }, 37 | "scripts": { 38 | "actions": "composer test && composer analyse && composer check-style", 39 | "test": "phpunit", 40 | "analyse": "phpstan analyse src tests --level=10", 41 | "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 tests", 42 | "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 tests" 43 | }, 44 | "extra": { 45 | "branch-alias": { 46 | "dev-master": "1.3-dev" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/CorsService.php: -------------------------------------------------------------------------------- 1 | 7 | * (c) Barryvdh 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | */ 12 | 13 | namespace Fruitcake\Cors; 14 | 15 | use Fruitcake\Cors\Exceptions\InvalidOptionException; 16 | use Symfony\Component\HttpFoundation\Request; 17 | use Symfony\Component\HttpFoundation\Response; 18 | 19 | /** 20 | * @phpstan-type CorsInputOptions array{ 21 | * 'allowedOrigins'?: string[], 22 | * 'allowedOriginsPatterns'?: string[], 23 | * 'supportsCredentials'?: bool, 24 | * 'allowedHeaders'?: string[], 25 | * 'allowedMethods'?: string[], 26 | * 'exposedHeaders'?: string[]|false, 27 | * 'maxAge'?: int|bool|null, 28 | * 'allowed_origins'?: string[], 29 | * 'allowed_origins_patterns'?: string[], 30 | * 'supports_credentials'?: bool, 31 | * 'allowed_headers'?: string[], 32 | * 'allowed_methods'?: string[], 33 | * 'exposed_headers'?: string[]|false, 34 | * 'max_age'?: int|bool|null 35 | * } 36 | * 37 | */ 38 | class CorsService 39 | { 40 | /** @var string[] */ 41 | private array $allowedOrigins = []; 42 | /** @var string[] */ 43 | private array $allowedOriginsPatterns = []; 44 | /** @var string[] */ 45 | private array $allowedMethods = []; 46 | /** @var string[] */ 47 | private array $allowedHeaders = []; 48 | /** @var string[] */ 49 | private array $exposedHeaders = []; 50 | private bool $supportsCredentials = false; 51 | private ?int $maxAge = 0; 52 | 53 | private bool $allowAllOrigins = false; 54 | private bool $allowAllMethods = false; 55 | private bool $allowAllHeaders = false; 56 | 57 | /** 58 | * @param CorsInputOptions $options 59 | */ 60 | public function __construct(array $options = []) 61 | { 62 | if ($options) { 63 | $this->setOptions($options); 64 | } 65 | } 66 | 67 | /** 68 | * @param CorsInputOptions $options 69 | */ 70 | public function setOptions(array $options): void 71 | { 72 | $this->allowedOrigins = $options['allowedOrigins'] ?? $options['allowed_origins'] ?? $this->allowedOrigins; 73 | $this->allowedOriginsPatterns = 74 | $options['allowedOriginsPatterns'] ?? $options['allowed_origins_patterns'] ?? $this->allowedOriginsPatterns; 75 | $this->allowedMethods = $options['allowedMethods'] ?? $options['allowed_methods'] ?? $this->allowedMethods; 76 | $this->allowedHeaders = $options['allowedHeaders'] ?? $options['allowed_headers'] ?? $this->allowedHeaders; 77 | $this->supportsCredentials = 78 | $options['supportsCredentials'] ?? $options['supports_credentials'] ?? $this->supportsCredentials; 79 | 80 | $maxAge = $this->maxAge; 81 | if (array_key_exists('maxAge', $options)) { 82 | $maxAge = $options['maxAge']; 83 | } elseif (array_key_exists('max_age', $options)) { 84 | $maxAge = $options['max_age']; 85 | } 86 | $this->maxAge = $maxAge === null ? null : (int)$maxAge; 87 | 88 | $exposedHeaders = $options['exposedHeaders'] ?? $options['exposed_headers'] ?? $this->exposedHeaders; 89 | $this->exposedHeaders = $exposedHeaders === false ? [] : $exposedHeaders; 90 | 91 | $this->normalizeOptions(); 92 | } 93 | 94 | private function normalizeOptions(): void 95 | { 96 | // Normalize case 97 | $this->allowedHeaders = array_map('strtolower', $this->allowedHeaders); 98 | $this->allowedMethods = array_map('strtoupper', $this->allowedMethods); 99 | 100 | // Normalize ['*'] to true 101 | $this->allowAllOrigins = in_array('*', $this->allowedOrigins); 102 | $this->allowAllHeaders = in_array('*', $this->allowedHeaders); 103 | $this->allowAllMethods = in_array('*', $this->allowedMethods); 104 | 105 | // Transform wildcard pattern 106 | if (!$this->allowAllOrigins) { 107 | foreach ($this->allowedOrigins as $origin) { 108 | if (strpos($origin, '*') !== false) { 109 | $this->allowedOriginsPatterns[] = $this->convertWildcardToPattern($origin); 110 | } 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * Create a pattern for a wildcard, based on Str::is() from Laravel 117 | * 118 | * @see https://github.com/laravel/framework/blob/5.5/src/Illuminate/Support/Str.php 119 | * @param string $pattern 120 | * @return string 121 | */ 122 | private function convertWildcardToPattern($pattern) 123 | { 124 | $pattern = preg_quote($pattern, '#'); 125 | 126 | // Asterisks are translated into zero-or-more regular expression wildcards 127 | // to make it convenient to check if the strings starts with the given 128 | // pattern such as "*.example.com", making any string check convenient. 129 | $pattern = str_replace('\*', '.*', $pattern); 130 | 131 | return '#^' . $pattern . '\z#u'; 132 | } 133 | 134 | public function isCorsRequest(Request $request): bool 135 | { 136 | return $request->headers->has('Origin'); 137 | } 138 | 139 | public function isPreflightRequest(Request $request): bool 140 | { 141 | return $request->getMethod() === 'OPTIONS' && $request->headers->has('Access-Control-Request-Method'); 142 | } 143 | 144 | public function handlePreflightRequest(Request $request): Response 145 | { 146 | $response = new Response(); 147 | 148 | $response->setStatusCode(204); 149 | 150 | return $this->addPreflightRequestHeaders($response, $request); 151 | } 152 | 153 | public function addPreflightRequestHeaders(Response $response, Request $request): Response 154 | { 155 | $this->configureAllowedOrigin($response, $request); 156 | 157 | if ($response->headers->has('Access-Control-Allow-Origin')) { 158 | $this->configureAllowCredentials($response, $request); 159 | 160 | $this->configureAllowedMethods($response, $request); 161 | 162 | $this->configureAllowedHeaders($response, $request); 163 | 164 | $this->configureMaxAge($response, $request); 165 | } 166 | 167 | return $response; 168 | } 169 | 170 | public function isOriginAllowed(Request $request): bool 171 | { 172 | if ($this->allowAllOrigins === true) { 173 | return true; 174 | } 175 | 176 | $origin = (string) $request->headers->get('Origin'); 177 | 178 | if (in_array($origin, $this->allowedOrigins)) { 179 | return true; 180 | } 181 | 182 | foreach ($this->allowedOriginsPatterns as $pattern) { 183 | if (preg_match($pattern, $origin)) { 184 | return true; 185 | } 186 | } 187 | 188 | return false; 189 | } 190 | 191 | public function addActualRequestHeaders(Response $response, Request $request): Response 192 | { 193 | $this->configureAllowedOrigin($response, $request); 194 | 195 | if ($response->headers->has('Access-Control-Allow-Origin')) { 196 | $this->configureAllowCredentials($response, $request); 197 | 198 | $this->configureExposedHeaders($response, $request); 199 | } 200 | 201 | return $response; 202 | } 203 | 204 | private function configureAllowedOrigin(Response $response, Request $request): void 205 | { 206 | if ($this->allowAllOrigins === true && !$this->supportsCredentials) { 207 | // Safe+cacheable, allow everything 208 | $response->headers->set('Access-Control-Allow-Origin', '*'); 209 | } elseif ($this->isSingleOriginAllowed()) { 210 | // Single origins can be safely set 211 | $response->headers->set('Access-Control-Allow-Origin', array_values($this->allowedOrigins)[0]); 212 | } else { 213 | // For dynamic headers, set the requested Origin header when set and allowed 214 | if ($this->isCorsRequest($request) && $this->isOriginAllowed($request)) { 215 | $response->headers->set('Access-Control-Allow-Origin', (string) $request->headers->get('Origin')); 216 | } 217 | 218 | $this->varyHeader($response, 'Origin'); 219 | } 220 | } 221 | 222 | private function isSingleOriginAllowed(): bool 223 | { 224 | if ($this->allowAllOrigins === true || count($this->allowedOriginsPatterns) > 0) { 225 | return false; 226 | } 227 | 228 | return count($this->allowedOrigins) === 1; 229 | } 230 | 231 | private function configureAllowedMethods(Response $response, Request $request): void 232 | { 233 | if ($this->allowAllMethods === true) { 234 | $allowMethods = strtoupper((string) $request->headers->get('Access-Control-Request-Method')); 235 | $this->varyHeader($response, 'Access-Control-Request-Method'); 236 | } else { 237 | $allowMethods = implode(', ', $this->allowedMethods); 238 | } 239 | 240 | $response->headers->set('Access-Control-Allow-Methods', $allowMethods); 241 | } 242 | 243 | private function configureAllowedHeaders(Response $response, Request $request): void 244 | { 245 | if ($this->allowAllHeaders === true) { 246 | $allowHeaders = (string) $request->headers->get('Access-Control-Request-Headers'); 247 | $this->varyHeader($response, 'Access-Control-Request-Headers'); 248 | } else { 249 | $allowHeaders = implode(', ', $this->allowedHeaders); 250 | } 251 | $response->headers->set('Access-Control-Allow-Headers', $allowHeaders); 252 | } 253 | 254 | private function configureAllowCredentials(Response $response, Request $request): void 255 | { 256 | if ($this->supportsCredentials) { 257 | $response->headers->set('Access-Control-Allow-Credentials', 'true'); 258 | } 259 | } 260 | 261 | private function configureExposedHeaders(Response $response, Request $request): void 262 | { 263 | if ($this->exposedHeaders) { 264 | $response->headers->set('Access-Control-Expose-Headers', implode(', ', $this->exposedHeaders)); 265 | } 266 | } 267 | 268 | private function configureMaxAge(Response $response, Request $request): void 269 | { 270 | if ($this->maxAge !== null) { 271 | $response->headers->set('Access-Control-Max-Age', (string) $this->maxAge); 272 | } 273 | } 274 | 275 | public function varyHeader(Response $response, string $header): Response 276 | { 277 | if (!$response->headers->has('Vary')) { 278 | $response->headers->set('Vary', $header); 279 | } else { 280 | $varyHeaders = $response->getVary(); 281 | if (!in_array($header, $varyHeaders, true)) { 282 | if (count($response->headers->all('Vary')) === 1) { 283 | $response->setVary(((string)$response->headers->get('Vary')) . ', ' . $header); 284 | } else { 285 | $response->setVary($header, false); 286 | } 287 | } 288 | } 289 | 290 | return $response; 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidOptionException.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 Fruitcake\Cors\Exceptions; 13 | 14 | class InvalidOptionException extends \RuntimeException 15 | { 16 | } 17 | --------------------------------------------------------------------------------