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:
@@ -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`
|
||||
|
||||
@@ -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
117
src/Http/CorsPolicy.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
143
tests/Http/CorsPolicyTest.php
Normal file
143
tests/Http/CorsPolicyTest.php
Normal 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,
|
||||
]],
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user