🔒 Added timestamp to webhook signature hash (#20500)

refs
[ENG-1238](https://linear.app/tryghost/issue/ENG-1238/🔒-webhook-signatures-dont-include-timestamp-in-the-signature)

Added timestamp to the webhook signature hash to prevent replay attacks.
This is
a breaking change for webhook consumers as signature verification logic
will need to be updated to account for the timestamp in the hash, for
example:

```js
const crypto = require('crypto');

// Webhook secret from Ghost Admin
const WEBHOOK_SECRET = 'FOOBARBAZ'

// Sample incoming webhook request object
const req = {
    headers: {
        'x-ghost-signature': 'sha256=fc9749d5b3333109bd779f65d4b1b891576bc5c92febea3b1d186a7f946d0745, t=1719842984367'
    },
    body: {
        tag: {
            current: {
                id: '6682b8a8e10cc04306284330',
                name: 'test',
                slug: 'test',
                description: null,
                feature_image: null,
                visibility: 'public',
                og_image: null,
                og_title: null,
                og_description: null,
                twitter_image: null,
                twitter_title: null,
                twitter_description: null,
                meta_title: null,
                meta_description: null,
                codeinjection_head: null,
                codeinjection_foot: null,
                canonical_url: null,
                accent_color: null,
                created_at: '2024-07-01T14:09:44.000Z',
                updated_at: '2024-07-01T14:09:44.000Z',
                url: 'http://localhost:2368/404/'
            },
            previous: {}
        }
    }
};

// Get the request body as a JSON string
const reqBodyJSON = JSON.stringify(req.body);

// Extract the hash and timestamp from the x-ghost-signature header
const {sha256: hash, t: timestamp} = req.headers['x-ghost-signature']
    .split(', ')
    .map((x) => x.split('='))
    .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})

// Recreate the hash using the secret, request body, and timestamp and compare it to the hash from the header
const isValid = crypto.createHmac('sha256', WEBHOOK_SECRET).update(`${reqBodyJSON}${timestamp}`).digest('hex') === hash

if (isValid) {
    console.log('Valid signature!')
}
```
This commit is contained in:
Michael Barrett 2024-07-01 15:59:04 +01:00 committed by GitHub
parent fcb95ecc1a
commit c285b0a0f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 126 additions and 53 deletions

View File

@ -87,6 +87,7 @@ class WebhookTrigger {
const reqPayload = JSON.stringify(hookPayload);
const url = webhook.get('target_url');
const secret = webhook.get('secret') || '';
const ts = Date.now();
const headers = {
'Content-Length': Buffer.byteLength(reqPayload),
@ -95,7 +96,7 @@ class WebhookTrigger {
};
if (secret !== '') {
headers['X-Ghost-Signature'] = `sha256=${crypto.createHmac('sha256', secret).update(reqPayload).digest('hex')}, t=${Date.now()}`;
headers['X-Ghost-Signature'] = `sha256=${crypto.createHmac('sha256', secret).update(`${reqPayload}${ts}`).digest('hex')}, t=${ts}`;
}
const opts = {

View File

@ -1,83 +1,155 @@
const assert = require('assert/strict');
const crypto = require('crypto');
const sinon = require('sinon');
const WebhookTrigger = require('../../../../../core/server/services/webhooks/WebhookTrigger');
const SIGNATURE_HEADER = 'X-Ghost-Signature';
const SIGNATURE_REGEX = /^sha256=[a-z0-9]+, t=\d+$/;
describe('Webhook Service', function () {
const models = {
Webhook: {
edit: () => sinon.stub().resolves(null),
destroy: () => sinon.stub().resolves(null),
findAllByEvent: () => sinon.stub().resolves(null),
getByEventAndTarget: () => sinon.stub().resolves(null),
add: () => sinon.stub().resolves(null)
}
};
const WEBHOOK_EVENT = 'post.added';
const WEBHOOK_TARGET_URL = 'http://example.com';
const WEBHOOK_SECRET = 'abc123dontstealme';
let payload = sinon.stub().resolves(null);
let models, payload, request, webhookTrigger;
afterEach(function () {
sinon.restore();
beforeEach(function () {
models = {
Webhook: {
edit: sinon.stub(),
destroy: sinon.stub(),
findAllByEvent: sinon.stub(),
getByEventAndTarget: sinon.stub(),
add: sinon.stub()
}
};
models.Webhook.edit.resolves(null);
models.Webhook.destroy.resolves(null);
models.Webhook.findAllByEvent.resolves(null);
models.Webhook.getByEventAndTarget.resolves(null);
models.Webhook.add.resolves(null);
payload = sinon.stub();
request = sinon.stub().resolves({});
webhookTrigger = new WebhookTrigger({models, payload, request});
sinon.stub(webhookTrigger, 'onSuccess').callsFake(() => Promise.resolve());
sinon.stub(webhookTrigger, 'onError').callsFake(() => Promise.resolve());
});
describe('trigger', function () {
it('Does not trigger payload handler when event and model that has no hooks registered', async function () {
sinon.stub(models.Webhook, 'findAllByEvent')
.withArgs('post.added', {context: {internal: true}})
it('does not trigger payload handler when there are no hooks registered for an event', async function () {
models.Webhook.findAllByEvent
.withArgs(WEBHOOK_EVENT, {context: {internal: true}})
.resolves({models: []});
const webhookTrigger = new WebhookTrigger({
models,
payload
});
await webhookTrigger.trigger('post.added');
await webhookTrigger.trigger(WEBHOOK_EVENT);
assert.equal(models.Webhook.findAllByEvent.called, true);
assert.equal(payload.called, false);
assert.equal(request.called, false);
});
it('Does triggers payload handler and request when event when model has a registered hook', async function () {
const postModelStub = sinon.stub();
const webhookModelStub = {
get: () => {}
it('triggers payload handler when there are hooks registered for an event', async function () {
const webhookModel = {
get: sinon.stub()
};
sinon.stub(webhookModelStub, 'get')
.withArgs('event').returns('post.added')
.withArgs('target_url').returns('http://example.com');
const requestStub = sinon.stub().resolves({});
webhookModel.get
.withArgs('event').returns(WEBHOOK_EVENT)
.withArgs('target_url').returns(WEBHOOK_TARGET_URL);
sinon.stub(models.Webhook, 'findAllByEvent')
.withArgs('post.added', {context: {internal: true}})
.resolves({models: [webhookModelStub]});
models.Webhook.findAllByEvent
.withArgs(WEBHOOK_EVENT, {context: {internal: true}})
.resolves({models: [webhookModel]});
payload = sinon.stub().resolves({data: [1]});
const postModel = sinon.stub();
const webhookTrigger = new WebhookTrigger({
models,
payload,
request: requestStub
});
payload
.withArgs(WEBHOOK_EVENT, postModel)
.resolves({data: [1]});
sinon.stub(webhookTrigger, 'onSuccess').callsFake(function () {
return Promise.resolve();
});
sinon.stub(webhookTrigger, 'onError').callsFake(function () {
return Promise.resolve();
});
await webhookTrigger.trigger('post.added', postModelStub);
await webhookTrigger.trigger(WEBHOOK_EVENT, postModel);
assert.equal(models.Webhook.findAllByEvent.called, true);
assert.equal(payload.called, true);
assert.equal(request.called, true);
assert.equal(request.args[0][0], WEBHOOK_TARGET_URL);
assert.equal(request.args[0][1].body, '{"data":[1]}');
assert.deepEqual(Object.keys(request.args[0][1].headers), ['Content-Length', 'Content-Type', 'Content-Version']);
assert.equal(request.args[0][1].headers['Content-Length'], 12);
assert.equal(request.args[0][1].headers['Content-Type'], 'application/json');
assert.match(request.args[0][1].headers['Content-Version'], /v\d+\.\d+/);
});
assert.equal(requestStub.called, true);
assert.equal(requestStub.args[0][0], 'http://example.com');
assert.equal(requestStub.args[0][1].body, '{"data":[1]}');
assert.deepEqual(Object.keys(requestStub.args[0][1].headers), ['Content-Length', 'Content-Type', 'Content-Version']);
assert.equal(requestStub.args[0][1].headers['Content-Length'], 12);
assert.equal(requestStub.args[0][1].headers['Content-Type'], 'application/json');
assert.match(requestStub.args[0][1].headers['Content-Version'], /v\d+\.\d+/);
it('includes a signature header when a webhook has a secret', async function () {
const webhookModel = {
get: sinon.stub()
};
webhookModel.get
.withArgs('event').returns(WEBHOOK_EVENT)
.withArgs('target_url').returns(WEBHOOK_TARGET_URL)
.withArgs('secret').returns(WEBHOOK_SECRET);
models.Webhook.findAllByEvent
.withArgs(WEBHOOK_EVENT, {context: {internal: true}})
.resolves({models: [webhookModel]});
const postModel = sinon.stub();
payload
.withArgs(WEBHOOK_EVENT, postModel)
.resolves({data: [1]});
await webhookTrigger.trigger(WEBHOOK_EVENT, postModel);
assert.equal(models.Webhook.findAllByEvent.called, true);
assert.equal(payload.called, true);
assert.equal(request.called, true);
assert.equal(request.args[0][0], WEBHOOK_TARGET_URL);
const header = request.args[0][1].headers[SIGNATURE_HEADER];
assert.equal(SIGNATURE_REGEX.test(header), true);
});
it('uses the request payload and a timestamp to generate the hash in the signature header', async function () {
const clock = sinon.useFakeTimers();
const ts = Date.now();
const webhookModel = {
get: sinon.stub()
};
webhookModel.get
.withArgs('event').returns(WEBHOOK_EVENT)
.withArgs('target_url').returns(WEBHOOK_TARGET_URL)
.withArgs('secret').returns(WEBHOOK_SECRET);
models.Webhook.findAllByEvent
.withArgs(WEBHOOK_EVENT, {context: {internal: true}})
.resolves({models: [webhookModel]});
const postModel = sinon.stub();
payload
.withArgs(WEBHOOK_EVENT, postModel)
.resolves({data: [1]});
await webhookTrigger.trigger(WEBHOOK_EVENT, postModel);
assert.equal(models.Webhook.findAllByEvent.called, true);
assert.equal(payload.called, true);
assert.equal(request.called, true);
assert.equal(request.args[0][0], WEBHOOK_TARGET_URL);
const expectedHeader = `sha256=${crypto.createHmac('sha256', WEBHOOK_SECRET).update(`{"data":[1]}${ts}`).digest('hex')}, t=${ts}`;
const header = request.args[0][1].headers[SIGNATURE_HEADER];
assert.equal(expectedHeader, header);
clock.restore();
});
});
});