Skip to content

Commit

Permalink
fix: webhook verification using a constant-time comparison (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlCalzone authored Sep 6, 2022
1 parent f7296c7 commit dee1c77
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 99 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-latest
name: Deploy
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Publish
uses: cloudflare/wrangler-action@1.3.0
uses: cloudflare/wrangler-action@2.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
16 changes: 4 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Note that you require access to the new GitHub Actions for the automated deploym
1. Install the `wrangler` CLI and login with your account

```
npm install --global @cloudflare/wrangler
npm install --global wrangler
wrangler login
```

Expand All @@ -42,7 +42,7 @@ Note that you require access to the new GitHub Actions for the automated deploym
wrangler secret put WEBHOOK_SECRET
```

- `PRIVATE_KEY_1`, `PRIVATE_KEY_2`, `PRIVATE_KEY_3`: Generate a private key (see the button at the bottom of your GitHub App registration's settings page).
- `PRIVATE_KEY`: Generate a private key (see the button at the bottom of your GitHub App registration's settings page).

1. You will be prompted to download a `*.pem` file. After download, rename it to `private-key.pem`.
1. Convert the key from the `PKCS#1` format to `PKCS#8` (The WebCrypto API only supports `PKCS#8`):
Expand All @@ -51,18 +51,10 @@ Note that you require access to the new GitHub Actions for the automated deploym
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private-key.pem -out private-key-pkcs8.pem
```

1. The contents of the private key is too large for the 1kb limit of Cloudflare Workers secrets. Split it up into 3 parts using [split](https://man.cx/split). This will create 3 new files: `xaa`, `xab`, `xac`
1. Write the contents of the new file into the secret `PRIVATE_KEY`:

```
split -l 10 private-key-pkcs8.pem
```

1. Write the contents of the 3 new files into the secrets `PRIVATE_KEY_1`, `PRIVATE_KEY_2`, and `PRIVATE_KEY_3`:

```
cat xaa | wrangler secret put PRIVATE_KEY_1
cat xab | wrangler secret put PRIVATE_KEY_2
cat xac | wrangler secret put PRIVATE_KEY_3
cat private-key-pkcs8.pem | wrangler secret put PRIVATE_KEY
```

1. Add the following secret in your fork's repository settings:
Expand Down
52 changes: 52 additions & 0 deletions lib/verify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export async function verifyWebhookSignature(payload, signature, secret) {
if (!signature) {
throw new Error("Signature is missing");
} else if (!signature.startsWith("sha256=")) {
throw new Error("Invalid signature format");
}

const algorithm = { name: "HMAC", hash: "SHA-256" };
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
algorithm,
false,
["sign", "verify"]
);

const signed = await crypto.subtle.sign(
algorithm.name,
key,
enc.encode(payload)
);
const expectedSignature = "sha256=" + array2hex(signed);
if (!safeCompare(expectedSignature, signature)) {
throw new Error("Signature does not match event payload and secret");
}

// All good!
}

function array2hex(arr) {
return [...new Uint8Array(arr)]
.map((x) => x.toString(16).padStart(2, "0"))
.join("");
}

/** Constant-time string comparison */
function safeCompare(expected, actual) {
const lenExpected = expected.length;
let result = 0;

if (lenExpected !== actual.length) {
actual = expected;
result = 1;
}

for (let i = 0; i < lenExpected; i++) {
result |= expected.charCodeAt(i) ^ actual.charCodeAt(i);
}

return result === 0;
}
169 changes: 87 additions & 82 deletions worker.js
Original file line number Diff line number Diff line change
@@ -1,97 +1,102 @@
const { App } = require("@octokit/app");
import { App } from "@octokit/app";
import { verifyWebhookSignature } from "./lib/verify.js";

// wrangler secret put APP_ID
const appId = APP_ID;
// wrangler secret put WEBHOOK_SECRET
const secret = WEBHOOK_SECRET;
export default {
/**
* @param {Request} request
* @param {Record<string, any>} env
*/
async fetch(request, env) {

// The private-key.pem file from GitHub needs to be transformed from the
// PKCS#1 format to PKCS#8, as the crypto APIs do not support PKCS#1:
//
// openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private-key.pem -out private-key-pkcs8.pem
//
// The private key is too large, so we split it up across 3 keys.
// You can split up the *.pem file into 3 equal parts with
//
// split -l 10 private-key-pkcs8.pem
//
// Then set the priveat keys
//
// cat xaa | wrangler secret put PRIVATE_KEY_1
// cat xab | wrangler secret put PRIVATE_KEY_2
// cat xac | wrangler secret put PRIVATE_KEY_3
//
const privateKey = [PRIVATE_KEY_1, PRIVATE_KEY_2, PRIVATE_KEY_3].join("\n");
// wrangler secret put APP_ID
const appId = env.APP_ID;
// wrangler secret put WEBHOOK_SECRET
const secret = env.WEBHOOK_SECRET;

// instantiate app
// https://github.com/octokit/app.js/#readme
const app = new App({
appId,
privateKey,
webhooks: {
secret,
},
});
// The private-key.pem file from GitHub needs to be transformed from the
// PKCS#1 format to PKCS#8, as the crypto APIs do not support PKCS#1:
//
// openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private-key.pem -out private-key-pkcs8.pem
//
// Then set the private key
//
// cat private-key-pkcs8.pem | wrangler secret put PRIVATE_KEY
//
const privateKey = env.PRIVATE_KEY;

app.webhooks.on("issues.opened", async ({ octokit, payload }) => {
await octokit.request(
"POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: payload.issue.number,
body:
"Hello there from [Cloudflare Workers](https://github.com/gr2m/cloudflare-worker-github-app-example/#readme)",
}
);
});
// instantiate app
// https://github.com/octokit/app.js/#readme
const app = new App({
appId,
privateKey,
webhooks: {
secret,
},
});

addEventListener("fetch", (event) => {
event.respondWith(handleRequest(event.request));
});
app.webhooks.on("issues.opened", async ({ octokit, payload }) => {
await octokit.request(
"POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: payload.issue.number,
body:
"Hello there from [Cloudflare Workers](https://github.com/gr2m/cloudflare-worker-github-app-example/#readme)",
}
);
});

/**
* Respond with hello worker text
* @param {Request} request
*/
async function handleRequest(request) {
if (request.method === "GET") {
const { data } = await app.octokit.request("GET /app");
if (request.method === "GET") {
const { data } = await app.octokit.request("GET /app");

return new Response(
`<h1>Cloudflare Worker Example GitHub app</h1>
return new Response(
`<h1>Cloudflare Worker Example GitHub app</h1>
<p>Installation count: ${data.installations_count}</p>
<p><a href="https://github.com/apps/cloudflare-worker-example">Install</a> | <a href="https://github.com/gr2m/cloudflare-worker-github-app-example/#readme">source code</a></p>`,
{
headers: { "content-type": "text/html" },
}
);
}
{
headers: { "content-type": "text/html" },
}
);
}

const id = request.headers.get("x-github-delivery");
const name = request.headers.get("x-github-event");
const payload = await request.json();
const id = request.headers.get("x-github-delivery");
const name = request.headers.get("x-github-event");
const signature = request.headers.get("x-hub-signature-256") ?? "";
const payloadString = await request.text();
const payload = JSON.parse(payloadString);

try {
// TODO: implement signature verification
// https://github.com/gr2m/cloudflare-worker-github-app-example/issues/1
await app.webhooks.receive({
id,
name,
payload,
});
// Verify webhook signature
try {
await verifyWebhookSignature(payloadString, signature, secret);
} catch (error) {
app.log.warn(error.message);
return new Response(`{ "error": "${error.message}" }`, {
status: 400,
headers: { "content-type": "application/json" },
});
}

return new Response(`{ "ok": true }`, {
headers: { "content-type": "application/json" },
});
} catch (error) {
app.log.error(error);
// Now handle the request
try {
await app.webhooks.receive({
id,
name,
payload,
});

return new Response(`{ "error": "${error.message}" }`, {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
return new Response(`{ "ok": true }`, {
headers: { "content-type": "application/json" },
});
} catch (error) {
app.log.error(error);

return new Response(`{ "error": "${error.message}" }`, {
status: 500,
headers: { "content-type": "application/json" },
});
}
},
};
5 changes: 2 additions & 3 deletions wrangler.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
name = "github-app-example"
type = "webpack"
account_id = "f8d9cac88285c4d654ba305a92e952d6"
workers_dev = true
route = ""
zone_id = ""

main = "worker.js"

0 comments on commit dee1c77

Please sign in to comment.