diff --git a/admin/modules/v1/controllers/XeroWebhookController.php b/admin/modules/v1/controllers/XeroWebhookController.php index 285e44ec..49eb1da8 100644 --- a/admin/modules/v1/controllers/XeroWebhookController.php +++ b/admin/modules/v1/controllers/XeroWebhookController.php @@ -5,6 +5,7 @@ use Yii; use yii\filters\Cors; use yii\rest\Controller; +use yii\web\UnauthorizedHttpException; class XeroWebhookController extends Controller { @@ -62,23 +63,24 @@ public function beforeAction($action) return false; } - $key = "+E4OxefKZm8uPKkiz8RkGA8t/XogInNgvIZhX9izCliOMVCerc8114/T7JSxudGfPxfwU1N3UCe1Ika2VuKDbQ=="; + $key = getenv('XERO_WEBHOOK_SIGNING_KEY') ?: ''; + if ($key === '') { + Yii::error('XERO_WEBHOOK_SIGNING_KEY is not configured.', __METHOD__); + throw new UnauthorizedHttpException('Webhook signature validation is not configured.'); + } // Get the provided signature from the request headers $provided_signature = Yii::$app->request->headers->get("x-xero-signature"); // $_SERVER['HTTP_X_Xero_Signature']; // Get the request data - $request_data = Yii::$app->request->getBodyParams(); //file_get_contents('php://input'); + $request_data = Yii::$app->request->rawBody; // Calculate the HMAC signature $generated_signature = base64_encode(hash_hmac('sha256', $request_data, $key, true)); // Compare the provided signature with the generated one - if ($provided_signature !== $generated_signature) { - // Signature mismatch - http_response_code(401); - echo "Signature mismatch"; - die(); + if (!$provided_signature || !hash_equals($generated_signature, $provided_signature)) { + throw new UnauthorizedHttpException('Signature mismatch.'); } /*else { // Signature matched http_response_code(200); @@ -106,4 +108,4 @@ public function actionIncomming() Yii::$app->eventManager->track ($event['eventCategory'] . " " . $event['eventType'], $data); } } -} \ No newline at end of file +} diff --git a/docs/xero-webhook-signing-key.md b/docs/xero-webhook-signing-key.md new file mode 100644 index 00000000..e8ddfb7b --- /dev/null +++ b/docs/xero-webhook-signing-key.md @@ -0,0 +1,9 @@ +# Xero webhook signing key + +The admin Xero webhook endpoint validates Xero's `x-xero-signature` header with an HMAC over the raw request body. + +Set the signing key at runtime: + +- `XERO_WEBHOOK_SIGNING_KEY` + +Do not commit the signing key to controller code or environment templates. If this variable is missing, webhook requests fail closed with HTTP 401 and a server-side configuration log entry. diff --git a/tests/check-xero-webhook-signature-hardening.sh b/tests/check-xero-webhook-signature-hardening.sh new file mode 100755 index 00000000..c2e74126 --- /dev/null +++ b/tests/check-xero-webhook-signature-hardening.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +controller="admin/modules/v1/controllers/XeroWebhookController.php" +legacy_key_pattern="E4OxefKZm8uPKkiz8""RkGA8t" + +if rg -n "$legacy_key_pattern" "$controller" docs --glob '!tests/check-xero-webhook-signature-hardening.sh'; then + echo "Xero webhook signing key must not be committed." >&2 + exit 1 +fi + +rg -n "getenv\\('XERO_WEBHOOK_SIGNING_KEY'\\)" "$controller" >/dev/null +rg -n '\$request_data = Yii::\$app->request->rawBody;' "$controller" >/dev/null +rg -n 'hash_equals\(\$generated_signature, \$provided_signature\)' "$controller" >/dev/null +rg -n "UnauthorizedHttpException" "$controller" >/dev/null + +if rg -n "echo \"Signature mismatch\"|die\\(\\)|http_response_code\\(401\\)" "$controller"; then + echo "Xero webhook signature failures should use framework 401 exceptions, not echo/die." >&2 + exit 1 +fi + +echo "Xero webhook signature validation is runtime-configured and fail-closed."