From 4ce09ac1b630b69b6acb0a3e9f462398b70d7bda Mon Sep 17 00:00:00 2001 From: Thomas Bartelt Date: Sun, 13 Sep 2026 21:32:17 +0200 Subject: [PATCH] fix(paypal_v2): Webhook-Signatur lokal pruefen (Zertifikat, CRC32, SHA256withRSA); API-Verify nur als Rueckfall --- .../payments/providers/paypal_v2/Provider.php | 61 ++++++++++++++++++- payment_webhook.php | 5 ++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/core/payments/providers/paypal_v2/Provider.php b/core/payments/providers/paypal_v2/Provider.php index 55e6ff3..ad4cb9a 100644 --- a/core/payments/providers/paypal_v2/Provider.php +++ b/core/payments/providers/paypal_v2/Provider.php @@ -282,15 +282,72 @@ final class Provider extends AbstractProvider return 'Header fehlt: ' . $k; } } + // 1. Lokale Pruefung (PayPal "self-verification"): Signatur ueber + // transmission_id|transmission_time|webhook_id|crc32(body), SHA256withRSA, + // mit dem Zertifikat von cert_url (nur *.paypal.com, https). + // Am 13.09.2026 in der Sandbox belegt: lokal GUELTIG, waehrend der + // API-Endpunkt verify-webhook-signature dieselbe Zustellung mit FAILURE beantwortet. + $local = $this->verifyLocally($fields, $rawBody); + if ($local === 'SUCCESS') { + return 'SUCCESS'; + } + + // 2. Rueckfall: PayPal fragen (roher Body eingebettet). $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)); + return 'lokal: ' . $local . '; API: HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body)); + } + + /** @param array $f */ + private function verifyLocally(array $f, string $rawBody): string + { + if (\strtoupper($f['auth_algo']) !== 'SHA256WITHRSA') { + return 'Algorithmus ' . $f['auth_algo']; + } + $u = \parse_url($f['cert_url']); + $host = \strtolower((string) ($u['host'] ?? '')); + if (($u['scheme'] ?? '') !== 'https' || !($host === 'paypal.com' || \str_ends_with($host, '.paypal.com'))) { + return 'cert_url nicht von paypal.com'; + } + $cert = $this->certificate($f['cert_url']); + if ($cert === '') { + return 'Zertifikat nicht ladbar'; + } + $pub = \openssl_pkey_get_public($cert); + if ($pub === false) { + return 'Zertifikat unlesbar'; + } + $msg = $f['transmission_id'] . '|' . $f['transmission_time'] . '|' . $f['webhook_id'] . '|' . \sprintf('%u', \crc32($rawBody)); + $sig = \base64_decode($f['transmission_sig'], true); + if ($sig === false) { + return 'Signatur kein Base64'; + } + return \openssl_verify($msg, $sig, $pub, OPENSSL_ALGO_SHA256) === 1 ? 'SUCCESS' : 'Signatur passt nicht'; + } + + /** Zertifikat von PayPal, 24 h neben dem Secrets-Schluessel zwischengespeichert. */ + private function certificate(string $url): string + { + $file = \dirname(Secrets::keyPath()) . '/paypal_cert_' . \md5($url) . '.pem'; + if (\is_file($file) && \filemtime($file) > \time() - 86400) { + return (string) \file_get_contents($file); + } + $ch = \curl_init($url); + \curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 10]); + $pem = (string) \curl_exec($ch); + $code = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE); + \curl_close($ch); + if ($code !== 200 || !\str_contains($pem, 'BEGIN CERTIFICATE')) { + return \is_file($file) ? (string) \file_get_contents($file) : ''; + } + @\file_put_contents($file, $pem, LOCK_EX); + @\chmod($file, 0600); + return $pem; } // -------------------------------------------------------------- reconcile diff --git a/payment_webhook.php b/payment_webhook.php index 043d833..b7dfa04 100644 --- a/payment_webhook.php +++ b/payment_webhook.php @@ -26,6 +26,11 @@ foreach ($_SERVER as $key => $value) { } } +// Letzte Zustellung roh ablegen (logs/ ist per .htaccess gesperrt) -- fuer die Fehlersuche +// an der Signaturpruefung. Enthaelt keine Zugangsdaten. +@\file_put_contents(__DIR__ . '/logs/webhook_last_' . \preg_replace('/[^a-z0-9_]/', '', $module) . '.json', + \json_encode(['time' => \date('c'), 'headers' => $headers, 'body' => $rawBody], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + try { $status = $provider->onWebhook($headers, $rawBody); } catch (\Throwable $e) {