Skip to content

Commit b35e0c4

Browse files
committed
fix: require webhook signatures
1 parent f6fc3ab commit b35e0c4

4 files changed

Lines changed: 71 additions & 24 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ jobs:
2929
PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
3030
AI_GATEWAY_TOKEN: ${{ secrets.AI_GATEWAY_TOKEN }}
3131
INTERNAL_SECRET: ${{ secrets.INTERNAL_SECRET }}
32+
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
3233
run: |
33-
jq -n --arg private_key "$PRIVATE_KEY" --arg ai "$AI_GATEWAY_TOKEN" --arg internal "$INTERNAL_SECRET" \
34-
'{PRIVATE_KEY: $private_key, AI_GATEWAY_TOKEN: $ai, INTERNAL_SECRET: $internal}' > "$RUNNER_TEMP/secrets.json"
34+
jq -n --arg private_key "$PRIVATE_KEY" --arg ai "$AI_GATEWAY_TOKEN" --arg internal "$INTERNAL_SECRET" --arg webhook "$WEBHOOK_SECRET" \
35+
'{PRIVATE_KEY: $private_key, AI_GATEWAY_TOKEN: $ai, INTERNAL_SECRET: $internal, WEBHOOK_SECRET: $webhook}' > "$RUNNER_TEMP/secrets.json"
3536
3637
- name: Deploy
3738
uses: cloudflare/wrangler-action@v4

‎AGENTS.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,13 @@ GitHub webhook
7575

7676
`main` 에 머지되면 GitHub Actions(`.github/workflows/deploy.yml`)가 테스트 후 `wrangler deploy --secrets-file` 로 배포한다. PR 에서는 `integration.yaml` 이 테스트만 돈다. 웹훅을 받는 서버라 PR 미리보기는 두지 않는다.
7777

78-
| Worker 에서 읽는 이름 | 어디서 오나 |
79-
| ---------------------- | ----------------------------------------------------------- |
80-
| `APP_ID`, `WORKER_URL` | `wrangler.jsonc` 의 `vars` (공개 값) |
81-
| `PRIVATE_KEY` | GitHub org secret `APP_PRIVATE_KEY` |
82-
| `AI_GATEWAY_TOKEN` | GitHub org secret `AI_GATEWAY_TOKEN` |
83-
| `INTERNAL_SECRET` | GitHub 저장소 secret `INTERNAL_SECRET` |
84-
| `WEBHOOK_SECRET` | (선택) 있으면 웹훅 서명을 검증한다. 지금은 설정돼 있지 않다 |
78+
| Worker 에서 읽는 이름 | 어디서 오나 |
79+
| ---------------------- | ----------------------------------------------------------------------------------- |
80+
| `APP_ID`, `WORKER_URL` | `wrangler.jsonc` 의 `vars` (공개 값) |
81+
| `PRIVATE_KEY` | GitHub org secret `APP_PRIVATE_KEY` |
82+
| `AI_GATEWAY_TOKEN` | GitHub org secret `AI_GATEWAY_TOKEN` |
83+
| `INTERNAL_SECRET` | GitHub 저장소 secret `INTERNAL_SECRET` |
84+
| `WEBHOOK_SECRET` | GitHub 저장소 secret `WEBHOOK_SECRET`. GitHub App 웹훅 설정의 secret 과 같은 값이다 |
8585

8686
GitHub secret 은 배포할 때마다 Worker secret 으로 들어가므로 `wrangler secret put` 으로 따로 바꾸지 않는다. 다음 배포 때 GitHub 값으로 덮인다.
8787
로컬에서는 `.dev.vars` 에 같은 이름으로 넣는다. `.dev.vars`, `*.pem` 은 커밋하지 않는다.
@@ -90,7 +90,7 @@ GitHub secret 은 배포할 때마다 Worker secret 으로 들어가므로 `wran
9090

9191
https://github.com/organizations/DaleStudy/settings/apps/dalestudy
9292

93-
- Webhook URL: `https://github.dalestudy.com/webhooks`
93+
- Webhook URL: `https://github.dalestudy.com/webhooks`, Webhook secret 은 저장소 secret `WEBHOOK_SECRET` 과 같은 값. `/webhooks` 는 서명(`X-Hub-Signature-256`)이 맞지 않거나 `WEBHOOK_SECRET` 이 없으면 받지 않는다
9494
- Repository permissions: Contents(Read), Issues(Read & write), Metadata(Read), Pull requests(Read & write), Projects(Read & write)
9595
- 이벤트: Issue comments, Pull request review comments, Projects v2 item, Pull requests
9696
- DaleStudy 조직에 설치돼 있고 leetcode-study 에 접근할 수 있어야 한다

‎index.js‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,21 @@ export default {
3737
const signature = request.headers.get("X-Hub-Signature-256");
3838
const rawBody = await request.text();
3939

40-
// Secret이 설정되어 있으면 검증
41-
if (env.WEBHOOK_SECRET) {
42-
const isValid = await verifyWebhookSignature(
43-
rawBody,
44-
signature,
45-
env.WEBHOOK_SECRET
46-
);
47-
48-
if (!isValid) {
49-
console.error("Invalid webhook signature");
50-
return errorResponse("Invalid signature", 401);
51-
}
40+
// Secret이 없으면 서명을 검증할 수 없으므로 받지 않는다
41+
if (!env.WEBHOOK_SECRET) {
42+
console.error("WEBHOOK_SECRET is not configured");
43+
return errorResponse("Webhook secret is not configured", 500);
44+
}
45+
46+
const isValid = await verifyWebhookSignature(
47+
rawBody,
48+
signature,
49+
env.WEBHOOK_SECRET
50+
);
51+
52+
if (!isValid) {
53+
console.error("Invalid webhook signature");
54+
return errorResponse("Invalid signature", 401);
5255
}
5356

5457
// Request 객체 재생성 (body를 다시 읽을 수 있도록)

‎tests/worker-runtime.test.js‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { describe, expect, it } from "vitest";
44

55
import worker from "../index.js";
66

7-
async function fetchWorker(input, init = {}) {
7+
async function fetchWorker(input, init = {}, bindings = env) {
88
const request = input instanceof Request ? input : new Request(input, init);
99
const ctx = createExecutionContext();
10-
const response = await worker.fetch(request, env, ctx);
10+
const response = await worker.fetch(request, bindings, ctx);
1111

1212
await waitOnExecutionContext(ctx);
1313

@@ -59,4 +59,47 @@ describe("Worker runtime smoke test", () => {
5959
it("exposes WORKER_URL from wrangler config in env", () => {
6060
expect(env.WORKER_URL).toBe("https://github.dalestudy.workers.dev");
6161
});
62+
63+
describe("/webhooks signature", () => {
64+
const secret = "test-webhook-secret";
65+
const body = JSON.stringify({ zen: "ping", repository: { name: "other-repo" } });
66+
67+
async function sign(payload) {
68+
const key = await crypto.subtle.importKey(
69+
"raw",
70+
new TextEncoder().encode(secret),
71+
{ name: "HMAC", hash: "SHA-256" },
72+
false,
73+
["sign"],
74+
);
75+
const bytes = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
76+
return "sha256=" + [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
77+
}
78+
79+
function webhookRequest(signature) {
80+
const headers = { "Content-Type": "application/json", "X-GitHub-Event": "ping" };
81+
if (signature) headers["X-Hub-Signature-256"] = signature;
82+
return new Request("https://example.com/webhooks", { method: "POST", headers, body });
83+
}
84+
85+
it("rejects webhooks when WEBHOOK_SECRET is not configured", async () => {
86+
const response = await fetchWorker(webhookRequest(await sign(body)), {}, { ...env, WEBHOOK_SECRET: undefined });
87+
88+
expect(response.status).toBe(500);
89+
});
90+
91+
it("rejects webhooks with a missing or wrong signature", async () => {
92+
const bindings = { ...env, WEBHOOK_SECRET: secret };
93+
94+
expect((await fetchWorker(webhookRequest(), {}, bindings)).status).toBe(401);
95+
expect((await fetchWorker(webhookRequest("sha256=" + "0".repeat(64)), {}, bindings)).status).toBe(401);
96+
});
97+
98+
it("accepts webhooks signed with WEBHOOK_SECRET", async () => {
99+
const response = await fetchWorker(webhookRequest(await sign(body)), {}, { ...env, WEBHOOK_SECRET: secret });
100+
101+
expect(response.status).toBe(200);
102+
});
103+
});
62104
});
105+

0 commit comments

Comments
 (0)