diff --git a/README.md b/README.md index d2b7364..3e14d07 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,12 @@ require 'vendor/autoload.php'; use Snippe\Webhook; -$event = Webhook::capture(); +$webhookSecret = getenv('SNIPPE_WEBHOOK_SECRET'); +if (!$webhookSecret) { + throw new RuntimeException('SNIPPE_WEBHOOK_SECRET is not configured'); +} + +$event = Webhook::capture($webhookSecret); if ($event->isPaymentCompleted()) { $ref = $event->reference(); @@ -167,11 +172,16 @@ if ($event->isPaymentFailed()) { $event->ok(); ``` -**What `Webhook::capture()` does for you:** -- Reads the raw POST body from `php://input` +**What `Webhook::capture($webhookSecret)` does for you:** +- Reads the exact raw POST body from `php://input` +- Verifies `X-Snippe-Signature` using HMAC-SHA256 and a constant-time comparison +- Rejects missing, malformed, or invalid signatures with HTTP `401` - Parses JSON safely (throws `SnippeException` on invalid JSON) - Normalizes headers to be **case-insensitive** (works on Apache, Nginx, PHP-FPM, etc.) - Extracts the event type from the `X-Webhook-Event` header +- Requires the signed payload status to agree with the event header before reporting completion or failure + +The signature format is `sha256=`, calculated over the exact raw request body. Store the webhook secret in secure server-side configuration—never in source control or client-side code. ### Webhook Data Accessors @@ -207,9 +217,13 @@ $body = json_encode([ ] ]); +$secret = 'whsec_test_secret'; +$signature = 'sha256=' . hash_hmac('sha256', $body, $secret); + $event = Webhook::fromRaw($body, [ 'X-Webhook-Event' => 'payment.completed', -]); + 'X-Snippe-Signature' => $signature, +], $secret); $event->isPaymentCompleted(); // true $event->reference(); // "test-ref-123" @@ -522,7 +536,12 @@ require 'vendor/autoload.php'; use Snippe\Webhook; -$event = Webhook::capture(); +$webhookSecret = getenv('SNIPPE_WEBHOOK_SECRET'); +if (!$webhookSecret) { + throw new RuntimeException('SNIPPE_WEBHOOK_SECRET is not configured'); +} + +$event = Webhook::capture($webhookSecret); if ($event->isPaymentCompleted()) { $ref = $event->reference(); @@ -623,8 +642,8 @@ The test bookstore includes these titles at TZS 1,000 each: | Method | Returns | Description | |--------|---------|-------------| -| `Webhook::capture()` | `Webhook` | Capture incoming webhook request | -| `Webhook::fromRaw($body, $headers)` | `Webhook` | Create from raw data (testing) | +| `Webhook::capture($secret)` | `Webhook` | Capture and verify an incoming webhook request | +| `Webhook::fromRaw($body, $headers, $secret)` | `Webhook` | Create and verify from raw data (testing/frameworks) | | `eventType()` | `string` | Event type string | | `isPaymentCompleted()` | `bool` | Is it a completed payment? | | `isPaymentFailed()` | `bool` | Is it a failed payment? | diff --git a/src/Webhook.php b/src/Webhook.php index a8911f0..cbfb9d7 100644 --- a/src/Webhook.php +++ b/src/Webhook.php @@ -3,42 +3,26 @@ namespace Snippe; /** - * Handles incoming Snippe webhook events. + * Handles and verifies incoming Snippe webhook events. * - * Usage in your webhook.php: + * Signatures use HMAC-SHA256 over the exact raw request body and are sent as: * - * require 'vendor/autoload.php'; - * use Snippe\Webhook; - * - * $event = Webhook::capture(); - * - * if ($event->isPaymentCompleted()) { - * $ref = $event->reference(); - * // Update your order to "paid"... - * } - * - * if ($event->isPaymentFailed()) { - * // Handle failure... - * } - * - * $event->ok(); // Always respond 200 + * X-Snippe-Signature: sha256= */ class Webhook { + public const SIGNATURE_HEADER = 'x-snippe-signature'; + private array $payload; private array $headers; private string $eventType; private string $rawBody; - private function __construct(array $payload, array $headers, string $rawBody = '') + private function __construct(array $payload, array $headers, string $rawBody) { $this->payload = $payload; $this->rawBody = $rawBody; - - // Normalize headers to lowercase for reliable lookup $this->headers = array_change_key_case($headers, CASE_LOWER); - - // Extract event type from header (case-insensitive) $this->eventType = $this->headers['x-webhook-event'] ?? ''; } @@ -79,43 +63,82 @@ public function verify(string $secret): self // ── Factory methods ── /** - * Capture the current incoming webhook request. - * Call this in your webhook endpoint. + * Capture and verify the current incoming webhook request. + * + * The secret must come from secure server-side configuration. Never expose it + * in client-side code or commit it to source control. * - * @throws SnippeException if the payload isn't valid JSON + * @throws SnippeException if verification fails or the payload is invalid */ - public static function capture(): self + public static function capture(string $secret): self { $headers = function_exists('getallheaders') ? getallheaders() : self::parseHeaders(); - $raw = file_get_contents('php://input'); - $payload = json_decode($raw, true); + $rawBody = file_get_contents('php://input'); - if ($payload === null && json_last_error() !== JSON_ERROR_NONE) { + if ($rawBody === false) { http_response_code(400); - throw new SnippeException( - 'Invalid webhook payload: ' . json_last_error_msg(), - 400 - ); + throw new SnippeException('Unable to read webhook payload', 400); } - return new self($payload ?? [], $headers, $raw); + try { + return self::fromRaw($rawBody, $headers, $secret); + } catch (SnippeException $exception) { + http_response_code($exception->getCode()); + throw $exception; + } } /** - * Create from raw data (useful for testing) + * Create and verify a webhook from raw data. Useful for framework adapters + * and automated tests. + * + * @throws SnippeException if verification fails or the payload is invalid */ - public static function fromRaw(string $body, array $headers = []): self + public static function fromRaw(string $body, array $headers, string $secret): self { + $normalizedHeaders = array_change_key_case($headers, CASE_LOWER); + self::verifySignature($body, $normalizedHeaders, $secret); + $payload = json_decode($body, true); - if ($payload === null && json_last_error() !== JSON_ERROR_NONE) { - throw new SnippeException('Invalid webhook payload', 400); + if (!is_array($payload)) { + $message = json_last_error() === JSON_ERROR_NONE + ? 'Webhook payload must be a JSON object or array' + : 'Invalid webhook payload: ' . json_last_error_msg(); + + throw new SnippeException($message, 400); } - return new self($payload ?? [], $headers, $body); + return new self($payload, $normalizedHeaders, $body); } - // ── Event type checks ── + /** + * Verify an HMAC-SHA256 signature against the exact raw request body. + * + * @throws SnippeException when the secret/signature is missing or invalid + */ + private static function verifySignature(string $body, array $headers, string $secret): void + { + if ($secret === '') { + throw new SnippeException('Webhook secret must not be empty', 500); + } + + $provided = $headers[self::SIGNATURE_HEADER] ?? null; + + if (!is_string($provided) || $provided === '') { + throw new SnippeException('Missing webhook signature', 401); + } + + if (!preg_match('/^sha256=[a-f0-9]{64}$/i', $provided)) { + throw new SnippeException('Invalid webhook signature format', 401); + } + + $expected = 'sha256=' . hash_hmac('sha256', $body, $secret); + + if (!hash_equals($expected, strtolower($provided))) { + throw new SnippeException('Invalid webhook signature', 401); + } + } public function eventType(): string { @@ -124,12 +147,12 @@ public function eventType(): string public function isPaymentCompleted(): bool { - return $this->eventType === 'payment.completed'; + return $this->eventType === 'payment.completed' && $this->status() === 'completed'; } public function isPaymentFailed(): bool { - return $this->eventType === 'payment.failed'; + return $this->eventType === 'payment.failed' && $this->status() === 'failed'; } public function isPayoutCompleted(): bool @@ -149,76 +172,61 @@ public function isPayoutFailed(): bool */ public function reference(): ?string { - return $this->payload['data']['reference'] - ?? $this->payload['reference'] - ?? null; + $reference = $this->data()['reference'] ?? $this->payload['reference'] ?? null; + + return is_string($reference) ? $reference : null; } - /** - * Get the payment status from payload - */ public function status(): ?string { - return $this->payload['data']['status'] - ?? $this->payload['status'] - ?? null; + $status = $this->data()['status'] ?? $this->payload['status'] ?? null; + + return is_string($status) ? $status : null; } - /** - * Get the payment amount - */ public function amount(): ?int { - return $this->payload['data']['amount']['value'] - ?? $this->payload['data']['amount'] - ?? null; + $amount = $this->data()['amount'] ?? null; + + if (is_array($amount)) { + $amount = $amount['value'] ?? null; + } + + return is_int($amount) ? $amount : null; } - /** - * Get the currency - */ public function currency(): ?string { - return $this->payload['data']['amount']['currency'] ?? null; + $amount = $this->data()['amount'] ?? null; + $currency = is_array($amount) ? ($amount['currency'] ?? null) : null; + + return is_string($currency) ? $currency : null; } - /** - * Get customer info - */ public function customer(): array { - return $this->payload['data']['customer'] ?? []; + $customer = $this->data()['customer'] ?? []; + + return is_array($customer) ? $customer : []; } - /** - * Get custom metadata - */ public function metadata(): array { - return $this->payload['data']['metadata'] ?? []; + $metadata = $this->data()['metadata'] ?? []; + + return is_array($metadata) ? $metadata : []; } - /** - * Get the full webhook payload - */ public function payload(): array { return $this->payload; } - /** - * Get the raw JSON body - */ public function rawBody(): string { return $this->rawBody; } - // ── Response helpers ── - - /** - * Respond 200 OK to Snippe (call this when you're done processing) - */ public function ok(): void { http_response_code(200); @@ -226,9 +234,6 @@ public function ok(): void echo json_encode(['ok' => true]); } - /** - * Respond with an error status - */ public function fail(int $code = 400): void { http_response_code($code); @@ -236,20 +241,27 @@ public function fail(int $code = 400): void echo json_encode(['ok' => false]); } - // ── Helpers ── + private function data(): array + { + $data = $this->payload['data'] ?? []; + + return is_array($data) ? $data : []; + } /** - * Fallback header parser for environments without getallheaders() + * Fallback header parser for environments without getallheaders(). */ private static function parseHeaders(): array { $headers = []; + foreach ($_SERVER as $key => $value) { if (str_starts_with($key, 'HTTP_')) { $name = str_replace('_', '-', substr($key, 5)); $headers[$name] = $value; } } + return $headers; } } diff --git a/tests/WebhookTest.php b/tests/WebhookTest.php index cc155a8..5499aad 100644 --- a/tests/WebhookTest.php +++ b/tests/WebhookTest.php @@ -3,12 +3,14 @@ namespace Snippe\Tests; use PHPUnit\Framework\TestCase; -use Snippe\Webhook; use Snippe\SnippeException; +use Snippe\Webhook; class WebhookTest extends TestCase { - public function test_parses_payment_completed_event(): void + private const SECRET = 'whsec_test_secret'; + + public function test_parses_verified_payment_completed_event(): void { $body = json_encode([ 'data' => [ @@ -21,130 +23,180 @@ public function test_parses_payment_completed_event(): void 'email' => 'john@example.com', ], 'metadata' => ['order_id' => 'ORD-001'], - ] + ], ]); - $event = Webhook::fromRaw($body, [ - 'X-Webhook-Event' => 'payment.completed', - ]); + $event = $this->webhook($body, ['X-Webhook-Event' => 'payment.completed']); $this->assertTrue($event->isPaymentCompleted()); $this->assertFalse($event->isPaymentFailed()); - $this->assertEquals('payment.completed', $event->eventType()); - $this->assertEquals('9015c155-9e29-4e8e-8fe6-d5d81553c8e6', $event->reference()); - $this->assertEquals('completed', $event->status()); - $this->assertEquals(5000, $event->amount()); - $this->assertEquals('TZS', $event->currency()); - $this->assertEquals(['order_id' => 'ORD-001'], $event->metadata()); + $this->assertSame('payment.completed', $event->eventType()); + $this->assertSame('9015c155-9e29-4e8e-8fe6-d5d81553c8e6', $event->reference()); + $this->assertSame('completed', $event->status()); + $this->assertSame(5000, $event->amount()); + $this->assertSame('TZS', $event->currency()); + $this->assertSame(['order_id' => 'ORD-001'], $event->metadata()); } public function test_parses_payment_failed_event(): void { - $body = json_encode([ - 'data' => [ - 'reference' => 'abc-123', - 'status' => 'failed', - ] - ]); - - $event = Webhook::fromRaw($body, [ - 'X-Webhook-Event' => 'payment.failed', - ]); + $body = json_encode(['data' => ['reference' => 'abc-123', 'status' => 'failed']]); + $event = $this->webhook($body, ['X-Webhook-Event' => 'payment.failed']); $this->assertTrue($event->isPaymentFailed()); $this->assertFalse($event->isPaymentCompleted()); - $this->assertEquals('abc-123', $event->reference()); + $this->assertSame('abc-123', $event->reference()); } - public function test_handles_lowercase_headers(): void + public function test_accepts_case_insensitive_headers(): void { - $body = json_encode(['data' => ['reference' => 'ref-123']]); + $body = json_encode(['data' => ['reference' => 'ref-123', 'status' => 'completed']]); + $signature = $this->signature($body); $event = Webhook::fromRaw($body, [ - 'x-webhook-event' => 'payment.completed', - ]); + 'X-WEBHOOK-EVENT' => 'payment.completed', + 'X-SNIPPE-SIGNATURE' => strtoupper($signature), + ], self::SECRET); $this->assertTrue($event->isPaymentCompleted()); } - public function test_handles_uppercase_headers(): void + public function test_does_not_trust_event_header_when_payload_status_disagrees(): void { - $body = json_encode(['data' => ['reference' => 'ref-123']]); + $body = json_encode(['data' => ['status' => 'failed']]); + $event = $this->webhook($body, ['X-Webhook-Event' => 'payment.completed']); - $event = Webhook::fromRaw($body, [ - 'X-WEBHOOK-EVENT' => 'payment.completed', - ]); + $this->assertFalse($event->isPaymentCompleted()); + $this->assertSame('failed', $event->status()); + } - $this->assertTrue($event->isPaymentCompleted()); + public function test_rejects_missing_signature(): void + { + $this->expectException(SnippeException::class); + $this->expectExceptionCode(401); + $this->expectExceptionMessage('Missing webhook signature'); + + Webhook::fromRaw('{}', [], self::SECRET); } - public function test_handles_missing_event_header(): void + public function test_rejects_malformed_signature(): void { - $body = json_encode(['data' => ['reference' => 'ref-123']]); + $this->expectException(SnippeException::class); + $this->expectExceptionCode(401); + $this->expectExceptionMessage('Invalid webhook signature format'); - $event = Webhook::fromRaw($body, []); + Webhook::fromRaw('{}', ['X-Snippe-Signature' => 'not-a-signature'], self::SECRET); + } - $this->assertEquals('', $event->eventType()); - $this->assertFalse($event->isPaymentCompleted()); - $this->assertFalse($event->isPaymentFailed()); + public function test_rejects_invalid_signature(): void + { + $this->expectException(SnippeException::class); + $this->expectExceptionCode(401); + $this->expectExceptionMessage('Invalid webhook signature'); + + Webhook::fromRaw('{}', [ + 'X-Snippe-Signature' => 'sha256=' . str_repeat('0', 64), + ], self::SECRET); } - public function test_throws_on_invalid_json(): void + public function test_rejects_signature_for_modified_body(): void { + $original = json_encode(['data' => ['status' => 'pending']]); + $modified = json_encode(['data' => ['status' => 'completed']]); + $this->expectException(SnippeException::class); + $this->expectExceptionCode(401); - Webhook::fromRaw('not valid json', []); + Webhook::fromRaw($modified, [ + 'X-Snippe-Signature' => $this->signature($original), + ], self::SECRET); } - public function test_handles_empty_json_object(): void + public function test_rejects_empty_secret(): void { - $event = Webhook::fromRaw('{}', [ - 'X-Webhook-Event' => 'payment.completed', - ]); + $this->expectException(SnippeException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessage('Webhook secret must not be empty'); - $this->assertTrue($event->isPaymentCompleted()); - $this->assertNull($event->reference()); - $this->assertNull($event->amount()); + Webhook::fromRaw('{}', ['X-Snippe-Signature' => $this->signature('{}')], ''); + } + + public function test_rejects_invalid_json_after_valid_signature(): void + { + $body = 'not valid json'; + + $this->expectException(SnippeException::class); + $this->expectExceptionCode(400); + $this->expectExceptionMessage('Invalid webhook payload'); + + $this->webhook($body); + } + + public function test_rejects_scalar_json_payload(): void + { + $this->expectException(SnippeException::class); + $this->expectExceptionCode(400); + $this->expectExceptionMessage('Webhook payload must be a JSON object or array'); + + $this->webhook('"scalar"'); } - public function test_raw_body_preserved(): void + public function test_preserves_raw_body(): void { $body = json_encode(['data' => ['reference' => 'test']]); + $event = $this->webhook($body); + + $this->assertSame($body, $event->rawBody()); + } - $event = Webhook::fromRaw($body, []); + public function test_handles_missing_event_header(): void + { + $event = $this->webhook('{}'); - $this->assertEquals($body, $event->rawBody()); + $this->assertSame('', $event->eventType()); + $this->assertFalse($event->isPaymentCompleted()); + $this->assertFalse($event->isPaymentFailed()); } - public function test_customer_data(): void + public function test_handles_malformed_optional_fields_safely(): void { $body = json_encode([ 'data' => [ - 'customer' => [ - 'first_name' => 'Ali', - 'last_name' => 'Hassan', - 'email' => 'ali@example.com', - 'phone' => '+255754123456', - ] - ] + 'reference' => 123, + 'status' => false, + 'amount' => 'not-an-integer', + 'customer' => 'not-an-array', + 'metadata' => null, + ], ]); - $event = Webhook::fromRaw($body, []); + $event = $this->webhook($body); + + $this->assertNull($event->reference()); + $this->assertNull($event->status()); + $this->assertNull($event->amount()); + $this->assertNull($event->currency()); + $this->assertSame([], $event->customer()); + $this->assertSame([], $event->metadata()); + } + + public function test_falls_back_to_root_reference(): void + { + $body = json_encode(['reference' => 'root-ref-123']); + $event = $this->webhook($body); - $customer = $event->customer(); - $this->assertEquals('Ali', $customer['first_name']); - $this->assertEquals('ali@example.com', $customer['email']); + $this->assertSame('root-ref-123', $event->reference()); } - public function test_fallback_reference_from_root(): void + private function webhook(string $body, array $headers = []): Webhook { - // Some APIs put reference at root level - $body = json_encode([ - 'reference' => 'root-ref-123', - ]); + $headers['X-Snippe-Signature'] = $this->signature($body); - $event = Webhook::fromRaw($body, []); + return Webhook::fromRaw($body, $headers, self::SECRET); + } - $this->assertEquals('root-ref-123', $event->reference()); + private function signature(string $body): string + { + return 'sha256=' . hash_hmac('sha256', $body, self::SECRET); } }