Validate Origin against allowlist with proper CORS semantics

Replaces the dumb config-string pass-through with a CorsPolicy value
object. Origin is matched against an explicit allowlist; on miss, no
Allow-Origin header is emitted (default-deny). Wildcard origin is
still supported but rejected at construction when combined with
allow_credentials=true (incompatible per Fetch Standard).

Vary: Origin is added to all responses except pure ['*'] without
credentials, preventing cache poisoning across origins.
Allow-Credentials is emitted only when configured and an origin was
actually allowed. Allow-Methods / Allow-Headers are now preflight-only,
as the spec dictates.

OPTIONS preflight runs the same Origin resolution before any routing
or middleware, so disallowed origins receive 204 with no CORS headers
(plus Vary).

Config schema changes: allowed_origins / allowed_methods /
allowed_headers are arrays now, plus allow_credentials. Pre-1.0
breaking change; old single-string keys are no longer recognized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 03:36:55 +03:00
parent f8b28c6d55
commit 0f68d610f5
6 changed files with 387 additions and 55 deletions

View File

@@ -105,24 +105,46 @@ $app->handleRequest(
`readRawBody()` оставлен `protected` как тестовый хук для подмены SAPI-чтения в анонимных сабклассах.
## HttpApplication — конфигурационные зависимости
## CORS
`HttpApplication` требует наличия ключа `cors` в `config/app.php`. Конфиг читается при каждом
запросе (в т.ч. OPTIONS-preflight). Если ключ отсутствует — бросается
`\RuntimeException('app.cors config is missing')`.
CORS-политика инкапсулирована в `src/Http/CorsPolicy.php` (DI-резолвится автовайрингом). Ключ `app.cors` в конфиге обязателен — `CorsPolicy` бросает в конструкторе, если секции нет.
```php
// config/app.php
return [
'cors' => [
'origins' => '*',
'methods' => 'GET, POST, PUT, DELETE, OPTIONS',
'headers' => 'Content-Type, Authorization',
'max_age' => '86400',
// Точный allowlist. Допустимо ['*'] — но только если allow_credentials=false.
'allowed_origins' => ['https://app.example.com', 'https://staging.example.com'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization'],
'allow_credentials' => false,
'max_age' => 600,
],
];
```
**Резолв `Access-Control-Allow-Origin`:**
| Запрос | Конфиг | Что эмитится |
|---|---|---|
| Без `Origin` | любой | ничего — same-origin / curl, CORS-заголовки не нужны |
| `Origin` есть, allowlist `['*']`, credentials=false | wildcard | `Allow-Origin: *`, без `Vary` |
| `Origin` есть, allowlist `['*']`, credentials=true | wildcard + creds | **ошибка конфига**`\RuntimeException` в конструкторе. По спеке `*` несовместим с credentials |
| `Origin` есть, в allowlist | exact match | `Allow-Origin: <тот origin>` + `Vary: Origin` |
| `Origin` есть, не в allowlist | miss | заголовок не эмитится — браузер интерпретирует как fail |
**`Vary: Origin`** добавляется ко всем ответам кроме чистого `['*']` без credentials — защита от cache-poisoning, когда CDN/прокси может отдать кэшированный ответ другого origin'а.
**`Access-Control-Allow-Credentials: true`** эмитится только если включено в конфиге **и** мы выдали `Allow-Origin`.
**Preflight (OPTIONS)** обрабатывается до роутинга и middleware. Для разрешённого origin'а добавляются `Allow-Methods`, `Allow-Headers`, `Max-Age`. Для запрещённого — только `Vary: Origin` (без CORS-заголовков), 204.
**Simple-ответы** (non-OPTIONS) не содержат `Allow-Methods` / `Allow-Headers` — по спеке они preflight-only.
## HttpApplication — конфигурационные зависимости
`HttpApplication` зависит от `CorsPolicy`, `Router`, `MiddlewarePipeline`, `Config`, `LoggerInterface` — всё через DI-конструктор.
## Лимит размера тела запроса
`HttpApplication` ограничивает размер тела через ключ `http.max_body_bytes`

View File

@@ -13,7 +13,8 @@ HTTP → /worker.php → worker loop
$handler($get, $post, $cookie, $files, $server, $rawBody) →
HttpApplication::handleRequest($get, $post, $cookie, $files, $server, $rawBody)
→ Request::fromGlobals() // парсит метод, путь, заголовки, JSON body
→ OPTIONS? → CORS headers + 204 // preflight, без роутинга
→ OPTIONS? → CorsPolicy::preflightHeaders($origin) + 204
// origin валидируется по allowlist; default-deny при miss
→ Router::match(method, path) // regex-компиляция {param} → именованные группы
→ 404 / 405+Allow / RouteMatch
→ MiddlewarePipeline::run() // каждый middleware меняет Request через withContext()
@@ -38,6 +39,7 @@ HTTP → /worker.php → worker loop
| `src/Http/Request.php` | `Request` | Иммутабельный; `body()`, `get(key)`, `withContext()` |
| `src/Http/Response.php` | `Response` | `json()`, `error()`, `withHeader()`, `emit()` |
| `src/Http/Router.php` | `Router` | Regex-компиляция маршрутов, возвращает `RouteMatch` |
| `src/Http/CorsPolicy.php` | `CorsPolicy` | Allowlist по Origin, preflight/simple-заголовки, Vary |
| `src/Http/MiddlewarePipeline.php` | `MiddlewarePipeline` | Последовательно прогоняет middleware через DI |
| `src/Http/RouteDefinition.php` | `RouteDefinition` | method, path, controller, middleware[] |
| `src/Http/HttpException.php` | `HttpException` | Выбрасывается из любого места → Response::error |

117
src/Http/CorsPolicy.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
namespace Pronchev\Pinecore\Http;
use Pronchev\Pinecore\Config;
final class CorsPolicy
{
/** @var list<string> */
private readonly array $allowedOrigins;
/** @var list<string> */
private readonly array $allowedMethods;
/** @var list<string> */
private readonly array $allowedHeaders;
private readonly bool $allowCredentials;
private readonly int $maxAge;
private readonly bool $isWildcardOnly;
public function __construct(Config $config)
{
$cors = $config->get('app.cors');
if (!is_array($cors)) {
throw new \RuntimeException('app.cors config is missing');
}
$this->allowedOrigins = self::asStringList($cors, 'allowed_origins');
$this->allowedMethods = self::asStringList($cors, 'allowed_methods');
$this->allowedHeaders = self::asStringList($cors, 'allowed_headers');
$this->allowCredentials = (bool) ($cors['allow_credentials'] ?? false);
$this->maxAge = (int) ($cors['max_age'] ?? 600);
$this->isWildcardOnly = $this->allowedOrigins === ['*'];
if ($this->isWildcardOnly && $this->allowCredentials) {
// Per Fetch Standard: Access-Control-Allow-Origin: * is incompatible
// with Access-Control-Allow-Credentials: true. Browsers reject the
// combination. Fail at boot, not silently in prod.
throw new \RuntimeException(
'app.cors: allowed_origins=["*"] cannot be combined with allow_credentials=true. '
. 'List explicit origins instead.',
);
}
}
/**
* Headers attached to a normal (non-OPTIONS) response.
*
* @return array<string, string>
*/
public function simpleHeaders(?string $requestOrigin): array
{
$allowOrigin = $this->resolveOrigin($requestOrigin);
$headers = [];
if ($allowOrigin !== null) {
$headers['Access-Control-Allow-Origin'] = $allowOrigin;
if ($this->allowCredentials) {
$headers['Access-Control-Allow-Credentials'] = 'true';
}
}
if (!$this->isWildcardOnly) {
$headers['Vary'] = 'Origin';
}
return $headers;
}
/**
* Headers for an OPTIONS preflight response. When the origin is rejected,
* returns an empty array (or just Vary) — the browser treats the missing
* Allow-Origin as a default-deny.
*
* @return array<string, string>
*/
public function preflightHeaders(?string $requestOrigin): array
{
$allowOrigin = $this->resolveOrigin($requestOrigin);
$headers = [];
if ($allowOrigin !== null) {
$headers['Access-Control-Allow-Origin'] = $allowOrigin;
$headers['Access-Control-Allow-Methods'] = implode(', ', $this->allowedMethods);
$headers['Access-Control-Allow-Headers'] = implode(', ', $this->allowedHeaders);
$headers['Access-Control-Max-Age'] = (string) $this->maxAge;
if ($this->allowCredentials) {
$headers['Access-Control-Allow-Credentials'] = 'true';
}
}
if (!$this->isWildcardOnly) {
$headers['Vary'] = 'Origin';
}
return $headers;
}
private function resolveOrigin(?string $requestOrigin): ?string
{
if ($this->isWildcardOnly) {
// Already validated incompatibility with credentials in the ctor.
return '*';
}
if ($requestOrigin === null || $requestOrigin === '') {
return null;
}
return in_array($requestOrigin, $this->allowedOrigins, true) ? $requestOrigin : null;
}
/**
* @return list<string>
*/
private static function asStringList(array $cors, string $key): array
{
$value = $cors[$key] ?? [];
if (!is_array($value)) {
throw new \RuntimeException("app.cors.{$key} must be a list of strings");
}
return array_values(array_map('strval', $value));
}
}

View File

@@ -19,6 +19,7 @@ class HttpApplication
private readonly MiddlewarePipeline $pipeline,
private readonly Config $config,
private readonly LoggerInterface $logger,
private readonly CorsPolicy $cors,
) {
$this->maxBodyBytes = (int) ($config->get('http.max_body_bytes') ?? self::DEFAULT_MAX_BODY_BYTES);
}
@@ -32,18 +33,23 @@ class HttpApplication
?string $rawBody = null,
): void {
$method = strtoupper($server['REQUEST_METHOD'] ?? 'GET');
$origin = isset($server['HTTP_ORIGIN']) ? (string) $server['HTTP_ORIGIN'] : null;
// OPTIONS preflight — respond immediately with CORS headers, no routing or body parsing
// OPTIONS preflight — respond immediately with CORS headers, no routing or body parsing.
if ($method === 'OPTIONS') {
$this->emitCorsHeaders();
foreach ($this->cors->preflightHeaders($origin) as $name => $value) {
$this->sendHeader($name . ': ' . $value);
}
http_response_code(204);
return;
}
$corsHeaders = $this->cors->simpleHeaders($origin);
$declaredLength = (int) ($server['CONTENT_LENGTH'] ?? 0);
if ($this->maxBodyBytes > 0 && $declaredLength > $this->maxBodyBytes) {
Response::error('Payload Too Large', 413)
->withHeaders($this->corsHeaders())
->withHeaders($corsHeaders)
->emit();
return;
}
@@ -51,13 +57,13 @@ class HttpApplication
$rawBody ??= $this->readRawBody();
if ($this->maxBodyBytes > 0 && strlen($rawBody) > $this->maxBodyBytes) {
Response::error('Payload Too Large', 413)
->withHeaders($this->corsHeaders())
->withHeaders($corsHeaders)
->emit();
return;
}
$response = $this->dispatch($get, $post, $cookie, $files, $server, $rawBody);
$response = $response->withHeaders($this->corsHeaders());
$response = $response->withHeaders($corsHeaders);
$response->emit();
}
@@ -66,6 +72,15 @@ class HttpApplication
return (string) file_get_contents('php://input');
}
/**
* Hook for emitting raw header lines (preflight path). Tests override
* this to capture headers — `header()` is not observable in CLI.
*/
protected function sendHeader(string $line): void
{
header($line);
}
private function dispatch(
array $get, array $post, array $cookie, array $files, array $server, string $rawBody
): Response {
@@ -108,34 +123,4 @@ class HttpApplication
{
// Завершение работы приложения. Закрытие соединений, ресурсов и.т.п.
}
/** @return array<string, string> */
private function corsHeaders(): array
{
$cors = $this->corsConfig();
return [
'Access-Control-Allow-Origin' => $cors['origins'],
'Access-Control-Allow-Methods' => $cors['methods'],
'Access-Control-Allow-Headers' => $cors['headers'],
];
}
private function emitCorsHeaders(): void
{
$cors = $this->corsConfig();
header('Access-Control-Allow-Origin: ' . $cors['origins']);
header('Access-Control-Allow-Methods: ' . $cors['methods']);
header('Access-Control-Allow-Headers: ' . $cors['headers']);
header('Access-Control-Max-Age: ' . $cors['max_age']);
}
/** @return array<string, mixed> */
private function corsConfig(): array
{
$cors = $this->config->get('app.cors');
if (!is_array($cors)) {
throw new \RuntimeException('app.cors config is missing');
}
return $cors;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace Tests\Pronchev\Pinecore\Http;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Pronchev\Pinecore\Config;
use Pronchev\Pinecore\Http\CorsPolicy;
final class CorsPolicyTest extends TestCase
{
#[Test]
public function wildcardWithoutCredentialsReturnsStarAndOmitsVary(): void
{
$policy = self::policy(['*']);
$headers = $policy->simpleHeaders('https://anything.example.com');
self::assertSame('*', $headers['Access-Control-Allow-Origin']);
self::assertArrayNotHasKey('Vary', $headers);
self::assertArrayNotHasKey('Access-Control-Allow-Credentials', $headers);
}
#[Test]
public function wildcardWithCredentialsThrowsAtConstruction(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('cannot be combined with allow_credentials');
self::policy(['*'], allowCredentials: true);
}
#[Test]
public function allowlistMatchEchoesOriginAndAddsVary(): void
{
$policy = self::policy(['https://app.example.com', 'https://staging.example.com']);
$headers = $policy->simpleHeaders('https://app.example.com');
self::assertSame('https://app.example.com', $headers['Access-Control-Allow-Origin']);
self::assertSame('Origin', $headers['Vary']);
}
#[Test]
public function allowlistMissOmitsAllowOrigin(): void
{
$policy = self::policy(['https://app.example.com']);
$headers = $policy->simpleHeaders('https://evil.example.org');
self::assertArrayNotHasKey('Access-Control-Allow-Origin', $headers);
self::assertArrayNotHasKey('Access-Control-Allow-Credentials', $headers);
// Vary still emitted — caches must not key on origin.
self::assertSame('Origin', $headers['Vary']);
}
#[Test]
public function noOriginHeaderProducesNoAllowOrigin(): void
{
$policy = self::policy(['https://app.example.com']);
$headers = $policy->simpleHeaders(null);
self::assertArrayNotHasKey('Access-Control-Allow-Origin', $headers);
self::assertSame('Origin', $headers['Vary']);
}
#[Test]
public function credentialsHeaderEmittedOnlyWhenOriginAllowedAndConfigEnabled(): void
{
$policy = self::policy(['https://app.example.com'], allowCredentials: true);
$allowed = $policy->simpleHeaders('https://app.example.com');
$rejected = $policy->simpleHeaders('https://evil.example.org');
self::assertSame('true', $allowed['Access-Control-Allow-Credentials']);
self::assertArrayNotHasKey('Access-Control-Allow-Credentials', $rejected);
}
#[Test]
public function preflightForAllowedOriginIncludesMethodsHeadersAndMaxAge(): void
{
$policy = self::policy(['https://app.example.com']);
$headers = $policy->preflightHeaders('https://app.example.com');
self::assertSame('https://app.example.com', $headers['Access-Control-Allow-Origin']);
self::assertSame('GET, POST, OPTIONS', $headers['Access-Control-Allow-Methods']);
self::assertSame('Content-Type, Authorization', $headers['Access-Control-Allow-Headers']);
self::assertSame('600', $headers['Access-Control-Max-Age']);
}
#[Test]
public function preflightForRejectedOriginOmitsMethodsAndHeaders(): void
{
$policy = self::policy(['https://app.example.com']);
$headers = $policy->preflightHeaders('https://evil.example.org');
self::assertArrayNotHasKey('Access-Control-Allow-Origin', $headers);
self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers);
self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers);
self::assertArrayNotHasKey('Access-Control-Max-Age', $headers);
}
#[Test]
public function simpleHeadersDoNotIncludeMethodsOrHeadersList(): void
{
// Allow-Methods / Allow-Headers are preflight-only by spec.
$policy = self::policy(['https://app.example.com']);
$headers = $policy->simpleHeaders('https://app.example.com');
self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers);
self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers);
self::assertArrayNotHasKey('Access-Control-Max-Age', $headers);
}
#[Test]
public function missingCorsConfigSectionThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('app.cors config is missing');
new CorsPolicy(new Config([]));
}
/**
* @param list<string> $allowedOrigins
*/
private static function policy(array $allowedOrigins, bool $allowCredentials = false): CorsPolicy
{
return new CorsPolicy(new Config([
'app' => ['cors' => [
'allowed_origins' => $allowedOrigins,
'allowed_methods' => ['GET', 'POST', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization'],
'allow_credentials' => $allowCredentials,
'max_age' => 600,
]],
]));
}
}

View File

@@ -5,6 +5,7 @@ namespace Tests\Pronchev\Pinecore\Http;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Pronchev\Pinecore\Config;
use Pronchev\Pinecore\Http\CorsPolicy;
use Pronchev\Pinecore\Http\HttpApplication;
use Pronchev\Pinecore\Http\MiddlewarePipeline;
use Pronchev\Pinecore\Http\Response;
@@ -118,14 +119,57 @@ final class HttpApplicationTest extends TestCase
self::assertSame('{"ok":true}', $output);
}
/**
* Anonymous HttpApplication subclass that:
* - returns a fixed body from readRawBody()
* - counts how many times readRawBody() was called
* - uses a one-route Router and a controller returning {"ok":true}
*/
private static function makeApp(string $rawBody, int $maxBodyBytes): object
#[Test]
public function optionsPreflightFromAllowedOriginEmitsFullCorsHeaders(): void
{
$app = self::makeApp(allowedOrigins: ['https://app.example.com']);
self::captureOutput(fn() => $app->handleRequest(
get: [], post: [], cookie: [], files: [],
server: [
'REQUEST_METHOD' => 'OPTIONS',
'REQUEST_URI' => '/echo',
'HTTP_ORIGIN' => 'https://app.example.com',
],
));
self::assertSame(204, http_response_code());
self::assertSame('https://app.example.com', $app->capturedHeader('Access-Control-Allow-Origin'));
self::assertNotNull($app->capturedHeader('Access-Control-Allow-Methods'));
self::assertNotNull($app->capturedHeader('Access-Control-Allow-Headers'));
self::assertNotNull($app->capturedHeader('Access-Control-Max-Age'));
self::assertSame('Origin', $app->capturedHeader('Vary'));
}
#[Test]
public function optionsPreflightFromDisallowedOriginEmitsNoCorsHeaders(): void
{
$app = self::makeApp(allowedOrigins: ['https://app.example.com']);
self::captureOutput(fn() => $app->handleRequest(
get: [], post: [], cookie: [], files: [],
server: [
'REQUEST_METHOD' => 'OPTIONS',
'REQUEST_URI' => '/echo',
'HTTP_ORIGIN' => 'https://evil.example.org',
],
));
self::assertSame(204, http_response_code());
self::assertNull($app->capturedHeader('Access-Control-Allow-Origin'));
self::assertNull($app->capturedHeader('Access-Control-Allow-Methods'));
// Vary: Origin is still emitted to keep caches honest.
self::assertSame('Origin', $app->capturedHeader('Vary'));
}
/**
* @param list<string>|null $allowedOrigins
*/
private static function makeApp(
string $rawBody = '',
int $maxBodyBytes = 1024,
?array $allowedOrigins = null,
): object {
$controller = static fn() => Response::json(['ok' => true]);
$container = new class($controller) implements ContainerInterface {
@@ -139,13 +183,20 @@ final class HttpApplicationTest extends TestCase
$config = new Config([
'http' => ['max_body_bytes' => $maxBodyBytes],
'app' => ['cors' => [
'origins' => '*', 'methods' => '*', 'headers' => '*', 'max_age' => 600,
'allowed_origins' => $allowedOrigins ?? ['*'],
'allowed_methods' => ['GET', 'POST', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization'],
'allow_credentials' => false,
'max_age' => 600,
]],
]);
$logger = new NullLogger();
$logger = new NullLogger();
$cors = new CorsPolicy($config);
return new class($container, $router, $pipeline, $config, $logger, $rawBody) extends HttpApplication {
return new class($container, $router, $pipeline, $config, $logger, $cors, $rawBody) extends HttpApplication {
public int $readCount = 0;
/** @var array<string, string> */
private array $headers = [];
public function __construct(
ContainerInterface $container,
@@ -153,9 +204,10 @@ final class HttpApplicationTest extends TestCase
MiddlewarePipeline $pipeline,
Config $config,
NullLogger $logger,
CorsPolicy $cors,
private readonly string $rawBody,
) {
parent::__construct($container, $router, $pipeline, $config, $logger);
parent::__construct($container, $router, $pipeline, $config, $logger, $cors);
}
protected function readRawBody(): string
@@ -163,6 +215,17 @@ final class HttpApplicationTest extends TestCase
$this->readCount++;
return $this->rawBody;
}
protected function sendHeader(string $line): void
{
[$name, $value] = explode(':', $line, 2);
$this->headers[trim($name)] = trim($value);
}
public function capturedHeader(string $name): ?string
{
return $this->headers[$name] ?? null;
}
};
}