fix(paypal_v2): Webhook-Signatur lokal pruefen (Zertifikat, CRC32, SHA256withRSA); API-Verify nur als Rueckfall
This commit is contained in:
@@ -282,15 +282,72 @@ final class Provider extends AbstractProvider
|
|||||||
return 'Header fehlt: ' . $k;
|
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 = \json_encode($fields, JSON_UNESCAPED_SLASHES);
|
||||||
$json = \substr($json, 0, -1) . ',"webhook_event":' . \trim($rawBody) . '}';
|
$json = \substr($json, 0, -1) . ',"webhook_event":' . \trim($rawBody) . '}';
|
||||||
|
|
||||||
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $json);
|
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $json);
|
||||||
$vs = (string) ($body['verification_status'] ?? '');
|
$vs = (string) ($body['verification_status'] ?? '');
|
||||||
if ($status === 200 && $vs === 'SUCCESS') {
|
if ($status === 200 && $vs === 'SUCCESS') {
|
||||||
return 'SUCCESS';
|
return 'SUCCESS';
|
||||||
}
|
}
|
||||||
return 'HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body));
|
return 'lokal: ' . $local . '; API: HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,string> $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
|
// -------------------------------------------------------------- reconcile
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
$status = $provider->onWebhook($headers, $rawBody);
|
$status = $provider->onWebhook($headers, $rawBody);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user