fix(paypal_v2): Webhook-Signatur mit rohem Body pruefen, Ablehnungsgrund protokollieren

This commit is contained in:
Thomas Bartelt
2026-09-13 21:19:36 +02:00
parent 83cf8b9808
commit 4248f8c148
2 changed files with 32 additions and 15 deletions
+3 -2
View File
@@ -46,10 +46,11 @@ final class Client
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>}
*/
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
public function request(string $method, string $path, array|string|null $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge(['Content-Type: application/json', 'Authorization: Bearer ' . $this->accessToken()], $extraHeaders);
$bodyStr = $payload === null ? null : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE);
// string = fertiges JSON (z.B. mit dem rohen Webhook-Body eingebettet), wird unveraendert gesendet
$bodyStr = $payload === null ? null : (\is_string($payload) ? $payload : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE));
[$status, $body] = $this->raw($method, $path, $bodyStr, $headers);
$decoded = $body === '' ? [] : \json_decode($body, true);
return [$status, \is_array($decoded) ? $decoded : ['raw' => $body]];
+29 -13
View File
@@ -212,8 +212,11 @@ final class Provider extends AbstractProvider
}
$type = (string) ($event['event_type'] ?? '');
if (!$this->verifyWebhook($headers, $event)) {
Log::write($this->module(), 'webhook', null, null, (string) ($event['id'] ?? ''), 'rejected', null, ['event_type' => $type], 'Signatur ungueltig');
$verify = $this->verifyWebhook($headers, $rawBody);
if ($verify !== 'SUCCESS') {
Log::write($this->module(), 'webhook', null, null, (string) ($event['id'] ?? ''), 'rejected', null,
['event_type' => $type, 'header_keys' => \array_keys(\array_filter($headers, static fn($k) => \str_starts_with($k, 'paypal-'), ARRAY_FILTER_USE_KEY))],
'Signatur ungueltig: ' . $verify);
return 400;
}
@@ -251,30 +254,43 @@ final class Provider extends AbstractProvider
return 200;
}
/** @param array<string,string> $headers @param array<mixed> $event */
private function verifyWebhook(array $headers, array $event): bool
/**
* Signaturpruefung gegen PayPal. Der rohe Body wird UNVERAENDERT als webhook_event
* eingebettet -- PayPal hat die Signatur ueber genau diesen Text gebildet; ein
* json_decode/encode in PHP aendert Escaping und Reihenfolge und laesst die
* Pruefung scheitern.
*
* @param array<string,string> $headers
* @return string 'SUCCESS' oder Grund der Ablehnung
*/
private function verifyWebhook(array $headers, string $rawBody): string
{
$webhookId = Secrets::get($this->db, $this->module(), 'webhook_id') ?? '';
if ($webhookId === '') {
\error_log('PayPal v2 Webhook: keine webhook_id hinterlegt -- Event abgelehnt.');
return false;
return 'keine webhook_id hinterlegt';
}
$payload = [
$fields = [
'transmission_id' => $headers['paypal-transmission-id'] ?? '',
'transmission_time' => $headers['paypal-transmission-time'] ?? '',
'cert_url' => $headers['paypal-cert-url'] ?? '',
'auth_algo' => $headers['paypal-auth-algo'] ?? '',
'transmission_sig' => $headers['paypal-transmission-sig'] ?? '',
'webhook_id' => $webhookId,
'webhook_event' => $event,
];
foreach (['transmission_id', 'transmission_time', 'cert_url', 'auth_algo', 'transmission_sig'] as $k) {
if ($payload[$k] === '') {
return false;
foreach ($fields as $k => $v) {
if ($v === '') {
return 'Header fehlt: ' . $k;
}
}
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $payload);
return $status === 200 && (($body['verification_status'] ?? '') === 'SUCCESS');
$json = \json_encode($fields, JSON_UNESCAPED_SLASHES);
$json = \substr($json, 0, -1) . ',"webhook_event":' . \trim($rawBody) . '}';
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $json);
$vs = (string) ($body['verification_status'] ?? '');
if ($status === 200 && $vs === 'SUCCESS') {
return 'SUCCESS';
}
return 'HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body));
}
// -------------------------------------------------------------- reconcile