<?php
/*
* This file is part of asm89/stack-cors.
*
* (c) Alexander <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Asm89\Stack;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class CorsService
{
private $options;
public function __construct(array $options = [])
{
$this->options = $this->normalizeOptions($options);
}
private function normalizeOptions(array $options = []): array
{
$options += [
'allowedOrigins' => [],
'allowedOriginsPatterns' => [],
'supportsCredentials' => false,
'allowedHeaders' => [],
'exposedHeaders' => [],
'allowedMethods' => [],
'maxAge' => 0,
];
// normalize array('*') to true
if (in_array('*', $options['allowedOrigins'])) {
$options['allowedOrigins'] = true;
}
if (in_array('*', $options['allowedHeaders'])) {
$options['allowedHeaders'] = true;
} else {
$options['allowedHeaders'] = array_map('strtolower', $options['allowedHeaders']);
}
if (in_array('*', $options['allowedMethods'])) {
$options['allowedMethods'] = true;
} else {
$options['allowedMethods'] = array_map('strtoupper', $options['allowedMethods']);
}
return $options;
}
/**
* @deprecated use isOriginAllowed
*/
public function isActualRequestAllowed(Request $request): bool
{
return $this->isOriginAllowed($request);
}
public function isCorsRequest(Request $request): bool
{
return $request->headers->has('Origin');
}
public function isPreflightRequest(Request $request): bool
{
return $request->getMethod() === 'OPTIONS' && $request->headers->has('Access-Control-Request-Method');
}
public function handlePreflightRequest(Request $request): Response
{
$response = new Response();
$response->setStatusCode(204);
return $this->addPreflightRequestHeaders($response, $request);
}
public function addPreflightRequestHeaders(Response $response, Request $request): Response
{
$this->configureAllowedOrigin($response, $request);
if ($response->headers->has('Access-Control-Allow-Origin')) {
$this->configureAllowCredentials($response, $request);
$this->configureAllowedMethods($response, $request);
$this->configureAllowedHeaders($response, $request);
$this->configureMaxAge($response, $request);
}
return $response;
}
public function isOriginAllowed(Request $request): bool
{
if ($this->options['allowedOrigins'] === true) {
return true;
}
if (!$request->headers->has('Origin')) {
return false;
}
$origin = $request->headers->get('Origin');
if (in_array($origin, $this->options['allowedOrigins'])) {
return true;
}
foreach ($this->options['allowedOriginsPatterns'] as $pattern) {
if (preg_match($pattern, $origin)) {
return true;
}
}
return false;
}
public function addActualRequestHeaders(Response $response, Request $request): Response
{
$this->configureAllowedOrigin($response, $request);
if ($response->headers->has('Access-Control-Allow-Origin')) {
$this->configureAllowCredentials($response, $request);
$this->configureExposedHeaders($response, $request);
}
return $response;
}
private function configureAllowedOrigin(Response $response, Request $request)
{
if ($this->options['allowedOrigins'] === true && !$this->options['supportsCredentials']) {
// Safe+cacheable, allow everything
$response->headers->set('Access-Control-Allow-Origin', '*');
} elseif ($this->isSingleOriginAllowed()) {
// Single origins can be safely set
$response->headers->set('Access-Control-Allow-Origin', array_values($this->options['allowedOrigins'])[0]);
} else {
// For dynamic headers, set the requested Origin header when set and allowed
if ($this->isCorsRequest($request) && $this->isOriginAllowed($request)) {
$response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin'));
}
$this->varyHeader($response, 'Origin');
}
}
private function isSingleOriginAllowed(): bool
{
if ($this->options['allowedOrigins'] === true || !empty($this->options['allowedOriginsPatterns'])) {
return false;
}
return count($this->options['allowedOrigins']) === 1;
}
private function configureAllowedMethods(Response $response, Request $request)
{
if ($this->options['allowedMethods'] === true) {
$allowMethods = strtoupper($request->headers->get('Access-Control-Request-Method'));
$this->varyHeader($response, 'Access-Control-Request-Method');
} else {
$allowMethods = implode(', ', $this->options['allowedMethods']);
}
$response->headers->set('Access-Control-Allow-Methods', $allowMethods);
}
private function configureAllowedHeaders(Response $response, Request $request)
{
if ($this->options['allowedHeaders'] === true) {
$allowHeaders = $request->headers->get('Access-Control-Request-Headers');
$this->varyHeader($response, 'Access-Control-Request-Headers');
} else {
$allowHeaders = implode(', ', $this->options['allowedHeaders']);
}
$response->headers->set('Access-Control-Allow-Headers', $allowHeaders);
}
private function configureAllowCredentials(Response $response, Request $request)
{
if ($this->options['supportsCredentials']) {
$response->headers->set('Access-Control-Allow-Credentials', 'true');
}
}
private function configureExposedHeaders(Response $response, Request $request)
{
if ($this->options['exposedHeaders']) {
$response->headers->set('Access-Control-Expose-Headers', implode(', ', $this->options['exposedHeaders']));
}
}
private function configureMaxAge(Response $response, Request $request)
{
if ($this->options['maxAge'] !== null) {
$response->headers->set('Access-Control-Max-Age', (int) $this->options['maxAge']);
}
}
public function varyHeader(Response $response, $header): Response
{
if (!$response->headers->has('Vary')) {
$response->headers->set('Vary', $header);
} elseif (!in_array($header, explode(', ', $response->headers->get('Vary')))) {
$response->headers->set('Vary', $response->headers->get('Vary') . ', ' . $header);
}
return $response;
}
private function isSameHost(Request $request): bool
{
return $request->headers->get('Origin') === $request->getSchemeAndHttpHost();
}
}