FEAT_CORS
This commit is contained in:
parent
3d794e6d3e
commit
69fe539a3d
@ -14,6 +14,7 @@ use App\Filters\HttpRequestLog;
|
||||
use App\Filters\CloseDbConnection;
|
||||
|
||||
use App\Filters\AuthJWT;
|
||||
use App\Filters\Cors;
|
||||
|
||||
class Filters extends BaseConfig
|
||||
{
|
||||
@ -35,6 +36,7 @@ class Filters extends BaseConfig
|
||||
'HttpRequestLog' => HttpRequestLog::class,
|
||||
'authJWT' => AuthJWT::class,
|
||||
'CloseDbConnection' => CloseDbConnection::class
|
||||
'Cors' => Cors::class
|
||||
];
|
||||
|
||||
/**
|
||||
@ -47,11 +49,13 @@ class Filters extends BaseConfig
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
'HttpRequestLog' => ['except' => 'cli/*'],
|
||||
'Cors',
|
||||
// 'csrf',
|
||||
// 'invalidchars',
|
||||
],
|
||||
'after' => [
|
||||
'CloseDbConnection'
|
||||
'Cors',
|
||||
// 'secureheaders',
|
||||
],
|
||||
];
|
||||
|
||||
422
app/Filters/Cors.php
Normal file
422
app/Filters/Cors.php
Normal file
@ -0,0 +1,422 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
|
||||
/**
|
||||
* CORS (Cross-Origin Resource Sharing) Filter
|
||||
*
|
||||
* Handles CORS preflight requests and adds appropriate CORS headers to responses.
|
||||
* Configurable via environment variables for flexibility across different environments.
|
||||
*
|
||||
* @package App\Filters
|
||||
*/
|
||||
class Cors implements FilterInterface
|
||||
{
|
||||
/**
|
||||
* List of allowed origins (domains that can access this API)
|
||||
* Can include wildcards like *.example.com
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $allowedOrigins = [];
|
||||
|
||||
/**
|
||||
* Whether to allow credentials (cookies, authorization headers) in CORS requests
|
||||
* WARNING: Cannot be true if using wildcard (*) origin
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected bool $allowCredentials = false;
|
||||
|
||||
/**
|
||||
* HTTP methods allowed for CORS requests
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $allowedMethods = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
|
||||
|
||||
/**
|
||||
* HTTP headers allowed in CORS requests
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $allowedHeaders = 'Content-Type,Authorization,X-Requested-With,Accept,Origin';
|
||||
|
||||
/**
|
||||
* Headers exposed to the client (accessible via JavaScript)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $exposeHeaders = '';
|
||||
|
||||
/**
|
||||
* How long (in seconds) the preflight response can be cached
|
||||
* Default: 24 hours (86400 seconds)
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected int $maxAge = 86400;
|
||||
|
||||
/**
|
||||
* Whether to enable debug logging for CORS requests
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected bool $debug = false;
|
||||
|
||||
/**
|
||||
* Initialize CORS configuration from environment variables
|
||||
*
|
||||
* @throws \RuntimeException If configuration is invalid
|
||||
*/
|
||||
|
||||
protected $myLogger;
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
$this->myLogger = \Config\Services::mylogger();
|
||||
// Parse allowed origins from environment variable
|
||||
// Format: comma or semicolon separated list
|
||||
// Examples: "https://example.com,https://app.example.com" or "*.example.com"
|
||||
$raw = env('CORS_ALLOWED_ORIGINS', '*');
|
||||
$parts = preg_split('/\s*[,;]\s*/', trim($raw));
|
||||
$this->allowedOrigins = array_filter(array_map('trim', $parts));
|
||||
|
||||
// Load other configuration from environment
|
||||
$this->allowCredentials = filter_var(
|
||||
env('CORS_ALLOW_CREDENTIALS', false),
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
$this->allowedMethods = env('CORS_ALLOWED_METHODS', $this->allowedMethods);
|
||||
$this->allowedHeaders = env('CORS_ALLOWED_HEADERS', $this->allowedHeaders);
|
||||
$this->exposeHeaders = env('CORS_EXPOSE_HEADERS', $this->exposeHeaders);
|
||||
$this->maxAge = (int) env('CORS_MAX_AGE', $this->maxAge);
|
||||
$this->debug = filter_var(env('CORS_DEBUG', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
// Security validation: wildcard origin cannot be used with credentials
|
||||
// This is a browser security requirement, not just a best practice
|
||||
if ($this->allowCredentials && in_array('*', $this->allowedOrigins, true)) {
|
||||
throw new \RuntimeException(
|
||||
'CORS configuration error: Cannot use wildcard (*) origin with credentials enabled. ' .
|
||||
'This violates browser security policies. Either disable credentials or specify explicit origins.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->log('CORS filter initialized', [
|
||||
'allowed_origins' => $this->allowedOrigins,
|
||||
'allow_credentials' => $this->allowCredentials,
|
||||
'allowed_methods' => $this->allowedMethods,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given origin is allowed to access this API
|
||||
*
|
||||
* Supports:
|
||||
* - Exact matches: https://example.com
|
||||
* - Wildcard origins: *.example.com
|
||||
* - Scheme-less matching: example.com (matches http and https)
|
||||
* - Universal wildcard: *
|
||||
*
|
||||
* @param string|null $origin The Origin header from the request
|
||||
* @return bool True if origin is allowed, false otherwise
|
||||
*/
|
||||
protected function isOriginAllowed(?string $origin): bool
|
||||
{
|
||||
// Reject empty origins
|
||||
if (empty($origin)) {
|
||||
$this->log('Origin rejected: empty origin header');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate origin format - must include scheme (http:// or https://)
|
||||
// This prevents malformed origins from being accepted
|
||||
if (!preg_match('#^https?://#i', $origin)) {
|
||||
$this->log('Origin rejected: invalid format (missing scheme)', ['origin' => $origin]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If wildcard present in configuration, allow any origin
|
||||
if (in_array('*', $this->allowedOrigins, true)) {
|
||||
$this->log('Origin allowed: wildcard match', ['origin' => $origin]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse the host from the origin for wildcard matching
|
||||
// Example: https://app.example.com:8080 → app.example.com
|
||||
$originHost = parse_url($origin, PHP_URL_HOST) ?: $origin;
|
||||
|
||||
foreach ($this->allowedOrigins as $allowed) {
|
||||
if ($allowed === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Exact match (including scheme and port)
|
||||
// Example: https://example.com matches https://example.com
|
||||
if (strcasecmp($allowed, $origin) === 0) {
|
||||
$this->log('Origin allowed: exact match', [
|
||||
'origin' => $origin,
|
||||
'matched_rule' => $allowed
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Handle scheme-less and wildcard patterns
|
||||
// If the allowed entry doesn't contain ://, it's either a host-only or wildcard pattern
|
||||
if (strpos($allowed, '://') === false) {
|
||||
|
||||
// 2a. Wildcard subdomain pattern: *.example.com
|
||||
// Matches: app.example.com, api.example.com, dev.app.example.com
|
||||
// Does NOT match: example.com (use explicit entry for root domain)
|
||||
if (strpos($allowed, '*.') === 0) {
|
||||
$allowedRoot = substr($allowed, 2); // Remove *. prefix
|
||||
|
||||
// Check if origin host ends with the allowed root domain
|
||||
if ($originHost === $allowedRoot || str_ends_with($originHost, '.' . $allowedRoot)) {
|
||||
$this->log('Origin allowed: wildcard subdomain match', [
|
||||
'origin' => $origin,
|
||||
'matched_rule' => $allowed,
|
||||
'origin_host' => $originHost
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 2b. Direct host match (scheme-less)
|
||||
// Allows both http and https for the same host
|
||||
// Example: example.com matches both http://example.com and https://example.com
|
||||
else {
|
||||
if (strcasecmp($allowed, $originHost) === 0) {
|
||||
$this->log('Origin allowed: host match (scheme-less)', [
|
||||
'origin' => $origin,
|
||||
'matched_rule' => $allowed,
|
||||
'origin_host' => $originHost
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No match found - reject this origin
|
||||
$this->log('Origin rejected: no matching rule', [
|
||||
'origin' => $origin,
|
||||
'checked_rules' => $this->allowedOrigins
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Access-Control-Allow-Origin header value
|
||||
*
|
||||
* Returns either:
|
||||
* - '*' if wildcard is configured and credentials are disabled
|
||||
* - The actual origin value if credentials are enabled or specific origins configured
|
||||
*
|
||||
* Note: When credentials are enabled, you MUST echo back the specific origin,
|
||||
* browsers reject wildcard with credentials.
|
||||
*
|
||||
* @param string $origin The validated origin
|
||||
* @return string The value for Access-Control-Allow-Origin header
|
||||
*/
|
||||
protected function buildAllowOriginHeader(string $origin): string
|
||||
{
|
||||
// If wildcard configured and credentials NOT required, can safely return '*'
|
||||
// This allows any origin to access the resource
|
||||
if (in_array('*', $this->allowedOrigins, true) && !$this->allowCredentials) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
// Otherwise, must return the specific origin
|
||||
// This is required when allow-credentials is true
|
||||
return $origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all CORS headers to the response
|
||||
*
|
||||
* This method is called for both preflight and actual requests
|
||||
* to ensure consistent CORS headers across all responses.
|
||||
*
|
||||
* @param ResponseInterface $response The response object to add headers to
|
||||
* @param RequestInterface $request The original request
|
||||
* @param string $origin The validated origin
|
||||
* @param bool $isPreflight Whether this is a preflight OPTIONS request
|
||||
* @return void
|
||||
*/
|
||||
protected function addCorsHeaders(
|
||||
ResponseInterface $response,
|
||||
RequestInterface $request,
|
||||
string $origin,
|
||||
bool $isPreflight = false
|
||||
): void {
|
||||
// CRITICAL: Vary header prevents caching issues
|
||||
// Without this, a cached response for origin A might be served to origin B,
|
||||
// causing CORS errors because the Access-Control-Allow-Origin won't match
|
||||
$response->setHeader('Vary', 'Origin');
|
||||
|
||||
// Set the allowed origin
|
||||
$allowOrigin = $this->buildAllowOriginHeader($origin);
|
||||
$response->setHeader('Access-Control-Allow-Origin', $allowOrigin);
|
||||
|
||||
// If credentials are allowed, set the header
|
||||
// This allows cookies, authorization headers, and TLS client certificates
|
||||
if ($this->allowCredentials) {
|
||||
$response->setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
// Allowed HTTP methods
|
||||
$response->setHeader('Access-Control-Allow-Methods', $this->allowedMethods);
|
||||
|
||||
// Handle allowed headers
|
||||
if ($isPreflight) {
|
||||
// For preflight: respect what the browser is asking for
|
||||
// The browser sends Access-Control-Request-Headers to ask permission
|
||||
$requestedHeaders = $request->getHeaderLine('Access-Control-Request-Headers');
|
||||
$response->setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
$requestedHeaders ?: $this->allowedHeaders
|
||||
);
|
||||
} else {
|
||||
// For actual requests: use configured headers
|
||||
// Access-Control-Request-Headers is only for preflight
|
||||
$response->setHeader('Access-Control-Allow-Headers', $this->allowedHeaders);
|
||||
}
|
||||
|
||||
// Expose additional headers to the client (accessible via JavaScript)
|
||||
// Without this, only simple headers are accessible: Cache-Control, Content-Language,
|
||||
// Content-Type, Expires, Last-Modified, Pragma
|
||||
if (!empty($this->exposeHeaders)) {
|
||||
$response->setHeader('Access-Control-Expose-Headers', $this->exposeHeaders);
|
||||
}
|
||||
|
||||
// Cache duration for preflight responses
|
||||
// Reduces preflight requests by allowing browser to cache the permissions
|
||||
if ($this->maxAge > 0) {
|
||||
$response->setHeader('Access-Control-Max-Age', (string) $this->maxAge);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute before the controller
|
||||
*
|
||||
* Handles preflight OPTIONS requests by returning early with appropriate headers.
|
||||
* For other requests, allows them to proceed to the controller.
|
||||
*
|
||||
* @param RequestInterface $request The request object
|
||||
* @param mixed $arguments Optional arguments
|
||||
* @return ResponseInterface|null Response for preflight, null for other requests
|
||||
*/
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$origin = $request->getHeaderLine('Origin') ?: '';
|
||||
$method = strtoupper($request->getMethod());
|
||||
|
||||
// Handle preflight OPTIONS requests
|
||||
// Preflight is sent by browsers before actual cross-origin requests
|
||||
// to check if the actual request is safe to send
|
||||
if ($method === 'OPTIONS') {
|
||||
$this->log('Preflight request received', [
|
||||
'origin' => $origin,
|
||||
'method' => $method,
|
||||
'uri' => (string) $request->getUri()
|
||||
]);
|
||||
|
||||
// Validate origin - reject if not allowed
|
||||
if (empty($origin) || !$this->isOriginAllowed($origin)) {
|
||||
$this->log('Preflight rejected: origin not allowed', ['origin' => $origin]);
|
||||
|
||||
// Return 403 Forbidden for rejected origins
|
||||
// Some prefer 200 with no CORS headers, but 403 is more explicit
|
||||
return Services::response()
|
||||
->setStatusCode(403)
|
||||
->setJSON(['error' => 'Origin not allowed']);
|
||||
}
|
||||
|
||||
// Origin is valid - build preflight response
|
||||
$response = Services::response();
|
||||
$this->addCorsHeaders($response, $request, $origin, true);
|
||||
|
||||
// 204 No Content is the standard response for successful preflight
|
||||
// It indicates "permission granted, but no data to return"
|
||||
$response->setStatusCode(204);
|
||||
$response->setBody('');
|
||||
|
||||
$this->log('Preflight approved', [
|
||||
'origin' => $origin,
|
||||
'allowed_methods' => $this->allowedMethods
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// For non-OPTIONS requests, don't return a response
|
||||
// Let the request proceed to the controller
|
||||
// CORS headers will be added in after() method
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute after the controller
|
||||
*
|
||||
* Adds CORS headers to the response for actual (non-preflight) requests.
|
||||
* This ensures all API responses include proper CORS headers.
|
||||
*
|
||||
* @param RequestInterface $request The request object
|
||||
* @param ResponseInterface $response The response object
|
||||
* @param mixed $arguments Optional arguments
|
||||
* @return void
|
||||
*/
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
$origin = $request->getHeaderLine('Origin') ?: '';
|
||||
|
||||
// Only add CORS headers if origin is present and allowed
|
||||
// No origin header means it's a same-origin request (no CORS needed)
|
||||
if (empty($origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isOriginAllowed($origin)) {
|
||||
$this->log('Response blocked: origin not allowed', [
|
||||
'origin' => $origin,
|
||||
'uri' => (string) $request->getUri()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add CORS headers to the response
|
||||
$this->addCorsHeaders($response, $request, $origin, false);
|
||||
|
||||
$this->log('CORS headers added to response', [
|
||||
'origin' => $origin,
|
||||
'status' => $response->getStatusCode()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug information if debug mode is enabled
|
||||
*
|
||||
* Logs to CodeIgniter's log system at 'info' level.
|
||||
* Enable with CORS_DEBUG=true in .env file.
|
||||
*
|
||||
* @param string $message The log message
|
||||
* @param array $context Additional context data
|
||||
* @return void
|
||||
*/
|
||||
protected function log(string $message, array $context = []): void
|
||||
{
|
||||
if (!$this->debug) {
|
||||
return;
|
||||
}
|
||||
|
||||
// $logger = Services::logger();
|
||||
$contextString = !empty($context) ? json_encode($context, JSON_UNESCAPED_SLASHES) : '';
|
||||
$this->myLogger->logme('error','[CORS] ' . $message . ($contextString ? ' | ' . $contextString : ''));
|
||||
// $logger->info('[CORS] ' . $message . ($contextString ? ' | ' . $contextString : ''));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user