CORS-Preflight beantworten, damit das Projekte-Modal speichern kann

Schreibende Aufrufe aus dem Frontend (POST/PATCH/DELETE auf /repo-configs und
/agents) schicken X-Admin-Key und Content-Type: application/json. Beides macht
aus dem fetch() einen preflighted request: Der Browser fragt zuerst per OPTIONS
nach. Fuer OPTIONS war aber keine Route registriert, der Router warf daher
MethodNotAllowedException (405), und ausserdem fehlte
Access-Control-Allow-Headers fuer den X-Admin-Key. Der Browser sendete den
eigentlichen POST damit nie und das Modal meldete nur "Netzwerkfehler beim
Abrufen der Ressource" - Lesen funktionierte, weil GET ohne Custom-Header ein
simple request ohne Preflight ist.

Application::handle() beantwortet OPTIONS jetzt vor dem Routing mit 204, und
Response::send() liefert Allow-Methods, Allow-Headers und Max-Age mit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-03 18:56:57 +02:00
parent ebf7dba537
commit 2f5321ac8e
3 changed files with 41 additions and 0 deletions

View file

@ -119,6 +119,14 @@ final class Application
public function handle(Request $request): Response
{
// CORS-Preflight generisch beantworten, bevor der Router laeuft: OPTIONS ist fuer
// keinen Pfad registriert, der Router wuerde also MethodNotAllowedException (405)
// werfen. Der Browser sendet den eigentlichen POST/PATCH/DELETE dann gar nicht mehr
// und meldet nur einen Netzwerkfehler - genau das brach das Projekte-Modal am PO.
if ($request->method === 'OPTIONS') {
return Response::preflight();
}
try {
$match = $this->router->match($request);
} catch (MethodNotAllowedException) {

View file

@ -30,6 +30,15 @@ final class Response
return new self($status, ['error' => $message]);
}
/**
* Antwort auf einen CORS-Preflight (OPTIONS): 204 ohne Body, die eigentliche Auskunft
* stehen in den Access-Control-Headern aus send().
*/
public static function preflight(): self
{
return new self(204, null);
}
/**
* @param \Closure $writer - wird von send() mit gesetzten SSE-Headern aufgerufen und
* schreibt selbst per echo/flush() an den Client (typischerweise eine Polling-Schleife).
@ -58,6 +67,13 @@ final class Response
// liefern ohnehin nur oeffentliche GET-Daten bzw. verlangen fuer POST/PATCH einen
// gueltigen API-Key, ein offener Origin ist daher unkritisch.
header('Access-Control-Allow-Origin: *');
// Schreibende Aufrufe schicken X-Admin-Key/X-Api-Key und Content-Type: application/json.
// Beides macht aus dem fetch() einen "preflighted request": Der Browser fragt vorher per
// OPTIONS nach und bricht mit einem reinen Netzwerkfehler ab, wenn die erlaubten Methoden
// und Header fehlen - ohne dass der eigentliche POST je gesendet wird.
header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Admin-Key, X-Api-Key');
header('Access-Control-Max-Age: 86400');
if ($this->streamWriter !== null) {
http_response_code($this->status);
@ -80,6 +96,11 @@ final class Response
}
http_response_code($this->status);
if ($this->status === 204) {
return;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

View file

@ -132,6 +132,18 @@ final class ApplicationTest extends TestCase
self::assertIsArray($response->body()['repos']);
}
/**
* Der X-Admin-Key-Header macht aus dem fetch() im Projekte-Modal einen preflighted
* request. Wird OPTIONS nicht mit 2xx beantwortet, sendet der Browser den POST nie und
* das Modal zeigt nur "Netzwerkfehler beim Abrufen der Ressource".
*/
public function testCorsPreflightOnAdminEndpointSucceeds(): void
{
$response = $this->app->handle(Request::create('OPTIONS', '/api/v1/repo-configs'));
self::assertSame(204, $response->status);
}
public function testCreateRepoConfigRequiresAdminKey(): void
{
$response = $this->app->handle(Request::create('POST', '/api/v1/repo-configs', body: [