-
-
Save stigok/57d075c1cf2a609cb758898c0b202428 to your computer and use it in GitHub Desktop.
/* | |
* Verify GitHub webhook signature header in Node.js | |
* Written by stigok and others (see gist link for contributor comments) | |
* https://gist.github.com/stigok/57d075c1cf2a609cb758898c0b202428 | |
* Licensed CC0 1.0 Universal | |
*/ | |
const crypto = require('crypto') | |
const express = require('express') | |
const bodyParser = require('body-parser') | |
const secret = CHANGE_ME; | |
// For these headers, a sigHashAlg of sha1 must be used instead of sha256 | |
// GitHub: X-Hub-Signature | |
// Gogs: X-Gogs-Signature | |
const sigHeaderName = 'X-Hub-Signature-256' | |
const sigHashAlg = 'sha256' | |
const app = express() | |
// Saves a valid raw JSON body to req.rawBody | |
// Credits to https://stackoverflow.com/a/35651853/90674 | |
app.use(bodyParser.json({ | |
verify: (req, res, buf, encoding) => { | |
if (buf && buf.length) { | |
req.rawBody = buf.toString(encoding || 'utf8'); | |
} | |
}, | |
})) | |
function verifyPostData(req, res, next) { | |
if (!req.rawBody) { | |
return next('Request body empty') | |
} | |
const sig = Buffer.from(req.get(sigHeaderName) || '', 'utf8') | |
const hmac = crypto.createHmac(sigHashAlg, secret) | |
const digest = Buffer.from(sigHashAlg + '=' + hmac.update(req.rawBody).digest('hex'), 'utf8') | |
if (sig.length !== digest.length || !crypto.timingSafeEqual(digest, sig)) { | |
return next(`Request body digest (${digest}) did not match ${sigHeaderName} (${sig})`) | |
} | |
return next() | |
} | |
app.post('/', verifyPostData, function (req, res) { | |
res.status(200).send('Request body was signed') | |
}) | |
app.use((err, req, res, next) => { | |
if (err) console.error(err) | |
res.status(403).send('Request body was not signed or verification failed') | |
}) | |
app.listen(3000, () => console.log("Listening on port 3000")) |
@animir cheers! I just published an update. It is also important to compare the lengths of the buffers before calling timingSafeEqual to avoid exception being thrown.
@stigok why are we using 'utf-8' encoding in one place to convert from string to buffer, but then we use "hex" in the other? I think it should be "hex" everywhere.
// digest is constructed with "hex"
const digest = Buffer.from('sha1=' + hmac.update(payload).digest('hex'), 'utf8')
// checksum is constructed with "utf8"
const checksum = Buffer.from(sig, 'utf8')
@Juriy it's because we have to create Buffers to compare them with crypto.timingSafeEqual
. The text that is prepended to the hex digest (sha1=
) is not hex itself: Buffer.from('sha1=' + hmac.update(payload).digest('hex'), 'utf8')
. If you think I'm misunderstanding you, please provide example code to what you are proposing.
Hi. I'm new to node.js. I had to do a proof of concept to process a webhook with Azure Web App and Node,js. By using the code as is, besides the correct secret, it worked great. Thanks! I do get this warning and I'm not sure why.
(node:10332) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.
I used Visual Studio 19 and pushed to Azure Web App. Any ideas why I receive most appreciated.
Thanks,
@tpb1962 this is probably not coming from a verbatim copy of this script. If you read the source, we are not using Buffer()
directly, but are in fact using Buffer.from()
as the warning recommends. If you still see this warning, please provide your local Node.js version information.
Hi,
Code is copy and paste except for the secret line and the last line for the port ( app.listen(process.env.port); ) . Node version is 10.6.1 . I have attached some screenshots. I figured it may be because of something with running in Azure but not sure. It works fine just get that warning and as you said no "buffer" statements that should be flagged are being used.
`/*
- Verify GitHub webhook signature header in Node.js
- Written by stigok and others (see gist link for contributor comments)
- https://gist.github.com/stigok/57d075c1cf2a609cb758898c0b202428
- Licensed CC0 1.0 Universal
*/
const crypto = require('crypto')
const express = require('express')
const bodyParser = require('body-parser')
const secret = 'TimsSecret';
// GitHub: X-Hub-Signature
// Gogs: X-Gogs-Signature
const sigHeaderName = 'X-Hub-Signature'
const app = express()
app.use(bodyParser.json())
function verifyPostData(req, res, next) {
const payload = JSON.stringify(req.body)
if (!payload) {
return next('Request body empty')
}
const sig = req.get(sigHeaderName) || ''
const hmac = crypto.createHmac('sha1', secret)
const digest = Buffer.from('sha1=' + hmac.update(payload).digest('hex'), 'utf8')
const checksum = Buffer.from(sig, 'utf8')
if (checksum.length !== digest.length || !crypto.timingSafeEqual(digest, checksum)) {
return next(`Request body digest (${digest}) did not match ${sigHeaderName} (${checksum})`)
}
return next()
}
app.post('/', verifyPostData, function (req, res) {
res.status(200).send('Request body was signed')
})
app.use((err, req, res, next) => {
if (err) console.error(err)
res.status(403).send('Request body was not signed or verification failed')
})
//app.listen(3000)
app.listen(process.env.port);
`
Sorry, it is 10.16.1. Package.json updated as well. Republished and still got the warning.
Thanks
I am unable to reproduce this on Linux with Node.js v10.16.1 and express@4.17.1. Maybe this is some Windows-specific issue. Good luck :)
@stigok - I'd say you are right. Thanks for checking!
Thanks.
How "Length Extension Attack" is mitigated in this code?
Thank you for this! Worked perfectly.
Thank for the help.
For me it only worked when using req.body
instead of JSON.stringify(req.body)
when calling hmac.update(payload)
@pimverkerk that sounds strange. When using bodyParser.json()
it should only be parsing requests with Content-Type: application/json
, and req.body
should be a JavaScript object. Are you using GitHub webhooks, and otherwise using a verbatim copy of this code?
Hi @stigok, thanks for sharing this!
I used this code for a different purpose, not for Github webhooks. In my case, I found that stringifying the parsed body does not necessarily return the exact original request body. Consider for example JSON.stringify(JSON.parse("{\"value\":1.0}"))
, which returns {"value":1}
. I don't know if this happens with the particular data sent by the Github webhooks, but if that is the case, I found the solutions here to be helpful https://stackoverflow.com/a/35651853/1531992
@ludcila oh, I see. So this can become a problem when verifying the signature. In JavaScript (1 === 1.0) === true
, and 1.0
will be represented as 1
between de- and serialisation. Thanks for reporting. I'll see what I can do here.
works with sha256 too.
I would suggest changing it to sha256
and using the header X-Hub-Signature-256
as it's the recommendation from GitHub
@andrefcdias could you please post a reference?
I would suggest changing it to
sha256
and using the headerX-Hub-Signature-256
as it's the recommendation from GitHub
@andrefcdias thank you! I've updated the code with new header name and hash alg.
Hi @stigok, thanks for sharing this!
I used this code for a different purpose, not for Github webhooks. In my case, I found that stringifying the parsed body does not necessarily return the exact original request body. Consider for example
JSON.stringify(JSON.parse("{\"value\":1.0}"))
, which returns{"value":1}
. I don't know if this happens with the particular data sent by the Github webhooks, but if that is the case, I found the solutions here to be helpful https://stackoverflow.com/a/35651853/1531992
Thanks again! Now imlemented!
thank you :)
Thank you for sharing this, @stigok!
Copy-paste for Typescript with express 4.16+
import express, { NextFunction, Request, Response } from 'express';
import crypto from 'crypto';
const secret = process.env.SECRET;
const sigHeaderName = 'X-Hub-Signature-256';
const sigHashAlg = 'sha256';
function verifyPayload(req: Request, res: Response, next: NextFunction): void {
if (!req.body) {
return next('Request body empty');
}
const data = JSON.stringify(req.body);
const sig = Buffer.from(req.get(sigHeaderName) || '', 'utf8');
const hmac = crypto.createHmac(sigHashAlg, secret);
const digest = Buffer.from(`${sigHashAlg}=${hmac.update(data).digest('hex')}`, 'utf8');
if (sig.length !== digest.length || !crypto.timingSafeEqual(digest, sig)) {
return next(`Request body digest (${digest}) did not match ${sigHeaderName} (${sig})`);
}
return next();
}
export const app = express();
app.use(express.json());
app.post('/', verifyPayload, (req: Request, res: Response) => {
res.status(200).send('Request body was signed');
});
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err) {
console.error(err);
}
res.status(403).send('Request body was not signed or verification failed');
});
Thanks a lot !!!!
Does the request body get sent as a base64 encoded string? I'm trying to write an AWS lambda function to deal with this. Not sure if this is something that happens with AWS in general or if the payload actually gets sent as a base64 encoded string.
Does the request body get sent as a base64 encoded string? I'm trying to write an AWS lambda function to deal with this. Not sure if this is something that happens with AWS in general or if the payload actually gets sent as a base64 encoded string.
I recommend you read the API documentation for AWS lambda to find an answer to this. This code works as-is for incoming HTTP GitHub webhook requests.
I'm trying to figure this out while using Cloudflare Workers (and itty-router instead of express), anyone have any suggestions?
@stigok Thanks for the example.
It is important to use timing safe signatures comparing against timing attacks: