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

nodejs and php discrepancies with webhook request signing #115

Closed
theroman opened this issue Aug 7, 2024 · 6 comments
Closed

nodejs and php discrepancies with webhook request signing #115

theroman opened this issue Aug 7, 2024 · 6 comments

Comments

@theroman
Copy link

theroman commented Aug 7, 2024

When trying to calculate the hash for a webhook payload we seem to get different hash values in node and php - with php matching the x-signature header as expected, and node always failing to validate the incoming request.

Upon close inspection of the raw request using webhook.site we see that forward slashes inside links and urls in the data are escaped using backward slashes. For example:

{
...
"links":{"self":"https:\/\/api.lemonsqueezy.com\/v1\/subscriptions\/xyz"}
...
}

While in php it's possible to parse this string as is, in node the backslashes are omitted - resulting in a different hash.
The issue occurs regardless of using the raw request body as string or as a buffer, the backslashes are simply stripped in any case.

Code examples:

nodejs

import crypto from 'crypto';

const payload = '{"links":{"self":"https:\/\/api.lemonsqueezy.com\/v1\/subscriptions\/xyz"}}';
const secret = 'abd1285d';

const hash = crypto.createHmac('sha256', secret).update(payload).digest('hex');
console.log(hash); // 59a3054b4d6a12778bbb68325c31a9fd628c942a377ff5464628df054d4d304b

php

<?php
$payload = '{"links":{"self":"https:\/\/api.lemonsqueezy.com\/v1\/subscriptions\/xyz"}}';
$secret = 'abd1285d';

$hash      = hash_hmac('sha256', $payload, $secret);
print_r($hash); // fcf1dea9cb1cf586e990dd7b481feeb36cd17548eac0180882c97a5949146679
@brankoconjic
Copy link
Collaborator

Hey @theroman,

To securely validate a webhook hash in Node.js, you can use the following approach:

  1. Capture the raw request body to ensure you're hashing the exact data that was sent.
  2. Extract the signature from the request headers and decode it from hex.
  3. Generate a HMAC-SHA256 hash of the raw body using the webhook secret, and then convert it to a hex buffer.
  4. Compare the hash to the signature using crypto.timingSafeEqual to ensure it's done securely.

Here's the code that should work fine:

// Get the raw body content.
const rawBody = await request.text();

// Your webhook secret set in Lemon Squeezy dashboard.
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;

// Get the signature from the request headers.
const signature = Buffer.from(
  request.headers.get("X-Signature") ?? "",
  "hex",
);

// Create a HMAC-SHA256 hash of the raw `body` content using the `secret` and compare it to the `signature`.
const hmac = Buffer.from(
  crypto.createHmac("sha256", secret).update(rawBody).digest("hex"),
  "hex",
);

if (!crypto.timingSafeEqual(hmac, signature)) {
  // invalid signature
}

Make sure that both Lemon Squeezy API key and the Webhook are configured in the same mode (Test mode or live).

@theroman
Copy link
Author

theroman commented Aug 17, 2024

Hi @brankoconjic,

Unfortunately the suggested approach didn't work for me. Testing with express, nextjs and solid-start the resulting text body consistently had missing backslashes in all URL related data - resulting in a failed signature check.

In the end I opted for a hacky solution where I re-add the backslashes to match the payload:

const rawBody = (await event.request.text()).replace(/\//g, '\\/');

With this approach the signature check passes consistently.

It could be nice to be able to choose the integration type (node/php/python etc) from the webhook settings - and have a matching server signing the request on your end, without risking differing serialization/parsing methods influencing the output.

@brankoconjic
Copy link
Collaborator

When working with webhooks, the raw body data must be read as bytes to avoid issues like the backslashes being altered during parsing.

There's a working Next.js webhook route example here.

Note that we are using Buffer and hex encoding to read bytes instead of parsing the body content.

@theroman
Copy link
Author

@brankoconjic thanks for the updated example!

I cloned the example repo as is + my own webhook secret and keys.
verificatIon fails for me out of the box.
only after modifying https://github.com/lmsqueezy/nextjs-billing/blob/443939cd9e9093a61975177ddfaf07eecb650f18/src/app/api/webhook/route.ts#L17C1-L18C1 with .replace(/\//g, '\\/'); the signature verification passes

@brankoconjic
Copy link
Collaborator

That's odd. Is that deployed somewhere or are you working locally?

@brankoconjic
Copy link
Collaborator

brankoconjic commented Aug 19, 2024

Really not sure what's going on on your end without digging deeper into that. Here's a working, deployed version of the app (deployed on Vercel). Node v18 and v20 tested.

If you need further help with this, please reopen the issue.

Closed since cannot reproduce.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants
@brankoconjic @theroman and others