Buy

Webhooks: Preventing Replay Attacks

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:

92 lines src/AppBundle/Controller/WebhookController.php
... 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:

36 lines src/AppBundle/Entity/StripeEventLog.php
... 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:

36 lines src/AppBundle/Entity/StripeEventLog.php
... 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":

36 lines src/AppBundle/Entity/StripeEventLog.php
... 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:

36 lines src/AppBundle/Entity/StripeEventLog.php
... 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:

36 lines src/AppBundle/Entity/StripeEventLog.php
... 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":

36 lines src/AppBundle/Entity/StripeEventLog.php
... 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:

35 lines app/DoctrineMigrations/Version20160807113428.php
... 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.

104 lines src/AppBundle/Controller/WebhookController.php
... 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":

104 lines src/AppBundle/Controller/WebhookController.php
... 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:

104 lines src/AppBundle/Controller/WebhookController.php
... 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:

128 lines tests/AppBundle/Controller/WebhookControllerTest.php
... 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():

128 lines tests/AppBundle/Controller/WebhookControllerTest.php
... 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!