Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This course is archived!
This tutorial uses an older version of Symfony of the stripe-php SDK. The majority of the concepts are still valid, though there *are* differences. We've done our best to add notes & comments that describe these changes.

Webhooks: Preventing Replay Attacks

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

There's one last teeny, tiny little detail we need to worry about with webhooks: replay attacks. These are a security concern but also a practical one.

We already know that nobody can send us, random, fake event data because we fetch a fresh event from Stripe:

... lines 1 - 8
class WebhookController extends BaseController
{
... lines 11 - 13
public function stripeWebhookAction(Request $request)
{
$data = json_decode($request->getContent(), true);
if ($data === null) {
throw new \Exception('Bad JSON body from Stripe!');
}
$eventId = $data['id'];
if ($this->getParameter('verify_stripe_event')) {
$stripeEvent = $this->get('stripe_client')
->findEvent($eventId);
... lines 26 - 28
}
... lines 30 - 69
}
... lines 71 - 90
}

But, someone could intercept a real webhook, and send it to us multiple times. I don't know why they would do that, but weird things would happen.

And there's also the practical concern. Suppose Stripe sends us a webhook and we process it. But somehow, there was a connection problem between our server and Stripe, so Stripe never received our 200 status code. Then, thinking that the webhook failed, Stripe tries to send the webhook again. If this were for an invoice.payment_succeeded event, one user might get two subscription renewal emails. That's weird.

Creating the stripe_event_log Table

Let's prevent that. And it's simple: create a database table that records all the event ID's we've handled. Then, query that table before processing a webhook to make sure we haven't seen it before.

In the AppBundle/Entity directory, create a new PHP Class called StripeEventLog:

... lines 1 - 2
namespace AppBundle\Entity;
... lines 4 - 10
class StripeEventLog
{
... lines 13 - 34
}

Give it a few properties: $id, $stripeEventId and a $handledAt date field:

... lines 1 - 10
class StripeEventLog
{
... lines 13 - 17
private $id;
... lines 19 - 22
private $stripeEventId;
... lines 24 - 27
private $handledAt;
... lines 29 - 34
}

Since this project uses Doctrine, I'll add a special use statement on top and then add some annotations, so that this new class will become a new table in the database. Use the "Code"->"Generate" menu, or Command + N on a Mac and select "ORM Class":

... lines 1 - 4
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="stripe_event_log")
*/
class StripeEventLog
{
... lines 13 - 34
}

Repeat that and select "ORM Annotations". Choose all the fields:

... lines 1 - 10
class StripeEventLog
{
/**
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", unique=true)
*/
private $stripeEventId;
/**
* @ORM\Column(type="datetime")
*/
private $handledAt;
... lines 29 - 34
}

Update stripeEventId to be a string field - that'll translate to a varchar in MySQL:

... lines 1 - 10
class StripeEventLog
{
... lines 13 - 19
/**
* @ORM\Column(type="string", unique=true)
*/
private $stripeEventId;
... lines 24 - 34
}

To set the properties, create a new __construct() method with a $stripeEventId argument. Inside, set that on the property and also set $this->handledAt to a new \DateTime() to set this field to "right now":

... lines 1 - 10
class StripeEventLog
{
... lines 13 - 29
public function __construct($stripeEventId)
{
$this->stripeEventId = $stripeEventId;
$this->handledAt = new \DateTime();
}
}

Brilliant! And now that we have the entity class, find your terminal and run:

./bin/console doctrine:migrations:diff

This generates a new file in the app/DoctrineMigrations directory that contains the raw SQL needed to create the new table:

... lines 1 - 2
namespace Application\Migrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20160807113428 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('CREATE TABLE stripe_event_log (id INT AUTO_INCREMENT NOT NULL, stripe_event_id VARCHAR(255) NOT NULL, handled_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_217D8BDC2CB034B8 (stripe_event_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB');
}
/**
* @param Schema $schema
*/
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('DROP TABLE stripe_event_log');
}
}

Execute that query by running:

./bin/console doctrine:migrations:migrate

Preventing the Replay Attack

Finally, in WebhookController, start by querying to see if this event has been handled before. Fetch the EntityManager, and then add $existingLog = $em->getRepository('AppBundle:StripeEventLog') and call findOneBy() on it to query for stripeEventId set to $eventId.

... lines 1 - 9
class WebhookController extends BaseController
{
... lines 12 - 14
public function stripeWebhookAction(Request $request)
{
... lines 17 - 21
$eventId = $data['id'];
$em = $this->getDoctrine()->getManager();
$existingLog = $em->getRepository('AppBundle:StripeEventLog')
->findOneBy(['stripeEventId' => $eventId]);
... lines 27 - 81
}
... lines 83 - 102
}

If an $existingLog is found, then we don't want to handle this. Just return a new Response() that says "Event previously handled":

... lines 1 - 9
class WebhookController extends BaseController
{
... lines 12 - 14
public function stripeWebhookAction(Request $request)
{
... lines 17 - 21
$eventId = $data['id'];
$em = $this->getDoctrine()->getManager();
$existingLog = $em->getRepository('AppBundle:StripeEventLog')
->findOneBy(['stripeEventId' => $eventId]);
if ($existingLog) {
return new Response('Event previously handled');
}
... lines 30 - 81
}
... lines 83 - 102
}

If you also want to log a message so that you know when this happens, that's not a bad idea.

But if there is not an existing log, time to process this webhook! Create a new StripeEventLog and pass it $eventId. Then, persist and flush just the log:

... lines 1 - 9
class WebhookController extends BaseController
{
... lines 12 - 14
public function stripeWebhookAction(Request $request)
{
... lines 17 - 21
$eventId = $data['id'];
$em = $this->getDoctrine()->getManager();
$existingLog = $em->getRepository('AppBundle:StripeEventLog')
->findOneBy(['stripeEventId' => $eventId]);
if ($existingLog) {
return new Response('Event previously handled');
}
$log = new StripeEventLog($eventId);
$em->persist($log);
$em->flush($log);
... lines 34 - 81
}
... lines 83 - 102
}

And yea, replay attacks are gone!

Update the Test!

To make sure we didn't mess anything up, open WebhookControllerTest and copy our test method. Run that:

./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted

Bah! Of course... it failed for a silly reason: I need to update my test database - to add the new table. A shortcut to do that is:

./bin/console doctrine:schema:update --force --env=test

Try the test now:

./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted

It works! So hey, run it again!

./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted

It fails?!

Failed to assert that true is false.

Well, that's not clear, but I know what the problem is: every event in the test has the same event ID:

... lines 1 - 9
class WebhookControllerTest extends WebTestCase
{
... lines 12 - 70
private function getCustomerSubscriptionDeletedEvent($subscriptionId)
{
$json = <<<EOF
{
... lines 75 - 76
"id": "evt_00000000000000",
... lines 78 - 121
}
EOF;
... lines 124 - 125
}
}

So when you run the test the second time, this already exists in the StripeEventLog table and the webhook is skipped. Well hey, at least we know the replay attack system is working.

To fix this, we need to set a little bit of randomness to the event ID by adding a %s at the end and adding an mt_rand() to the sprintf():

... lines 1 - 9
class WebhookControllerTest extends WebTestCase
{
... lines 12 - 70
private function getCustomerSubscriptionDeletedEvent($subscriptionId)
{
$json = <<<EOF
{
... lines 75 - 76
"id": "evt_00000000000000%s",
... lines 78 - 121
}
EOF;
return sprintf($json, mt_rand(), $subscriptionId);
}
}

Now, every event ID will be unique. Try the test again:

./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted

Green and happy!

Ok, enough webhooks. Let's do something fun, like making it possible for a user to upgrade from one subscription to another.

Leave a comment!

2
Login or Register to join the conversation
Kiuega Avatar
Kiuega Avatar Kiuega | posted 2 years ago | edited

Hi ! I don't know if this was already possible at the time you did the tutorial, but I changed the way of "checking" an event based on the documentation: https://stripe.com/docs/webhooks/signatures

I adapted this for Symfony, while keeping some of what we did in the training, which would look something like this:


public function stripeWebhookAction(Request $request, $shouldVerifyStripeEvent, $stripeWebhooksSecretKey)
{

        if ($shouldVerifyStripeEvent) {
            $header = 'Stripe-Signature';
            $signature = $request->headers->get($header);

            if (is_null($signature)) {
                throw new BadRequestHttpException(sprintf('Missing header %s', $header));
            }

            try {
                $stripeEvent = \Stripe\Webhook::constructEvent(
                    $request->getContent(),
                    $signature,
                    $stripeWebhooksSecretKey // The Stripe webhook key, you will find it in your Stripe dashboard, in your webhook configuration
                );
            } catch (\UnexpectedValueException $e) {
                throw new BadRequestHttpException('Invalid Stripe payload');
            } catch (SignatureVerificationException $e) {
                throw new BadRequestHttpException('Invalid Stripe signature');
            }
        } else {
            $stripeEvent = json_decode($request->getContent());
        }

        $eventId = $stripeEvent->id;
        $em = $this->getDoctrine()->getManager();
        $existingLog = $em->getRepository(StripeEventLog::class)->findOneBy(['stripeEventId' => $eventId]);

        if ($existingLog) {
            return new Response('Event previously handled !');
        }

        switch ($stripeEvent->type) {
           case '....':
           break;

           case '...':
           break:

          default:
            throw new \Exception('Unexpected webhook from Stripe '.$stripeEvent->type);
        }

        $log = new StripeEventLog($eventId);
        $em->persist($log);
        $em->flush();

        return new Response('Event handled: '.$stripeEvent->type);

There you go, I don't know if it's more secure, less secure, or if it's the same thing. But I hope it can help more than one person!

Reply

Hey Kiuega!

This is awesome - thanks for sharing! Indeed, because they put a timestamp in their signature, this prevents replay attacks also (unless they attacker is able to intercept the webhook and use it within 5 minutes... but that doesn't leave an attacker much room to do this :) ).

You've also kept the StripeEventLog, which I like. Even if you've mitigated reply attacks, you still want to make sure that you don't handle a webhook multiple times. This can happen (in the wild) if, for some reason, Stripe sends you a webhook, you handle it, but you fail to respond successfully (so Stripe tries the webhook again later).

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony of the stripe-php SDK. The majority of the concepts are still valid, though there *are* differences. We've done our best to add notes & comments that describe these changes.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9, <7.4",
        "symfony/symfony": "3.1.*", // v3.1.10
        "doctrine/orm": "^2.5", // v2.7.2
        "doctrine/doctrine-bundle": "^1.6", // 1.6.8
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.6.2
        "symfony/monolog-bundle": "^2.8", // v2.12.1
        "symfony/polyfill-apcu": "^1.0", // v1.3.0
        "sensio/distribution-bundle": "^5.0", // v5.0.22
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.26
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "friendsofsymfony/user-bundle": "~2.0.1", // v2.0.1
        "stof/doctrine-extensions-bundle": "^1.2", // v1.2.2
        "stripe/stripe-php": "^3.15", // v3.23.0
        "doctrine/doctrine-migrations-bundle": "^1.1", // v1.2.1
        "phpunit/phpunit": "^5.5", // 5.7.20
        "composer/package-versions-deprecated": "^1.11" // 1.11.99
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.4
        "symfony/phpunit-bridge": "^3.0", // v3.3.0
        "hautelook/alice-bundle": "^1.3", // v1.4.1
        "doctrine/data-fixtures": "^1.2" // v1.2.2
    }
}
userVoice