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;
|
||||
}
|
||||
}
|
||||
// 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<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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user