Skip to content

Commit 033e83a

Browse files
author
Daniel Neto
committed
fix: Update webhook signature verification and add manual registration endpoint
https://github.com/WWBN/AVideo/security/advisories/GHSA-95jh-7r58-xmxw#event-592245
1 parent 36dfae2 commit 033e83a

File tree

3 files changed

+108
-71
lines changed

3 files changed

+108
-71
lines changed

plugin/AuthorizeNet/AuthorizeNet.php

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -43,24 +43,21 @@ class AuthorizeNet extends PluginAbstract
4343
/**
4444
* Validate ANet webhook signature (HMAC-SHA512).
4545
*
46+
* The Signature Key is used as a raw ASCII string in the HMAC — not hex-decoded.
47+
*
4648
* @param string $rawBody
4749
* @param array $headers
48-
* @param string $signatureKeyHex
49-
* @return array{valid:bool,expected:string,received:string}
50+
* @param string $signatureKey
51+
* @return bool
5052
*/
51-
public static function verifySignature(string $rawBody, array $headers, string $signatureKeyHex): array
53+
public static function verifySignature(string $rawBody, array $headers, string $signatureKey): bool
5254
{
5355
$received = $headers['X-ANET-Signature'] ?? ($headers['x-anet-signature'] ?? '');
54-
if (empty($signatureKeyHex) || !ctype_xdigit($signatureKeyHex) || empty($received)) {
55-
return ['valid' => false, 'expected' => '', 'received' => $received];
56+
if (empty($signatureKey) || empty($received)) {
57+
return false;
5658
}
57-
$keyBin = hex2bin($signatureKeyHex);
58-
$expected = 'sha512=' . hash_hmac('sha512', $rawBody, $keyBin);
59-
return [
60-
'valid' => hash_equals($expected, $received),
61-
'expected' => $expected,
62-
'received' => $received
63-
];
59+
$expected = 'sha512=' . strtolower(hash_hmac('sha512', $rawBody, $signatureKey));
60+
return hash_equals($expected, strtolower($received));
6461
}
6562

6663
/**
@@ -86,22 +83,17 @@ public static function verifySignature(string $rawBody, array $headers, string $
8683
*/
8784
public static function parseWebhookRequest(string $rawBody, array $headers, array $allowedEvents = ['net.authorize.payment.authcapture.created']): array
8885
{
89-
$cfg = self::getConfig();
90-
$sig = self::verifySignature($rawBody, $headers, trim($cfg->signatureKey ?? ''));
86+
$cfg = self::getConfig();
87+
$sigValid = self::verifySignature($rawBody, $headers, trim((string)($cfg->signatureKey ?? '')));
9188

9289
$json = json_decode($rawBody, true);
9390
if (!is_array($json)) {
94-
return ['error' => true, 'msg' => 'Invalid JSON', 'signatureValid' => $sig['valid']];
91+
return ['error' => true, 'msg' => 'Invalid JSON', 'signatureValid' => $sigValid];
9592
}
9693

97-
$eventType = $json['eventType'] ?? '';
94+
$eventType = $json['eventType'] ?? '';
9895
if (!in_array($eventType, $allowedEvents)) {
99-
return [
100-
'error' => true,
101-
'msg' => 'Ignored event type',
102-
'eventType' => $eventType,
103-
'signatureValid' => $sig['valid']
104-
];
96+
return ['error' => true, 'msg' => 'Ignored event type', 'eventType' => $eventType, 'signatureValid' => $sigValid];
10597
}
10698

10799
$payload = $json['payload'] ?? [];
@@ -110,21 +102,20 @@ public static function parseWebhookRequest(string $rawBody, array $headers, arra
110102
$currency = $payload['currencyCode'] ?? ($payload['currency'] ?? null);
111103
$metadata = $payload['metadata'] ?? [];
112104
$users_id = isset($metadata['users_id']) ? (int)$metadata['users_id'] : null;
113-
114-
$uniq_key = sha1($eventType . ($transactionId ?? 'no-txn'));
105+
$uniq_key = sha1($eventType . ($transactionId ?? 'no-txn'));
115106

116107
return [
117-
'error' => false,
118-
'data' => $json,
119-
'payload' => $payload,
120-
'eventType' => $eventType,
121-
'transactionId' => $transactionId,
122-
'amount' => $amount,
123-
'currency' => $currency,
124-
'metadata' => $metadata,
125-
'users_id' => $users_id,
126-
'uniq_key' => $uniq_key,
127-
'signatureValid' => $sig['valid']
108+
'error' => false,
109+
'data' => $json,
110+
'payload' => $payload,
111+
'eventType' => $eventType,
112+
'transactionId' => $transactionId,
113+
'amount' => $amount,
114+
'currency' => $currency,
115+
'metadata' => $metadata,
116+
'users_id' => $users_id,
117+
'uniq_key' => $uniq_key,
118+
'signatureValid' => $sigValid,
128119
];
129120
}
130121

@@ -1024,7 +1015,7 @@ public static function getTransactionDetails(string $transactionId): array
10241015
$submitTime = method_exists($txn, 'getSubmitTimeUTC') ? $txn->getSubmitTimeUTC() : null;
10251016
$responseCode = $txn->getResponseCode() ?? '';
10261017
$status = $txn->getTransactionStatus() ?? '';
1027-
$isApproved = $responseCode == 1 && in_array($status, ['capturedPendingSettlement', 'settledSuccessfully'], true);
1018+
$isApproved = $responseCode == 1 && in_array(strtolower($status), ['capturedpendingsettlement', 'settledsuccessfully'], true);
10281019

10291020
// ---- NEW: get description and decode metadata ----
10301021
$orderDescription = ($order && method_exists($order, 'getDescription')) ? (string)$order->getDescription() : null;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
/**
3+
* Admin-only endpoint to manually (re)register the AuthorizeNet webhook.
4+
* Use this after changing API credentials in the plugin settings.
5+
*
6+
* GET/POST /plugin/AuthorizeNet/registerWebhook.json.php
7+
*/
8+
header('Content-Type: application/json');
9+
10+
require_once __DIR__ . '/../../videos/configuration.php';
11+
require_once $global['systemRootPath'] . 'objects/user.php';
12+
13+
if (!User::isAdmin()) {
14+
http_response_code(403);
15+
echo json_encode(['error' => true, 'msg' => 'Permission denied']);
16+
exit;
17+
}
18+
19+
require_once $global['systemRootPath'] . 'plugin/AuthorizeNet/AuthorizeNet.php';
20+
21+
$cfg = AVideoPlugin::getDataObject('AuthorizeNet');
22+
if (empty($cfg->apiLoginId) || empty($cfg->transactionKey) || empty($cfg->signatureKey)) {
23+
echo json_encode(['error' => true, 'msg' => 'AuthorizeNet credentials not fully configured. Set API Login ID, Transaction Key and Signature Key first.']);
24+
exit;
25+
}
26+
27+
$result = AuthorizeNet::createWebhookIfNotExists(['net.authorize.payment.authcapture.created']);
28+
29+
$response = [
30+
'error' => !empty($result['error']),
31+
'msg' => $result['msg'] ?? null,
32+
'webhookId' => $result['webhookId'] ?? null,
33+
'status' => $result['status'] ?? null,
34+
'webhookUrl' => AuthorizeNet::getWebhookURL(),
35+
];
36+
37+
_error_log('[AuthorizeNet] Manual webhook registration: ' . json_encode($response));
38+
39+
echo json_encode($response);

plugin/AuthorizeNet/webhook.php

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,26 @@
66
$rawBody = file_get_contents('php://input');
77
$headers = getallheaders();
88

9-
// 1) Parse + signature
9+
// 1) Parse + signature — reject immediately if signature is invalid
1010
$parsed = AuthorizeNet::parseWebhookRequest($rawBody, $headers);
1111
if (!empty($parsed['error'])) {
1212
_error_log('[Authorize.Net webhook] ' . $parsed['msg']);
1313
http_response_code(200);
1414
echo $parsed['msg'] ?? 'ignored';
1515
exit;
1616
}
17-
18-
_error_log('[Authorize.Net webhook] Event: ' . $parsed['eventType']);
19-
_error_log('[Authorize.Net webhook] uniq_key: ' . $parsed['uniq_key']);
17+
if (!$parsed['signatureValid']) {
18+
$sigHeader = $headers['X-ANET-Signature'] ?? ($headers['x-anet-signature'] ?? '');
19+
_error_log('[Authorize.Net webhook] Bad signature'
20+
. ' | event=' . $parsed['eventType']
21+
. ' | txn=' . ($parsed['transactionId'] ?? 'n/a')
22+
. ' | body_len=' . strlen($rawBody)
23+
. ' | sig_header=' . (empty($sigHeader) ? 'missing' : 'present')
24+
);
25+
http_response_code(401);
26+
echo 'invalid signature';
27+
exit;
28+
}
2029

2130
// 2) Dedup
2231
if (Anet_webhook_log::alreadyProcessed($parsed['uniq_key'])) {
@@ -26,45 +35,52 @@
2635
exit;
2736
}
2837

29-
// 3) Fetch txn (fallback/confirm)
38+
// 3) Fetch txn details for enrichment
3039
$txnInfo = AuthorizeNet::getTransactionDetails($parsed['transactionId']);
3140

32-
// 4) Block only if both invalid signature AND no valid txn info
33-
if (!$parsed['signatureValid'] && (empty($txnInfo) || !empty($txnInfo['error']))) {
34-
_error_log('[Authorize.Net webhook] Bad signature and could not confirm transaction');
35-
http_response_code(401);
36-
echo 'invalid signature';
37-
exit;
38-
}
39-
40-
// 5) Analyze payload + raw txn
41+
// 4) Analyze payload + raw txn
4142
$analysis = AuthorizeNet::analyzeTransactionFromWebhook($parsed['payload'], $txnInfo['raw'] ?? null);
4243

43-
// Fill missing basics from txnInfo if needed
44-
if (!$analysis['users_id'] && !empty($txnInfo['users_id'])) {
44+
// Always prefer the Authorize.Net transaction details over webhook payload values.
45+
if (!empty($txnInfo['users_id'])) {
4546
$analysis['users_id'] = (int)$txnInfo['users_id'];
4647
}
47-
if (!$analysis['amount'] && isset($txnInfo['amount'])) {
48+
if (isset($txnInfo['amount'])) {
4849
$analysis['amount'] = (float)$txnInfo['amount'];
4950
}
50-
if (!$analysis['currency'] && !empty($txnInfo['currency'])) {
51+
if (!empty($txnInfo['currency'])) {
5152
$analysis['currency'] = $txnInfo['currency'];
5253
}
53-
if (!$analysis['isApproved'] && !empty($txnInfo['isApproved'])) {
54+
if (array_key_exists('isApproved', $txnInfo)) {
5455
$analysis['isApproved'] = (bool)$txnInfo['isApproved'];
5556
}
56-
if (!$analysis['plans_id'] && !empty($txnInfo['plans_id'])) {
57+
if (!empty($txnInfo['plans_id'])) {
5758
$analysis['plans_id'] = (int)$txnInfo['plans_id'];
5859
}
59-
_error_log('[Authorize.Net webhook] Analysis: ' . json_encode($analysis));
60-
61-
if(empty($analysis['users_id']) || empty($analysis['amount'])) {
62-
_error_log('[Authorize.Net webhook] Missing user ID or amount in analysis');
60+
if (empty($analysis['users_id']) || empty($analysis['amount'])) {
61+
_error_log('[Authorize.Net webhook] Missing user ID or amount'
62+
. ' | txn=' . ($parsed['transactionId'] ?? 'n/a')
63+
. ' | users_id=' . ($analysis['users_id'] ?? 'null')
64+
. ' | amount=' . ($analysis['amount'] ?? 'null')
65+
. ' | txn_lookup=' . (!empty($txnInfo['error']) ? 'error:' . $txnInfo['msg'] : 'ok')
66+
. ' | txn_email=' . ($txnInfo['email'] ?? 'n/a')
67+
);
6368
http_response_code(400);
6469
echo 'missing user ID or amount';
6570
exit;
6671
}
6772

73+
if (empty($analysis['isApproved'])) {
74+
_error_log('[Authorize.Net webhook] Transaction not approved'
75+
. ' | txn=' . ($parsed['transactionId'] ?? 'n/a')
76+
. ' | status=' . ($txnInfo['status'] ?? 'n/a')
77+
. ' | responseCode=' . ($txnInfo['responseCode'] ?? 'n/a')
78+
);
79+
http_response_code(400);
80+
echo 'transaction not approved';
81+
exit;
82+
}
83+
6884
$result = AuthorizeNet::processSinglePayment(
6985
$analysis['users_id'],
7086
(float)$analysis['amount'],
@@ -134,14 +150,5 @@
134150
}
135151
}
136152

137-
// 9) Return success response
138-
echo json_encode([
139-
'success' => true,
140-
'users_id' => $analysis['users_id'],
141-
'subscription' => $analysis['isASubscription'],
142-
'subscriptionId' => $analysis['subscriptionId'] ?? ($subscriptionResult['subscriptionId'] ?? null),
143-
'newSubscription' => $shouldCreateSubscription,
144-
'subscriptionCreated' => !empty($subscriptionResult) && empty($subscriptionResult['error']),
145-
'sigValid' => $parsed['signatureValid'],
146-
'logId' => $result['logId'] ?? null
147-
]);
153+
http_response_code(200);
154+
echo json_encode(['success' => true]);

0 commit comments

Comments
 (0)