From 85843e99c780ada37de3c375e8e0cad1dc7fa698 Mon Sep 17 00:00:00 2001 From: Pier-Paolo Mammi Date: Thu, 16 Jul 2026 15:18:24 +0200 Subject: [PATCH] add CORS manager with relative docs --- bootstrap.php | 8 ++ config/config.php.template | 39 ++++++++ config/secrets.php.template | 4 +- docs/CORS.md | 168 +++++++++++++++++++++++++++++++++++ public/index.php | 43 +++++++++ src/Api/Core/CorsManager.php | 112 +++++++++++++++++++++++ 6 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 docs/CORS.md create mode 100644 src/Api/Core/CorsManager.php diff --git a/bootstrap.php b/bootstrap.php index d30281d..656a5b4 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/vendor/autoload.php'; use Api\Core\Container; use Api\Core\Config; use Api\Core\HttpClient; +use Api\Core\CorsManager; use Api\Core\RateLimiter\RateLimiterInterface; use Api\Core\RateLimiter\FileRateLimiter; use Api\Core\RateLimiter\InMemoryRateLimiter; @@ -25,6 +26,13 @@ $container->singleton(HttpClient::class, function() { return new HttpClient(); }); +// CORS manager binding +$container->singleton(CorsManager::class, function($c) { + $config = $c->make(Config::class); + $corsConfig = $config->get('cors', []); + return new CorsManager($corsConfig); +}); + // Rate limiter driver configurabile via config or env: 'file' or 'memory' $rlDriver = $config->get('rate_limiter_driver', 'file'); diff --git a/config/config.php.template b/config/config.php.template index 17f0b3e..18b7604 100644 --- a/config/config.php.template +++ b/config/config.php.template @@ -1,10 +1,49 @@ 'https://api.example.com', + 'rate_limiter_driver' => 'file', // o 'memory' // configuration for file-based rate limiter 'rate_limit_storage_dir' => sys_get_temp_dir() . '/api_rate_limit', // configuration for memory-based rate limiter 'rate_limit_requests' => 100, 'rate_limit_window_seconds' => 60, + + 'cors' => [ + 'enabled' => true, + // Allowed origins - requests from other origins will be rejected + 'allowed_origins' => [ + 'http://localhost:3000', // Local development - frontend + 'http://localhost:8080', // Local development - alternative port + 'https://app.example.com', // Production frontend + 'https://admin.example.com', // Production admin panel + // 'http://localhost:*', // Allow any port on localhost (not recommended) + // '*' // Allow all origins (HIGHLY NOT RECOMMENDED for production) + ], + // HTTP methods allowed for CORS requests + 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + // HTTP headers allowed in the request + 'allowed_headers' => [ + 'Content-Type', + 'Authorization', + 'X-Requested-With', + 'Accept', + 'Accept-Language', + 'Content-Language', + 'X-API-Key', + ], + // HTTP headers exposed to the client + 'exposed_headers' => [ + 'Content-Length', + 'X-JSON-Response-Code', + 'X-Rate-Limit-Limit', + 'X-Rate-Limit-Remaining', + 'X-Rate-Limit-Reset', + ], + // Allow credentials (cookies, authorization headers) in cross-origin requests + // Only set to true if you understand the security implications + 'allow_credentials' => false, + // How long (in seconds) the browser can cache the preflight response + 'max_age' => 86400, // 24 hours + ], ]; diff --git a/config/secrets.php.template b/config/secrets.php.template index 4193a63..a7b27d8 100644 --- a/config/secrets.php.template +++ b/config/secrets.php.template @@ -1,8 +1,10 @@ 'myApiAccessToken', + // elixforms API credentials 'elixforms_api_username' => 'myUser', 'elixforms_api_password' => 'myPass', 'elixforms_api_token' => 'mySecretToken', - 'api_access_token' => 'myApiAccessToken', ]; diff --git a/docs/CORS.md b/docs/CORS.md new file mode 100644 index 0000000..1de7232 --- /dev/null +++ b/docs/CORS.md @@ -0,0 +1,168 @@ +# CORS Management Documentation + +## Overview + +This API now includes comprehensive CORS (Cross-Origin Resource Sharing) management. CORS allows your API to be accessed from web applications hosted on different domains. + +## How It Works + +### Automatic CORS Header Application +All API responses automatically include CORS headers when CORS is enabled. This allows web applications from configured origins to access your API. + +### Preflight Request Handling +The API automatically handles preflight OPTIONS requests (sent by browsers before actual requests to CORS-protected resources). These are handled with a 204 No Content response. + +### Origin Validation +Only requests from configured allowed origins are accepted. Requests from unauthorized origins are rejected. + +## Configuration + +Edit `config/config.php` to configure CORS: + +```php +'cors' => [ + 'enabled' => true, // Enable/disable CORS + 'allowed_origins' => [ + 'http://localhost:3000', + 'https://example.com', + ], + 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + 'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'], + 'exposed_headers' => ['Content-Length', 'X-JSON-Response-Code'], + 'allow_credentials' => false, + 'max_age' => 86400, +], +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | `true` | Enable or disable CORS | +| `allowed_origins` | array | `[]` | List of domains allowed to access the API. Use `'*'` to allow all (NOT recommended) | +| `allowed_methods` | array | `GET, POST, PUT, DELETE, PATCH, OPTIONS` | HTTP methods allowed | +| `allowed_headers` | array | `Content-Type, Authorization, X-Requested-With, Accept` | Headers allowed in requests | +| `exposed_headers` | array | `Content-Length, X-JSON-Response-Code` | Headers exposed to the client | +| `allow_credentials` | bool | `false` | Allow credentials (cookies, auth) in requests | +| `max_age` | int | `86400` | Browser cache time for preflight (seconds) | + +## Setup for Different Environments + +### Development +```php +'cors' => [ + 'enabled' => true, + 'allowed_origins' => [ + 'http://localhost:3000', + 'http://localhost:8080', + 'http://127.0.0.1:3000', + ], + 'allow_credentials' => false, + 'max_age' => 3600, +], +``` + +### Production +```php +'cors' => [ + 'enabled' => true, + 'allowed_origins' => [ + 'https://app.example.com', + 'https://admin.example.com', + ], + 'allow_credentials' => false, + 'max_age' => 86400, +], +``` + +### Allow All Origins (NOT RECOMMENDED for Production) +```php +'cors' => [ + 'enabled' => true, + 'allowed_origins' => ['*'], + 'allow_credentials' => false, +], +``` + +## Browser Preflight Requests + +When making cross-origin requests with certain headers or methods (like PUT or DELETE), browsers automatically send a preflight OPTIONS request. The API handles these automatically: + +``` +Browser sends: OPTIONS /api/resource +API responds: 204 No Content + CORS headers +Browser sees: Request is allowed, proceeds with actual request +``` + +## Testing CORS + +### Using curl +```bash +# Test CORS with curl +curl -H "Origin: http://localhost:3000" \ + -H "Access-Control-Request-Method: POST" \ + -H "Access-Control-Request-Headers: Content-Type" \ + -X OPTIONS \ + http://localhost:8000/api/users/index -v +``` + +### Expected Response Headers +``` +HTTP/1.1 204 No Content +Access-Control-Allow-Origin: http://localhost:3000 +Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS +Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, Accept +Access-Control-Max-Age: 86400 +``` + +## Security Considerations + +1. **Be Specific with Origins**: Always specify exact allowed origins. Don't use `'*'` in production unless absolutely necessary. + +2. **Credentials**: Only set `allow_credentials: true` if you understand the security implications. This allows cross-origin requests to send cookies. + +3. **Sensitive Headers**: Don't expose sensitive headers in `exposed_headers`. Only expose what clients actually need. + +4. **HTTPS in Production**: Always use HTTPS in production to prevent man-in-the-middle attacks. + +## CorsManager Class + +The `CorsManager` class handles all CORS logic. You can also use it programmatically: + +```php +$corsManager = $container->make(\Api\Core\CorsManager::class); + +// Check if current request is allowed +if ($corsManager->isOriginAllowed()) { + // Process request +} + +// Apply CORS headers manually +$corsManager->applyHeaders(); + +// Check for preflight request +if ($corsManager->isPreflightRequest()) { + $corsManager->handlePreflight(); +} +``` + +## Troubleshooting + +### Preflight request fails +- Check that the origin in the request matches one in `allowed_origins` +- Verify CORS is enabled in config +- Check browser console for CORS error messages + +### Missing CORS headers in response +- Ensure CORS is enabled: `'enabled' => true` +- Verify the requesting origin is in `allowed_origins` +- Check that `CorsManager` is properly initialized in the container + +### "No Access-Control-Allow-Origin header" +- Browser origin is not in `allowed_origins` +- Add the origin or use `'*'` (only for development) + +## References + +- [MDN: CORS Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) +- [OWASP: CORS](https://owasp.org/www-community/CORS) diff --git a/public/index.php b/public/index.php index 0ce0a83..78d458c 100644 --- a/public/index.php +++ b/public/index.php @@ -5,6 +5,7 @@ use Api\Auth\ApiTokenAuthenticator; use Api\Core\Router; use Api\Core\Request; use Api\Core\Response; +use Api\Core\CorsManager; use Api\Core\RateLimiter\RateLimiterInterface; use Psr\Log\LoggerInterface; @@ -32,6 +33,48 @@ $router = new Router($container); // vedi nota: router può ricevere container $request = new Request(); $response = new Response(); +// CORS handling +$corsManager = $container->make(CorsManager::class); +$corsConfig = $container->make(\Api\Core\Config::class)->get('cors', []); + +if (!empty($corsConfig['enabled'])) { + $origin = $corsManager->getOrigin(); + $requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? 'none'; + + // Log CORS request details for debugging + $logger->debug('CORS request received', [ + 'request_origin' => $requestOrigin, + 'allowed_origin' => $origin, + 'is_preflight' => $corsManager->isPreflightRequest(), + 'method' => $_SERVER['REQUEST_METHOD'], + 'path' => $request->path() + ]); + + // Apply CORS headers to all responses + $corsManager->applyHeaders(); + + // Handle preflight OPTIONS requests + if ($corsManager->isPreflightRequest()) { + $corsManager->handlePreflight(); + } +} + +// Diagnostic CORS endpoint (no auth required) +if ($request->path() === '/cors-check' && $request->method() === 'GET') { + $corsManager = $container->make(CorsManager::class); + $corsConfig = $container->make(\Api\Core\Config::class)->get('cors', []); + + $response->json([ + 'cors_enabled' => !empty($corsConfig['enabled']), + 'request_origin' => $_SERVER['HTTP_ORIGIN'] ?? null, + 'allowed_origins' => $corsConfig['allowed_origins'] ?? [], + 'is_origin_allowed' => $corsManager->isOriginAllowed(), + 'is_preflight' => $corsManager->isPreflightRequest(), + 'request_method' => $_SERVER['REQUEST_METHOD'], + 'headers_sent' => function_exists('getallheaders') ? getallheaders() : $_SERVER, + ]); +} + // Autenticazione separata per le API esterne if (strpos($request->path(), '/api/') === 0) { try { diff --git a/src/Api/Core/CorsManager.php b/src/Api/Core/CorsManager.php new file mode 100644 index 0000000..52aaf78 --- /dev/null +++ b/src/Api/Core/CorsManager.php @@ -0,0 +1,112 @@ +allowedOrigins = $config['allowed_origins'] ?? []; + $this->allowedMethods = $config['allowed_methods'] ?? ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']; + $this->allowedHeaders = $config['allowed_headers'] ?? ['Content-Type', 'Authorization', 'X-Requested-With']; + $this->exposedHeaders = $config['exposed_headers'] ?? ['Content-Length', 'X-JSON-Response-Code']; + $this->allowCredentials = $config['allow_credentials'] ?? false; + $this->maxAge = $config['max_age'] ?? 86400; + } + + /** + * Get the allowed origin for the current request + */ + public function getOrigin(): ?string { + $origin = $_SERVER['HTTP_ORIGIN'] ?? null; + + if (!$origin) { + return null; + } + + // Check if origin is in allowed list + if (in_array('*', $this->allowedOrigins)) { + return '*'; + } + + if (in_array($origin, $this->allowedOrigins)) { + return $origin; + } + + return null; + } + + /** + * Check if the current request is a preflight OPTIONS request + */ + public function isPreflightRequest(): bool { + return $_SERVER['REQUEST_METHOD'] === 'OPTIONS'; + } + + /** + * Apply CORS headers to the response + */ + public function applyHeaders(?string $origin = null): void { + if ($origin === null) { + $origin = $this->getOrigin(); + } + + if (!$origin) { + // If no valid origin, don't set CORS headers + // This ensures blocked origins don't accidentally get access + header('Vary: Origin'); + return; + } + + header('Access-Control-Allow-Origin: ' . $origin); + header('Access-Control-Allow-Methods: ' . implode(', ', $this->allowedMethods)); + header('Access-Control-Allow-Headers: ' . implode(', ', $this->allowedHeaders)); + header('Access-Control-Expose-Headers: ' . implode(', ', $this->exposedHeaders)); + header('Vary: Origin'); + + if ($this->allowCredentials) { + header('Access-Control-Allow-Credentials: true'); + } + + header('Access-Control-Max-Age: ' . $this->maxAge); + } + + /** + * Handle preflight OPTIONS request + */ + public function handlePreflight(): void { + $origin = $this->getOrigin(); + + if ($origin) { + $this->applyHeaders($origin); + http_response_code(204); + exit; + } + + http_response_code(403); + exit; + } + + /** + * Check if origin is allowed + */ + public function isOriginAllowed(?string $origin = null): bool { + if ($origin === null) { + $origin = $this->getOrigin(); + } + + if (!$origin) { + return false; + } + + if (in_array('*', $this->allowedOrigins)) { + return true; + } + + return in_array($origin, $this->allowedOrigins); + } +}