Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stripe webhook validator and test #17

Merged
merged 8 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/Pay/Validator/Stripe/Webhook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

namespace Utopia\Pay\Validator\Stripe;

// header
// t=1723597289,v1=f53b5765cc9847786d33f8f96d9e22c0d08967271a734b1a69327e22ecf1bc73,v0=353c23cbcfc17f983e3089a339d2004174ee472df39e61d7e52805008ffad044
// secret
// whsec_2FMR5OjJa6Czcj3G07HvMGjLsw8uw3dQ
class Webhook
{
public const DEFAULT_TOLERANCE = 300;

public const EXPECTED_SCHEME = 'v1';

private static $isHashEqualsAvailable = null;

/**
* Verifies the signature header sent by Stripe. Throws an
* Exception\SignatureVerificationException exception if the verification fails for
* any reason.
*
* @param string $payload the payload sent by Stripe
* @param string $header the contents of the signature header sent by
* Stripe
* @param string $secret secret used to generate the signature
* @param int $tolerance maximum difference allowed between the header's
* timestamp and the current time
* @return bool
*/
public function isValid($payload, $header, $secret, $tolerance = null)
{
// Extract timestamp and signatures from header
$timestamp = $this->getTimestamp($header);
$signatures = $this->getSignatures($header, self::EXPECTED_SCHEME);
if (-1 === $timestamp) {
return false;
}
if (empty($signatures)) {
return false;
}

// Check if expected signature is found in list of signatures from
// header
$signedPayload = "{$timestamp}.{$payload}";
$expectedSignature = $this->computeSignature($signedPayload, $secret);
$signatureFound = false;
foreach ($signatures as $signature) {
if ($this->secureCompare($expectedSignature, $signature)) {
$signatureFound = true;

break;
}
}
if (! $signatureFound) {
return false;
}

// Check if timestamp is within tolerance
if (($tolerance > 0) && (\abs(\time() - $timestamp) > $tolerance)) {
return false;
}

return true;
}

public function secureCompare($a, $b)
{
if (null === self::$isHashEqualsAvailable) {
self::$isHashEqualsAvailable = \function_exists('hash_equals');
}

if (self::$isHashEqualsAvailable) {
return \hash_equals($a, $b);
}
if (\strlen($a) !== \strlen($b)) {
return false;
}

$result = 0;
for ($i = 0; $i < \strlen($a); $i++) {
$result |= \ord($a[$i]) ^ \ord($b[$i]);
}

return 0 === $result;
}

/**
* Extracts the timestamp in a signature header.
*
* @param string $header the signature header
* @return int the timestamp contained in the header, or -1 if no valid
* timestamp is found
*/
private function getTimestamp($header)
{
$items = \explode(',', $header);

foreach ($items as $item) {
$itemParts = \explode('=', $item, 2);
if ('t' === $itemParts[0]) {
if (! \is_numeric($itemParts[1])) {
return -1;
}

return (int) ($itemParts[1]);
}
}

return -1;
}

/**
* Extracts the signatures matching a given scheme in a signature header.
*
* @param string $header the signature header
* @param string $scheme the signature scheme to look for
* @return array the list of signatures matching the provided scheme
*/
private function getSignatures($header, $scheme)
{
$signatures = [];
$items = \explode(',', $header);

foreach ($items as $item) {
$itemParts = \explode('=', $item, 2);
if (\trim($itemParts[0]) === $scheme) {
$signatures[] = $itemParts[1];
}
}

return $signatures;
}

/**
* Computes the signature for a given payload and secret.
*
* The current scheme used by Stripe ("v1") is HMAC/SHA-256.
*
* @param string $payload the payload to sign
* @param string $secret the secret used to generate the signature
* @return string the signature as a string
*/
private function computeSignature($payload, $secret)
{
return \hash_hmac('sha256', $payload, $secret);
}
}
33 changes: 33 additions & 0 deletions tests/Pay/Validator/Stripe/WebhookTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Utopia\Tests;

use PHPUnit\Framework\TestCase;
use Utopia\Pay\Validator\Stripe\Webhook;

class WebhookTest extends TestCase
{
public function testValid()
{
$header = 't=1723597289,v1=ca18f2c5b48c347b26f2d862f29d93dc1c9c6b319ba2cd934db54333acef1492';
$secret = 'whsec_2FMR5OjJa6Czcj3G07HvMGjLsw8uw3dQ';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has this key been invalidated?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes


$validator = new Webhook();

// test valid (Tolerance set to high)
$isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret, PHP_INT_MAX);
$this->assertTrue($isValid);

// Test time tolerance low
$isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret, 10);
$this->assertFalse($isValid);

// payload doesn't match
$isValid = $validator->isValid('{"id": "pi_abcdef"}', $header, $secret, PHP_INT_MAX);
$this->assertFalse($isValid);

// Secret doesn't match
$isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret.'ef', PHP_INT_MAX);
$this->assertFalse($isValid);
}
}
Loading