Ausgehende Webhook-Zustellung
Ausgehende Webhooks benachrichtigen Drittsysteme, wenn Ereignisse in der Anwendung auftreten. Die primären Sicherheitsbedenken sind SSRF (Anfragen an interne Infrastruktur senden), Secret-Leak und Signaturintegrität.
Kernkomponenten
- Endpoint-Registry: speichert URL, Event-Filter und ein gehashtes Secret pro Subscriber.
- Delivery-Queue: ein Datensatz pro (Endpoint, Event)-Paar, verfolgt Versuchsanzahl und Status.
- Signer: generiert HMAC-SHA256-Signaturen, die der Empfänger verifizieren kann.
- URL-Validator: blockiert SSRF-Ziele vor der Speicherung von Endpunkten.
Schema
sql
CREATE TABLE webhook_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
event_type TEXT NOT NULL,
secret_hash TEXT NOT NULL, -- SHA-256 des rohen Secrets; rohes Secret wird nie gespeichert
max_retries INTEGER NOT NULL DEFAULT 3,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE webhook_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint_id INTEGER NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | delivered | failed
attempt_count INTEGER NOT NULL DEFAULT 0,
last_status INTEGER, -- letzter HTTP-Antwortcode
last_error TEXT,
delivered_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);Nur der SHA-256-Hash des Secrets wird gespeichert. Das rohe Secret wird nie persistiert — wenn die Datenbank kompromittiert wird, können Hashes nicht rückgängig gemacht werden, um Signaturen zu fälschen (SHA-256 ohne HMAC ist für ein zufälliges 32-Byte-Secret nicht umkehrbar).
Signaturformat
X-Webhook-Signature: sha256={hex}
X-Webhook-Timestamp: {unix_timestamp}Signierter Inhalt: {timestamp}.{body} — bindet die Signatur sowohl an das Payload als auch an einen Zeitpunkt.
php
public function sign(string $rawSecret, string $body, string $timestamp): string
{
$payload = $timestamp . '.' . $body;
$mac = hash_hmac('sha256', $payload, $rawSecret);
return 'sha256=' . $mac;
}Der Timestamp im signierten Inhalt verhindert Replay-Angriffe: Ein Angreifer, der einen gültigen Webhook erfasst, kann ihn nicht später wiederverwenden, weil der Timestamp veraltet wäre. Empfänger sollten Signaturen ablehnen, die älter als ein Schwellenwert (z. B. 5 Minuten) sind.
SSRF-Prävention
Jede Webhook-URL vor der Speicherung validieren. Mindestens blockieren:
php
final class UrlValidator
{
public function validate(string $url): ?string
{
// CRLF/Null-Byte-Injection blockieren
if (str_contains($url, "\n") || str_contains($url, "\r") || str_contains($url, "\0")) {
return 'URL contains illegal control characters.';
}
// Nur HTTPS
if (strtolower(parse_url($url, PHP_URL_SCHEME) ?? '') !== 'https') {
return 'Only HTTPS URLs are allowed.';
}
// Private/Loopback-IPs und reservierte Hostnamen blockieren
// ...
}
}Zu blockierende private IPv4-Bereiche: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 0.0.0.0.
Zu blockierende Hostnamen: localhost, *.local, *.internal, *.test, *.invalid.
IPv6: ::1, fc00::/7 (ULA), fe80::/10 (link-local).
DNS-Rebinding: Die URL nur bei der Registrierung zu validieren ist nicht ausreichend — der DNS-Eintrag könnte sich zwischen Registrierung und Zustellung ändern, um auf eine interne IP zu zeigen. Für die Produktion auch die aufgelöste IP bei der Zustellung validieren, bevor die TCP-Verbindung geöffnet wird.
Antwortfilterung — Secrets niemals preisgeben
Die toArray()-Methode auf WebhookEndpoint muss sowohl secret als auch secret_hash weglassen:
php
public function toArray(): array
{
return [
'id', 'url', 'event_type', 'max_retries', 'active', 'created_at',
// secret_hash absichtlich weggelassen
];
}Dies gilt für: GET /webhooks/{id}, Endpunkte auflisten und jedes Audit-Log, das Endpoint-Metadaten aufzeichnet.
Wiederholungslogik
php
public function markFailed(int $id, string $error, ?int $httpStatus, string $now, int $maxRetries): ?WebhookDelivery
{
$newCount = $delivery->attemptCount + 1;
$newStatus = $newCount >= $maxRetries ? 'failed' : 'pending';
$this->executor->execute(
'UPDATE webhook_deliveries SET status = ?, attempt_count = ?, last_error = ?, updated_at = ? WHERE id = ?',
[$newStatus, $newCount, $error, $now, $id],
);
}attempt_count < max_retries→ Status bleibtpending→ Worker nimmt ihn erneut auf.attempt_count >= max_retries→ Status wirdfailed→ Keine weiteren Wiederholungen.
Worker sollten exponentielles Backoff implementieren (z. B. 2^attempt_count Sekunden), um einen kämpfenden Empfänger nicht zu überlasten.
Deaktivierung
Deaktivierte Endpunkte (active = 0) werden beim Dispatch aus der Fan-Out-Abfrage ausgeschlossen:
sql
SELECT * FROM webhook_endpoints WHERE event_type = ? AND active = 1Das gibt Subscribern eine Möglichkeit, die Zustellung zu pausieren ohne ihre Registrierung zu löschen.
Designentscheidungen
Warum secret_hash statt rohes Secret speichern? Wenn die DB kompromittiert wird, kann der Angreifer keine Secrets extrahieren, um an Empfänger gesendete Webhook-Signaturen zu fälschen. Das rohe Secret wird einmalig bei der Erstellung zurückgegeben und muss vom Aufrufer sicher gespeichert werden.
Warum Timestamp in der Signatur einschließen? Signaturen ohne Timestamps sind unbegrenzt wiederholbar. Das Einschließen von {timestamp}.{body} im HMAC bedeutet, dass ein Angreifer, der einen Webhook abfängt, ihn nicht erneut senden kann — Empfänger können Timestamps außerhalb eines ±5-Minuten-Fensters ablehnen.
Warum URL bei Registrierung und nicht bei Dispatch validieren? Das Blockieren ungültiger URLs bei der Registrierung gibt dem Subscriber sofortiges Feedback und verhindert, dass schlechte Daten in die Delivery-Queue gelangen. DNS-Rebinding-Angriffe erfordern zusätzliche Validierung zum Zeitpunkt der Zustellung.