feat: add HTTP\Cors class

This commit is contained in:
kenjis 2024-03-22 18:12:26 +09:00
parent 39210beb13
commit fd25f15255
No known key found for this signature in database
GPG Key ID: BD254878922AF198
3 changed files with 834 additions and 0 deletions

102
app/Config/Cors.php Normal file
View File

@ -0,0 +1,102 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Cross-Origin Resource Sharing (CORS) Configuration
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
*/
class Cors extends BaseConfig
{
/**
* Origins for the `Access-Control-Allow-Origin` header.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
*
* E.g.:
* - ['http://localhost:8080']
* - ['https://www.example.com']
*
* @var list<string>
*/
public array $allowedOrigins = [];
/**
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
*
* NOTE: You must set `\A` (beginning with) and `\z` (ending with) in patterns,
* otherwise you will give access from unintended external domains.
* E.g., if you set '!\w+\.example\.com!', 'www.example.com.evil.site'
* is permitted.
*
* E.g.:
* - ['!\Ahttps://\w+\.example\.com\z!']
*
* @var list<string>
*/
public array $allowedOriginsPatterns = [];
/**
* Weather to send the `Access-Control-Allow-Credentials` header.
*
* The Access-Control-Allow-Credentials response header tells browsers whether
* the server allows cross-origin HTTP requests to include credentials.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
*/
public bool $supportsCredentials = false;
/**
* Set headers to allow.
*
* The Access-Control-Allow-Headers response header is used in response to
* a preflight request which includes the Access-Control-Request-Headers to
* indicate which HTTP headers can be used during the actual request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
*
* @var list<string>
*/
public array $allowedHeaders = [];
/**
* Set headers to expose.
*
* The Access-Control-Expose-Headers response header allows a server to
* indicate which response headers should be made available to scripts running
* in the browser, in response to a cross-origin request.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
*
* @var list<string>
*/
public array $exposedHeaders = [];
/**
* Set methods to allow.
*
* The Access-Control-Allow-Methods response header specifies one or more
* methods allowed when accessing a resource in response to a preflight
* request.
*
* E.g.:
* - ['GET', 'POST', 'PUT', 'DELETE']
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
*
* @var list<string>
*/
public array $allowedMethods = [];
/**
* Set how many seconds the results of a preflight request can be cached.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
*/
public int $maxAge = 7200;
}

219
system/HTTP/Cors.php Normal file
View File

@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\HTTP;
use CodeIgniter\Exceptions\ConfigException;
use Config\Cors as CorsConfig;
/**
* Cross-Origin Resource Sharing (CORS)
*
* @see \CodeIgniter\HTTP\CorsTest
*/
class Cors
{
/**
* @var array{
* allowedOrigins: list<string>,
* allowedOriginsPatterns: list<string>,
* supportsCredentials: bool,
* allowedHeaders: list<string>,
* exposedHeaders: list<string>,
* allowedMethods: list<string>,
* maxAge: int,
* }
*/
private array $config = [
'allowedOrigins' => [],
'allowedOriginsPatterns' => [],
'supportsCredentials' => false,
'allowedHeaders' => [],
'exposedHeaders' => [],
'allowedMethods' => [],
'maxAge' => 7200,
];
/**
* @param array{
* allowedOrigins?: list<string>,
* allowedOriginsPatterns?: list<string>,
* supportsCredentials?: bool,
* allowedHeaders?: list<string>,
* exposedHeaders?: list<string>,
* allowedMethods?: list<string>,
* maxAge?: int,
* }|CorsConfig|null $config
*/
public function __construct($config = null)
{
$config ??= config(CorsConfig::class);
if ($config instanceof CorsConfig) {
$config = (array) $config;
}
$this->config = array_merge($this->config, $config);
}
public function isPreflightRequest(IncomingRequest $request): bool
{
return $request->is('OPTIONS')
&& $request->hasHeader('Access-Control-Request-Method');
}
public function handlePreflightRequest(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
$response->setStatusCode(204);
$this->setAllowOrigin($request, $response);
if ($response->hasHeader('Access-Control-Allow-Origin')) {
$this->setAllowHeaders($response);
$this->setAllowMethods($response);
$this->setAllowMaxAge($response);
$this->setAllowCredentials($response);
}
return $response;
}
private function setAllowOrigin(RequestInterface $request, ResponseInterface $response): void
{
$originCount = count($this->config['allowedOrigins']);
$originPatternCount = count($this->config['allowedOriginsPatterns']);
if (in_array('*', $this->config['allowedOrigins'], true) && $originCount > 1) {
throw new ConfigException(
"If wildcard is specified, you must set `'allowedOrigins' => ['*']`."
);
}
if (
$originCount === 1 && $this->config['allowedOrigins'][0] === '*'
&& $this->config['supportsCredentials']
) {
throw new ConfigException(
'When responding to a credentialed request, the server must not specify the "*" wildcard for the Access-Control-Allow-Origin response-header value.'
);
}
if ($originCount === 1 && $originPatternCount === 0) {
$response->setHeader('Access-Control-Allow-Origin', $this->config['allowedOrigins'][0]);
return;
}
$origin = $request->getHeaderLine('Origin');
if ($originCount > 1 && in_array($origin, $this->config['allowedOrigins'], true)) {
$response->setHeader('Access-Control-Allow-Origin', $origin);
$response->appendHeader('Vary', 'Origin');
return;
}
if ($originPatternCount > 0) {
foreach ($this->config['allowedOriginsPatterns'] as $pattern) {
if (preg_match($pattern, $origin)) {
$response->setHeader('Access-Control-Allow-Origin', $origin);
$response->appendHeader('Vary', 'Origin');
}
}
}
}
private function setAllowHeaders(ResponseInterface $response): void
{
if (
in_array('*', $this->config['allowedHeaders'], true)
&& count($this->config['allowedHeaders']) > 1
) {
throw new ConfigException(
"If wildcard is specified, you must set `'allowedHeaders' => ['*']`."
);
}
if (
$this->config['allowedHeaders'][0] === '*'
&& $this->config['supportsCredentials']
) {
throw new ConfigException(
'When responding to a credentialed request, the server must not specify the "*" wildcard for the Access-Control-Allow-Headers response-header value.'
);
}
$response->setHeader(
'Access-Control-Allow-Headers',
implode(', ', $this->config['allowedHeaders'])
);
}
private function setAllowMethods(ResponseInterface $response): void
{
if (
in_array('*', $this->config['allowedMethods'], true)
&& count($this->config['allowedMethods']) > 1
) {
throw new ConfigException(
"If wildcard is specified, you must set `'allowedMethods' => ['*']`."
);
}
if (
$this->config['allowedMethods'][0] === '*'
&& $this->config['supportsCredentials']
) {
throw new ConfigException(
'When responding to a credentialed request, the server must not specify the "*" wildcard for the Access-Control-Allow-Methods response-header value.'
);
}
$response->setHeader(
'Access-Control-Allow-Methods',
implode(', ', $this->config['allowedMethods'])
);
}
private function setAllowMaxAge(ResponseInterface $response): void
{
$response->setHeader('Access-Control-Max-Age', (string) $this->config['maxAge']);
}
private function setAllowCredentials(ResponseInterface $response): void
{
if ($this->config['supportsCredentials']) {
$response->setHeader('Access-Control-Allow-Credentials', 'true');
}
}
public function addResponseHeaders(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
$this->setAllowOrigin($request, $response);
if ($response->hasHeader('Access-Control-Allow-Origin')) {
$this->setAllowCredentials($response);
$this->setExposeHeaders($response);
}
return $response;
}
private function setExposeHeaders(ResponseInterface $response): void
{
if ($this->config['exposedHeaders'] !== []) {
$response->setHeader(
'Access-Control-Expose-Headers',
implode(', ', $this->config['exposedHeaders'])
);
}
}
}

View File

@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\HTTP;
use CodeIgniter\Test\CIUnitTestCase;
use Config\Cors as CorsConfig;
use Config\Services;
/**
* @internal
*
* @group Others
*/
final class CorsTest extends CIUnitTestCase
{
private function createCors(?CorsConfig $config = null): Cors
{
$config ??= new CorsConfig();
return new Cors($config);
}
public function testInstantiate()
{
$cors = $this->createCors();
$this->assertInstanceOf(Cors::class, $cors);
}
public function testIsPreflightRequestTrue()
{
$cors = $this->createCors();
$request = $this->createRequest()
->withMethod('OPTIONS')
->setHeader('Access-Control-Request-Method', 'PUT');
$this->assertTrue($cors->isPreflightRequest($request));
}
public function testIsPreflightRequestFalse()
{
$cors = $this->createCors();
$request = $this->createRequest()
->withMethod('OPTIONS');
$this->assertFalse($cors->isPreflightRequest($request));
}
private function createRequest(): RequestInterface
{
return Services::incomingrequest(null, false);
}
private function createCorsConfig(): CorsConfig
{
$config = new CorsConfig();
$config->allowedHeaders = ['X-API-KEY', 'X-Requested-With', 'Content-Type', 'Accept'];
$config->allowedMethods = ['PUT'];
$config->maxAge = 3600;
return $config;
}
public function testHandlePreflightRequestSingleAllowedOrigin()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['http://localhost:8080'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('OPTIONS')
->setHeader('Access-Control-Request-Method', 'PUT')
->setHeader('Origin', 'http://localhost:8080');
$response = Services::response(null, false);
$response = $cors->handlePreflightRequest($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'http://localhost:8080'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Headers',
'X-API-KEY, X-Requested-With, Content-Type, Accept'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Methods',
'PUT'
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Credentials')
);
}
private function assertHeader(ResponseInterface $response, string $name, string $value): void
{
$this->assertSame($value, $response->getHeaderLine($name));
}
public function testHandlePreflightRequestMultipleAllowedOriginsAllowed()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['https://example.com', 'https://api.example.com'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('OPTIONS')
->setHeader('Access-Control-Request-Method', 'PUT')
->setHeader('Origin', 'https://api.example.com');
$response = Services::response(null, false);
$response = $cors->handlePreflightRequest($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'https://api.example.com'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Headers',
'X-API-KEY, X-Requested-With, Content-Type, Accept'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Methods',
'PUT'
);
$this->assertHeader(
$response,
'Vary',
'Origin'
);
}
public function testHandlePreflightRequestMultipleAllowedOriginsAllowedAlreadyVary()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['https://example.com', 'https://api.example.com'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('OPTIONS')
->setHeader('Access-Control-Request-Method', 'PUT')
->setHeader('Origin', 'https://api.example.com');
$response = Services::response(null, false)
->setHeader('Vary', 'Accept-Language');
$response = $cors->handlePreflightRequest($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'https://api.example.com'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Headers',
'X-API-KEY, X-Requested-With, Content-Type, Accept'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Methods',
'PUT'
);
$this->assertHeader(
$response,
'Vary',
'Accept-Language, Origin'
);
}
public function testHandlePreflightRequestMultipleAllowedOriginsNotAllowed()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['https://example.com', 'https://api.example.com'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('OPTIONS')
->setHeader('Access-Control-Request-Method', 'PUT')
->setHeader('Origin', 'https://bad.site.com');
$response = Services::response(null, false);
$response = $cors->handlePreflightRequest($request, $response);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Origin')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Headers')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Methods')
);
}
public function testHandlePreflightRequestAllowedOriginsPatternsAllowed()
{
$config = $this->createCorsConfig();
$config->allowedOriginsPatterns = ['!\Ahttps://\w+\.example\.com\z!'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('OPTIONS')
->setHeader('Access-Control-Request-Method', 'PUT')
->setHeader('Origin', 'https://api.example.com');
$response = Services::response(null, false);
$response = $cors->handlePreflightRequest($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'https://api.example.com'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Headers',
'X-API-KEY, X-Requested-With, Content-Type, Accept'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Methods',
'PUT'
);
$this->assertHeader(
$response,
'Vary',
'Origin'
);
}
public function testHandlePreflightRequestAllowedOriginsPatternsNotAllowed()
{
$config = $this->createCorsConfig();
$config->allowedOriginsPatterns = ['!\Ahttps://\w+\.example\.com\z!'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('OPTIONS')
->setHeader('Access-Control-Request-Method', 'PUT')
->setHeader('Origin', 'https://bad.site.com');
$response = Services::response(null, false);
$response = $cors->handlePreflightRequest($request, $response);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Origin')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Headers')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Methods')
);
}
public function testHandlePreflightRequestSingleAllowedOriginWithCredentials()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['http://localhost:8080'];
$config->supportsCredentials = true;
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('OPTIONS')
->setHeader('Access-Control-Request-Method', 'PUT')
->setHeader('Origin', 'http://localhost:8080');
$response = Services::response(null, false);
$response = $cors->handlePreflightRequest($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'http://localhost:8080'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Headers',
'X-API-KEY, X-Requested-With, Content-Type, Accept'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Methods',
'PUT'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Credentials',
'true'
);
}
public function testAddResponseHeadersSingleAllowedOriginSimpleRequest()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['http://localhost:8080'];
$config->allowedMethods = ['GET', 'POST', 'PUT'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('GET')
->setHeader('Origin', 'http://localhost:8080');
$response = Services::response(null, false);
$response = $cors->addResponseHeaders($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'http://localhost:8080'
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Headers')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Methods')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Credentials')
);
}
public function testAddResponseHeadersSingleAllowedOriginRealRequest()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['http://localhost:8080'];
$config->allowedMethods = ['GET', 'POST', 'PUT'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('POST')
->setHeader('Origin', 'http://localhost:8080');
$response = Services::response(null, false);
$response = $cors->addResponseHeaders($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'http://localhost:8080'
);
}
public function testAddResponseHeadersSingleAllowedOriginWithCredentials()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['http://localhost:8080'];
$config->supportsCredentials = true;
$config->allowedMethods = ['GET'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('GET')
->setHeader('Cookie', 'pageAccess=2')
->setHeader('Origin', 'http://localhost:8080');
$response = Services::response(null, false);
$response = $cors->addResponseHeaders($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'http://localhost:8080'
);
$this->assertHeader(
$response,
'Access-Control-Allow-Credentials',
'true'
);
}
public function testAddResponseHeadersSingleAllowedOriginWithExposeHeaders()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['http://localhost:8080'];
$config->allowedMethods = ['GET'];
$config->exposedHeaders = ['Content-Length', 'X-Kuma-Revision'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('GET')
->setHeader('Origin', 'http://localhost:8080');
$response = Services::response(null, false);
$response = $cors->addResponseHeaders($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'http://localhost:8080'
);
$this->assertHeader(
$response,
'Access-Control-Expose-Headers',
'Content-Length, X-Kuma-Revision'
);
}
public function testAddResponseHeadersMultipleAllowedOriginsAllowed()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['https://example.com', 'https://api.example.com'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('PUT')
->setHeader('Origin', 'https://api.example.com');
$response = Services::response(null, false);
$response = $cors->addResponseHeaders($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'https://api.example.com'
);
$this->assertHeader(
$response,
'Vary',
'Origin'
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Headers')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Methods')
);
}
public function testAddResponseHeadersMultipleAllowedOriginsAllowedAlreadyVary()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['https://example.com', 'https://api.example.com'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('PUT')
->setHeader('Origin', 'https://api.example.com');
$response = Services::response(null, false)
->setHeader('Vary', 'Accept-Language');
$response = $cors->addResponseHeaders($request, $response);
$this->assertHeader(
$response,
'Access-Control-Allow-Origin',
'https://api.example.com'
);
$this->assertHeader(
$response,
'Vary',
'Accept-Language, Origin'
);
}
public function testAddResponseHeadersMultipleAllowedOriginsNotAllowed()
{
$config = $this->createCorsConfig();
$config->allowedOrigins = ['https://example.com', 'https://api.example.com'];
$cors = $this->createCors($config);
$request = $this->createRequest()
->withMethod('PUT')
->setHeader('Origin', 'https://bad.site.com');
$response = Services::response(null, false);
$response = $cors->addResponseHeaders($request, $response);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Origin')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Headers')
);
$this->assertFalse(
$response->hasHeader('Access-Control-Allow-Methods')
);
}
}