Issue Title: Unable to Verify Ghost Event Using Webhook
Problem:
I am currently working on integrating webhooks from my Ghost website into my server, and I’m having trouble understanding how to verify that the events received by my server are indeed from my Ghost website and not from a potential malicious source.
Context:
I have set up webhooks in my Ghost website to notify my server of certain events such as new post creation, but I’m not sure how to implement authentication or headers to ensure that the incoming events are legitimate.
Request:
I would greatly appreciate it if someone could provide guidance on how to properly verify the authenticity of Ghost events received via webhooks. It would be especially helpful if there are specific steps or code examples that can be shared to illustrate the process.
Steps to Reproduce
Create a webhook
Send an event
Ghost Version
Version: 5.67.0 Environment: Production Database: mysql8 Mail: SMTP
When your server receives a webhook from Ghost, have it make an API call to get the information independently. In other words, use the webhook as a trigger, not a source of truth.
Can you please provide clarification or documentation on how to verify it. This information is non existent in the official documentation and super confusing.
You’ll need to perform the same operation that was performed in the linked line of code and compare the stripped* x-ghost-signature value with your locally computed value
*e.g.
const [externalHmac, timestamp] = header.split(',');
if (seenTimestamps.has(timestamp)) {
return false;
}
// You might also want to make sure the timestamp is within the last X period (like 3 mins)
if (hash(...) !== exteralHmac) {
return false;
}
return true;
I’m just a member of the Ghost community so I can’t update the documentation. I don’t have the bandwidth to provide the exact solution, so I’ve provided a starting point and high-level code example.
I haven’t seen a proper documentation about it either, but here’s hopefully a better example for those who stumble upon this post. It would also be better to verify the information on a second hand as mentioned by Cathy.
const signature = request.headers['x-ghost-signature'];
if (!signature || typeof signature !== 'string') {
logger.error(
`Request from ${request.ip} to ${request.originalUrl} denied, missing or invalid x-ghost-signature header`,
GhostSecretKeyGuard.name,
);
throw new UnauthorizedException();
}
const [ghostHmac, timestampPart] = signature.split(',');
if (!ghostHmac || !timestampPart) {
logger.error(
`Request from ${request.ip} to ${request.originalUrl} denied, missing parts in x-ghost-signature header`,
GhostSecretKeyGuard.name,
);
throw new UnauthorizedException();
}
const timestamp = parseInt(timestampPart, 10);
const currentTimestamp = Date.now();
const timestampDiff = Math.abs(currentTimestamp - timestamp);
// Weak check for replay attack
if (isNaN(timestampDiff) || timestampDiff > MAX_TIMESTAMP_DIFF /* Ex: 5000 ms */) {
logger.error(
`Request from ${request.ip} to ${request.originalUrl} denied, invalid timestamp in x-ghost-signature header`,
GhostSecretKeyGuard.name,
);
throw new UnauthorizedException();
}
const hmac = crypto
.createHmac('sha256', config.api.ghost.secret /* Secret used for the webhook */)
.update(JSON.stringify(request.body))
.digest('hex');
if (ghostHmac !== `sha256=${hmac}`) {
logger.error(
`Request from ${request.ip} to ${request.originalUrl} denied, invalid ghost signature`,
GhostSecretKeyGuard.name,
);
throw new UnauthorizedException();
}
return true;
This still doesn’t work for me. If I try to compute the hash using the following code then it is still different from the value from in the x-ghost-signature header:
I spent 2 days to understand why my webhooks stopped working and then realized that webhook signature format is changed 6 months ago with a “patch” release. (Release 5.87.1 · TryGhost/Ghost · GitHub)
This is very concerning. As the commit message already mentions, this is a BREAKING CHANGE, how can it be released with a patch release, and just 1 line of text without mentioning any risks of it?
I hope this will never happen again and Ghost team will stick to the semantic versioning rules. Otherwise what is the meaning of having all those numbers in the versions, right? Please.