Aenderungen an Codeberg-Issues landeten bisher erst beim naechsten Lauf des sync-Services auf dem Board (MONITOR_SYNC_INTERVAL, Default 600s). Neu meldet Codeberg jede Aenderung aktiv per Webhook, der Weg aufs Board dauert damit wenige Sekunden (Webhook -> Sync -> SSE-Push ans Frontend). POST /api/v1/webhooks/codeberg wertet den Payload bewusst nicht aus, sondern fordert nur einen regulaeren Sync an - CodebergSyncService bleibt die einzige Stelle, die Issues auf Tickets abbildet, und ein verpasster Webhook ist folgenlos, weil der periodische Lauf denselben Stand ohnehin nachholt. Der Sync passiert nicht im Request: Forgejo bricht die Zustellung nach wenigen Sekunden ab (DELIVER_TIMEOUT, Default 5s), ein Sync macht aber mehrere Requests nach codeberg.org. Der Endpunkt setzt daher nur eine Trigger-Datei im gemeinsamen ./api-Volume (Sync\SyncTrigger) und antwortet sofort mit 202; der sync-Service laeuft jetzt im Watch-Modus und greift sie im Sekundentakt auf. Authentifiziert wird per HMAC-SHA256 ueber den rohen Request-Body (X-Forgejo-Signature bzw. X-Gitea-Signature) - Codeberg kennt unsere API-Keys nicht. Ohne gesetztes CODEBERG_WEBHOOK_SECRET weist der Endpunkt grundsaetzlich mit 401 ab, damit ein vergessenes Secret keinen offenen Sync-Ausloeser im Internet hinterlaesst. Request traegt dafuer neu den unveraenderten rawBody. Anders als der sync-Service braucht der api-Service damit eine eingehende Freigabe in Proxy und Firewall (POST /api/v1/webhooks/codeberg). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
2.9 KiB
PHP
98 lines
2.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Monitor\Api\Tests\Auth;
|
|
|
|
use Monitor\Api\Auth\WebhookAuthenticator;
|
|
use Monitor\Api\Http\Request;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class WebhookAuthenticatorTest extends TestCase
|
|
{
|
|
private const SECRET = 'geheim';
|
|
private const PAYLOAD = '{"action":"opened","issue":{"number":7}}';
|
|
|
|
public function testAcceptsForgejoSignature(): void
|
|
{
|
|
$auth = new WebhookAuthenticator(self::SECRET);
|
|
|
|
self::assertTrue($auth->authenticate($this->requestWithSignature(
|
|
'X-Forgejo-Signature',
|
|
hash_hmac('sha256', self::PAYLOAD, self::SECRET)
|
|
)));
|
|
}
|
|
|
|
public function testAcceptsGiteaSignatureHeaderForCompatibility(): void
|
|
{
|
|
$auth = new WebhookAuthenticator(self::SECRET);
|
|
|
|
self::assertTrue($auth->authenticate($this->requestWithSignature(
|
|
'X-Gitea-Signature',
|
|
hash_hmac('sha256', self::PAYLOAD, self::SECRET)
|
|
)));
|
|
}
|
|
|
|
public function testRejectsSignatureCreatedWithAnotherSecret(): void
|
|
{
|
|
$auth = new WebhookAuthenticator(self::SECRET);
|
|
|
|
self::assertFalse($auth->authenticate($this->requestWithSignature(
|
|
'X-Forgejo-Signature',
|
|
hash_hmac('sha256', self::PAYLOAD, 'falsches-secret')
|
|
)));
|
|
}
|
|
|
|
/**
|
|
* Die Signatur muss ueber den rohen Body gebildet werden: ein Angreifer darf den Payload
|
|
* nicht veraendern koennen, ohne dass die Pruefsumme bricht.
|
|
*/
|
|
public function testRejectsSignatureForDifferentPayload(): void
|
|
{
|
|
$auth = new WebhookAuthenticator(self::SECRET);
|
|
|
|
$request = Request::create(
|
|
'POST',
|
|
'/api/v1/webhooks/codeberg',
|
|
[],
|
|
[],
|
|
['X-Forgejo-Signature' => hash_hmac('sha256', self::PAYLOAD, self::SECRET)],
|
|
'{"action":"closed","issue":{"number":7}}'
|
|
);
|
|
|
|
self::assertFalse($auth->authenticate($request));
|
|
}
|
|
|
|
public function testRejectsRequestWithoutSignature(): void
|
|
{
|
|
$auth = new WebhookAuthenticator(self::SECRET);
|
|
|
|
$request = Request::create('POST', '/api/v1/webhooks/codeberg', [], [], [], self::PAYLOAD);
|
|
|
|
self::assertFalse($auth->authenticate($request));
|
|
}
|
|
|
|
public function testRejectsEverythingWhenNoSecretIsConfigured(): void
|
|
{
|
|
foreach ([null, ''] as $secret) {
|
|
$auth = new WebhookAuthenticator($secret);
|
|
|
|
self::assertFalse($auth->authenticate($this->requestWithSignature(
|
|
'X-Forgejo-Signature',
|
|
hash_hmac('sha256', self::PAYLOAD, (string) $secret)
|
|
)));
|
|
}
|
|
}
|
|
|
|
private function requestWithSignature(string $header, string $signature): Request
|
|
{
|
|
return Request::create(
|
|
'POST',
|
|
'/api/v1/webhooks/codeberg',
|
|
[],
|
|
[],
|
|
[$header => $signature],
|
|
self::PAYLOAD
|
|
);
|
|
}
|
|
}
|