From 4248f8c1483a3a68aca3e67bb1d5a8ccbc48a8f1 Mon Sep 17 00:00:00 2001 From: Thomas Bartelt Date: Sun, 13 Sep 2026 21:19:36 +0200 Subject: [PATCH] fix(paypal_v2): Webhook-Signatur mit rohem Body pruefen, Ablehnungsgrund protokollieren --- core/payments/providers/paypal_v2/Client.php | 5 ++- .../payments/providers/paypal_v2/Provider.php | 42 +++++++++++++------ 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/core/payments/providers/paypal_v2/Client.php b/core/payments/providers/paypal_v2/Client.php index 528b55b..aa14a87 100644 --- a/core/payments/providers/paypal_v2/Client.php +++ b/core/payments/providers/paypal_v2/Client.php @@ -46,10 +46,11 @@ final class Client * @param list $extraHeaders * @return array{0:int,1:array} */ - 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]]; diff --git a/core/payments/providers/paypal_v2/Provider.php b/core/payments/providers/paypal_v2/Provider.php index 8cf2559..55e6ff3 100644 --- a/core/payments/providers/paypal_v2/Provider.php +++ b/core/payments/providers/paypal_v2/Provider.php @@ -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 $headers @param array $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 $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