← All changes
|
includes/square/gateway/class-charitable-square-webhook-processor.php
+157
-1
1.8.11
→
1.8.13
View file →
| @@ -962,9 +962,19 @@ | ||
| 962 | 962 | error_log( 'Square Webhook - Transaction ID: ' . $transaction_id ); |
| 963 | 963 | // phpcs:enable |
| 964 | 964 | } |
| 965 | 965 | |
| 966 | - $refund_amount = $charge['refund']['amount_money']['amount']; | |
| 966 | + $refund_amount = $charge['refund']['amount_money']['amount']; | |
| 967 | + $refund_currency = $charge['refund']['amount_money']['currency']; | |
| 968 | + | |
| 969 | + // Square sends amounts in the smallest currency unit (cents for | |
| 970 | + // USD, yen for JPY, etc.). process_refund() expects the site's | |
| 971 | + // display unit, so divide by 100 unless this is a zero-decimal | |
| 972 | + // currency. | |
| 973 | + if ( ! Charitable_Square_Gateway_Processor::is_zero_decimal_currency( $refund_currency ) ) { | |
| 974 | + $refund_amount = $refund_amount / 100; | |
| 975 | + } | |
| 976 | + | |
| 967 | 977 | if ( charitable_is_debug( 'square' ) ) { |
| 968 | 978 | // phpcs:disable |
| 969 | 979 | error_log( 'Square Webhook - Refund Amount: ' . $refund_amount ); |
| 970 | 980 | // phpcs:enable |
| @@ -1345,8 +1355,154 @@ | ||
| 1345 | 1355 | error_log( 'Raw webhook body: ' . $body ); |
| 1346 | 1356 | error_log( 'Request method: ' . $_SERVER['REQUEST_METHOD'] ); |
| 1347 | 1357 | error_log( 'Content type: ' . ( isset( $_SERVER['CONTENT_TYPE'] ) ? $_SERVER['CONTENT_TYPE'] : 'not set' ) ); |
| 1348 | 1358 | // phpcs:enable |
| 1359 | + } | |
| 1360 | + | |
| 1361 | + /* | |
| 1362 | + * Verify Square's webhook signature before trusting the payload. | |
| 1363 | + * | |
| 1364 | + * Square signs every webhook by computing an HMAC-SHA256 over | |
| 1365 | + * ( notification_url . request_body ) using the subscription's | |
| 1366 | + * signature key, and sends the base64 result in the | |
| 1367 | + * x-square-hmacsha256-signature header. We recompute it with the | |
| 1368 | + * stored signing secret and reject anything that does not match. | |
| 1369 | + * Without this an unauthenticated caller could POST a forged event | |
| 1370 | + * to the public charitable-listener=square_core endpoint and tamper | |
| 1371 | + * with donation, refund, and subscription records. | |
| 1372 | + * | |
| 1373 | + * @since 1.8.11.3 | |
| 1374 | + */ | |
| 1375 | + $signature_header = isset( $_SERVER['HTTP_X_SQUARE_HMACSHA256_SIGNATURE'] ) ? wp_unslash( $_SERVER['HTTP_X_SQUARE_HMACSHA256_SIGNATURE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- A cryptographic signature is compared byte-for-byte and must not be altered by sanitization. | |
| 1376 | + $mode = Charitable_Square_Helpers::get_mode(); | |
| 1377 | + | |
| 1378 | + /* | |
| 1379 | + * The signing secret is stored per-mode by the webhooks manager. | |
| 1380 | + * Lite saves it under the `gateways_square` group; read the | |
| 1381 | + * `gateways_square_core` group first for forward-compatibility and | |
| 1382 | + * fall back to `gateways_square` where it actually lives. | |
| 1383 | + */ | |
| 1384 | + $core_settings = charitable_get_option( 'gateways_square_core' ); | |
| 1385 | + $legacy_settings = charitable_get_option( 'gateways_square' ); | |
| 1386 | + $signature_key = ''; | |
| 1387 | + if ( is_array( $core_settings ) && ! empty( $core_settings[ $mode ]['webhooks-secret'] ) ) { | |
| 1388 | + $signature_key = $core_settings[ $mode ]['webhooks-secret']; | |
| 1389 | + } elseif ( is_array( $legacy_settings ) && ! empty( $legacy_settings[ $mode ]['webhooks-secret'] ) ) { | |
| 1390 | + $signature_key = $legacy_settings[ $mode ]['webhooks-secret']; | |
| 1391 | + } | |
| 1392 | + | |
| 1393 | + /* | |
| 1394 | + * Verify against the exact URL registered with Square | |
| 1395 | + * ( Charitable_Square_WebhooksManager registers with | |
| 1396 | + * get_webhook_url() ), so sites using the CHARITABLE_SQUARE_WHURL | |
| 1397 | + * override still validate. Fall back to the REST URL if needed. | |
| 1398 | + */ | |
| 1399 | + $notification_url = Charitable_Square_Helpers::get_webhook_url(); | |
| 1400 | + if ( empty( $notification_url ) ) { | |
| 1401 | + $notification_url = Charitable_Square_Helpers::get_webhook_url_for_rest(); | |
| 1402 | + } | |
| 1403 | + | |
| 1404 | + if ( charitable_is_debug( 'square' ) ) { | |
| 1405 | + // phpcs:disable | |
| 1406 | + error_log( 'Square webhook signature header: ' . ( ! empty( $signature_header ) ? 'present' : 'missing' ) ); | |
| 1407 | + error_log( 'Square webhook signing secret: ' . ( ! empty( $signature_key ) ? 'stored' : 'not stored' ) ); | |
| 1408 | + // phpcs:enable | |
| 1409 | + } | |
| 1410 | + | |
| 1411 | + if ( ! empty( $signature_key ) && ! empty( $signature_header ) ) { | |
| 1412 | + | |
| 1413 | + /* | |
| 1414 | + * Resolve the Square SDK webhook helper. Lite bundles the SDK | |
| 1415 | + * under the unprefixed \Square namespace; also accept a | |
| 1416 | + * prefixed (scoped) build so a future packaging change cannot | |
| 1417 | + * silently disable verification. | |
| 1418 | + */ | |
| 1419 | + $helper_class = ''; | |
| 1420 | + if ( class_exists( '\Square\Utils\WebhooksHelper' ) ) { | |
| 1421 | + $helper_class = '\Square\Utils\WebhooksHelper'; | |
| 1422 | + } elseif ( class_exists( '\CharitableSquareSDK\Utils\WebhooksHelper' ) ) { | |
| 1423 | + $helper_class = '\CharitableSquareSDK\Utils\WebhooksHelper'; | |
| 1424 | + } | |
| 1425 | + | |
| 1426 | + if ( '' === $helper_class ) { | |
| 1427 | + // Fail closed: a signed request we cannot verify is treated | |
| 1428 | + // as untrusted. Partial verification is worse than none. | |
| 1429 | + error_log( 'Square webhook REJECTED: WebhooksHelper class unavailable, cannot verify signature.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1430 | + return false; | |
| 1431 | + } | |
| 1432 | + | |
| 1433 | + try { | |
| 1434 | + $is_valid = $helper_class::verifySignature( $body, $signature_header, $signature_key, $notification_url ); | |
| 1435 | + } catch ( \Throwable $e ) { | |
| 1436 | + // Fail closed on any error/exception from the SDK: a signed | |
| 1437 | + // request we could not verify is treated as untrusted. | |
| 1438 | + if ( charitable_is_debug( 'square' ) ) { | |
| 1439 | + error_log( 'Square webhook signature verification error: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1440 | + } | |
| 1441 | + return false; | |
| 1442 | + } | |
| 1443 | + | |
| 1444 | + if ( ! $is_valid ) { | |
| 1445 | + if ( charitable_is_debug( 'square' ) ) { | |
| 1446 | + error_log( 'Square webhook REJECTED: signature verification failed.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1447 | + } | |
| 1448 | + return false; | |
| 1449 | + } | |
| 1450 | + | |
| 1451 | + if ( charitable_is_debug( 'square' ) ) { | |
| 1452 | + error_log( 'Square webhook signature verified OK.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1453 | + } | |
| 1454 | + } elseif ( ! empty( $signature_key ) && empty( $signature_header ) ) { | |
| 1455 | + // A signing secret is configured but the request is unsigned: reject. | |
| 1456 | + if ( charitable_is_debug( 'square' ) ) { | |
| 1457 | + error_log( 'Square webhook REJECTED: signing secret configured but request has no signature header.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1458 | + } | |
| 1459 | + return false; | |
| 1460 | + } | |
| 1461 | + | |
| 1462 | + /* | |
| 1463 | + * No signing secret stored. Historically the processor fell through | |
| 1464 | + * here to a merchant_id presence check and ACCEPTED the (unsigned) | |
| 1465 | + * event, which let an unauthenticated caller forge Square webhooks on | |
| 1466 | + * any site that had not yet stored a signing secret. Fail closed: the | |
| 1467 | + * event is refused unless enforcement is explicitly disabled. | |
| 1468 | + * | |
| 1469 | + * Sites connected through the Square settings screen store a signing | |
| 1470 | + * secret during onboarding, so they never reach this branch and are | |
| 1471 | + * unaffected. For the rare site that has webhooks enabled but no | |
| 1472 | + * stored secret, refused events stop donations/refunds from | |
| 1473 | + * reconciling, which the hourly Charitable_Square_WebhooksHealthCheck | |
| 1474 | + * detects and surfaces as an admin notice prompting a reconnect (which | |
| 1475 | + * re-registers the subscription and stores a fresh signing secret, | |
| 1476 | + * restoring verification). | |
| 1477 | + * | |
| 1478 | + * @since 1.8.12 | |
| 1479 | + */ | |
| 1480 | + if ( empty( $signature_key ) ) { | |
| 1481 | + | |
| 1482 | + /** | |
| 1483 | + * Filter whether to enforce Square webhook signature verification | |
| 1484 | + * when no signing secret is stored. | |
| 1485 | + * | |
| 1486 | + * Defaults to true (fail closed). Returning false restores the | |
| 1487 | + * legacy behaviour of accepting unsigned events and re-opens the | |
| 1488 | + * forgery vector, so it should only be used as a temporary escape | |
| 1489 | + * hatch while reconnecting Square. | |
| 1490 | + * | |
| 1491 | + * @since 1.8.12 | |
| 1492 | + * | |
| 1493 | + * @param bool $enforce Whether to reject unsigned events. Default true. | |
| 1494 | + */ | |
| 1495 | + $enforce_signature = (bool) apply_filters( 'charitable_square_webhook_enforce_signature', true ); | |
| 1496 | + | |
| 1497 | + if ( $enforce_signature ) { | |
| 1498 | + error_log( 'Square webhook REJECTED: no signing secret stored. Reconnect Square (Settings > Payment > Square) to register webhooks and enable signature verification. To temporarily accept unsigned events, use the charitable_square_webhook_enforce_signature filter.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1499 | + return false; | |
| 1500 | + } | |
| 1501 | + | |
| 1502 | + if ( charitable_is_debug( 'square' ) ) { | |
| 1503 | + error_log( 'Square webhook WARNING: no signing secret stored and signature enforcement disabled via the charitable_square_webhook_enforce_signature filter - accepting unsigned event.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1504 | + } | |
| 1349 | 1505 | } |
| 1350 | 1506 | |
| 1351 | 1507 | $event = json_decode( $body, true ); |
| 1352 | 1508 | |