@edgehero/pi-dispatch-receiver 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +52 -0
- package/src/azure-members.mjs +119 -0
- package/src/azure-subset.mjs +166 -0
- package/src/cli.mjs +82 -0
- package/src/config.mjs +257 -0
- package/src/filter-azure.mjs +273 -0
- package/src/filter-forgejo.mjs +206 -0
- package/src/filter-gitlab.mjs +252 -0
- package/src/filter.mjs +224 -0
- package/src/forgejo-members.mjs +90 -0
- package/src/forgejo-subset.mjs +113 -0
- package/src/gitlab-members.mjs +91 -0
- package/src/gitlab-subset.mjs +76 -0
- package/src/http-body.mjs +73 -0
- package/src/poller-config.mjs +100 -0
- package/src/poller.mjs +700 -0
- package/src/predicate.mjs +62 -0
- package/src/receiver.mjs +330 -0
- package/src/start.mjs +176 -0
- package/src/verify-azure.mjs +109 -0
- package/src/verify-gitlab.mjs +193 -0
- package/src/verify.mjs +98 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Azure DevOps trust boundary.
|
|
3
|
+
*
|
|
4
|
+
* WHAT AZURE CANNOT DO, stated first because everything here follows from it: Service Hooks offer no HMAC.
|
|
5
|
+
* The only authentication a subscription can carry is HTTP Basic credentials or a static custom header. A
|
|
6
|
+
* bare credential proves the sender knew a secret and COVERS NO BYTES -- it says nothing about whether the
|
|
7
|
+
* body arrived as it was sent.
|
|
8
|
+
*
|
|
9
|
+
* So `CONST-HMAC-OVER-RAW-BODY` is satisfied here in one half and not the other, and this is not a new
|
|
10
|
+
* class of exception: it is exactly GitLab's `token` mode, which that entry already admits BY NAME. What
|
|
11
|
+
* does NOT vary, and is preserved exactly, is the ordering the whole downstream depends on -- raw bytes,
|
|
12
|
+
* then verify, then parse -- and the refusal to let the request choose which mechanism it faces.
|
|
13
|
+
*
|
|
14
|
+
* TWO CONSEQUENCES AN OPERATOR HAS TO KNOW ABOUT, both documented rather than papered over:
|
|
15
|
+
*
|
|
16
|
+
* - The DELIVERY ID IS IN THE BODY. Azure sends no delivery-id header at all, so the dedup key
|
|
17
|
+
* (`REQ-DEDUP-BY-DELIVERY-GUID`) is read from the verified payload. `verify-gitlab.mjs` explicitly
|
|
18
|
+
* REFUSES to do this -- it 400s rather than synthesise a key from payload content -- and that refusal
|
|
19
|
+
* is right for a forge that HAS a header and wrong for one that has none. The distinction is the
|
|
20
|
+
* difference between an exception and an erosion, so it is written down rather than inferred. Note the
|
|
21
|
+
* ordering is unaffected: the id is read inside `onVerified`, after the credential check.
|
|
22
|
+
* - There is NO REPLAY WINDOW. GitLab pairs its dedup with a signed 5-minute timestamp precisely because
|
|
23
|
+
* dedup is retention-bounded. Azure signs no timestamp and no body, so a captured delivery replays as
|
|
24
|
+
* new paid work once the job key ages out, and there is no second line of defence. `OQ-015` records it.
|
|
25
|
+
*
|
|
26
|
+
* HTTPS is the operator's obligation and the docs say so: over plain HTTP the credential is on the wire in
|
|
27
|
+
* base64, which is not encryption.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { timingSafeEqual } from "node:crypto";
|
|
31
|
+
import { isJsonContentType, readRawBody, respond } from "./http-body.mjs";
|
|
32
|
+
|
|
33
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Constant-time string compare. Length is leaked (it always is, by the early return), but the CONTENT is
|
|
37
|
+
* not: a byte-by-byte `===` on a secret is a timing oracle, and this is the only thing standing between a
|
|
38
|
+
* stranger and a paid job on this forge.
|
|
39
|
+
*/
|
|
40
|
+
export function secretEquals(a, b) {
|
|
41
|
+
const left = Buffer.from(String(a ?? ""), "utf8");
|
|
42
|
+
const right = Buffer.from(String(b ?? ""), "utf8");
|
|
43
|
+
if (left.length !== right.length) return false;
|
|
44
|
+
return timingSafeEqual(left, right);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the request carries the configured credential.
|
|
49
|
+
*
|
|
50
|
+
* `mode` is chosen by the operator when they create the subscription, never negotiated from the request:
|
|
51
|
+
* - `basic` -- `Authorization: Basic base64(user:pass)`, compared against the whole configured string.
|
|
52
|
+
* - `header` -- a custom header name and value, for deployments fronted by something that consumes the
|
|
53
|
+
* Authorization header before pi-dispatch sees it.
|
|
54
|
+
*
|
|
55
|
+
* A delivery presenting the wrong mechanism's credential is refused even when that credential is correct:
|
|
56
|
+
* a sender able to choose which gate it faces chooses the weakest available one.
|
|
57
|
+
*/
|
|
58
|
+
export function credentialOk({ mode, secret, headerName }, headers) {
|
|
59
|
+
if (mode === "basic") {
|
|
60
|
+
const raw = headers?.authorization;
|
|
61
|
+
if (typeof raw !== "string" || !raw.startsWith("Basic ")) return false;
|
|
62
|
+
return secretEquals(raw.slice("Basic ".length).trim(), secret);
|
|
63
|
+
}
|
|
64
|
+
if (mode === "header") {
|
|
65
|
+
const raw = headers?.[String(headerName ?? "").toLowerCase()];
|
|
66
|
+
return typeof raw === "string" && secretEquals(raw, secret);
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Wrap `onVerified({ rawBody }, res)` so it only ever runs for an authenticated delivery.
|
|
73
|
+
*
|
|
74
|
+
* The preamble mirrors the other two verifiers exactly -- method, content type, declared length, bounded
|
|
75
|
+
* read, then the credential check -- because that ordering is the part every downstream gate depends on
|
|
76
|
+
* and it must not vary per forge.
|
|
77
|
+
*/
|
|
78
|
+
export function makeAzureVerifiedHandler({ mode, secret, headerName = null }, onVerified) {
|
|
79
|
+
return async function handler(req, res) {
|
|
80
|
+
try {
|
|
81
|
+
if (req.method !== "POST") return respond(res, 405, { error: "method-not-allowed" });
|
|
82
|
+
if (!isJsonContentType(req.headers["content-type"])) return respond(res, 415, { error: "unsupported-media-type" });
|
|
83
|
+
|
|
84
|
+
const declared = Number(req.headers["content-length"] ?? NaN);
|
|
85
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return respond(res, 413, { error: "payload-too-large" });
|
|
86
|
+
|
|
87
|
+
let buf;
|
|
88
|
+
try {
|
|
89
|
+
buf = await readRawBody(req, MAX_BODY_BYTES);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (err?.code === "E_PAYLOAD_TOO_LARGE") return respond(res, 413, { error: "payload-too-large" });
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
const rawBody = buf.toString("utf8");
|
|
95
|
+
|
|
96
|
+
// The credential is checked against the RAW bytes having already been read and NOT yet parsed --
|
|
97
|
+
// the same ordering the HMAC forges use. It buys less here (the credential covers no bytes), but
|
|
98
|
+
// the property it does buy is real: no field of an unauthenticated body is ever read.
|
|
99
|
+
if (!credentialOk({ mode, secret, headerName }, req.headers)) {
|
|
100
|
+
return respond(res, 401, { error: "unauthorized" });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return await onVerified({ rawBody }, res);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
process.stderr.write(`${JSON.stringify({ event: "azure_verify_handler_error", reason: err?.message })}\n`);
|
|
106
|
+
if (!res.headersSent) respond(res, 500, { error: "internal" });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitLab trust boundary (issue #42), the counterpart of verify.mjs. Nothing downstream -- not the
|
|
3
|
+
* access-level gate, not the label predicate, not the enqueue -- means anything unless this ran first and
|
|
4
|
+
* said yes: every one of them reads fields from a body somebody has to have authenticated
|
|
5
|
+
* (CONST-HMAC-OVER-RAW-BODY).
|
|
6
|
+
*
|
|
7
|
+
* GitLab offers TWO mechanisms, and which one a deployment gets depends on its version:
|
|
8
|
+
*
|
|
9
|
+
* - `signature` -- a real HMAC-SHA256, `webhook-signature: v1,<base64>`, over the Standard Webhooks
|
|
10
|
+
* message `{webhook-id}.{webhook-timestamp}.{raw body}`. Available from GitLab 19.0 (GA 19.1).
|
|
11
|
+
* - `token` -- `X-Gitlab-Token` compared against a shared secret. Available on every version, and
|
|
12
|
+
* STRICTLY WEAKER: it proves the sender knew a secret, and says nothing about whether the body
|
|
13
|
+
* arrived as it was sent. It is offered because most self-hosted instances cannot do better yet, not
|
|
14
|
+
* because it is adequate.
|
|
15
|
+
*
|
|
16
|
+
* The mode is DECLARED in config and verified exactly, never negotiated from what the request happens to
|
|
17
|
+
* carry. That is the whole reason it is a config field: a handler that accepted whichever header showed up
|
|
18
|
+
* would let the sender choose which gate it faced, and an attacker would choose the weaker one every time.
|
|
19
|
+
* A delivery arriving without the declared mode's header is refused, even when the other one is present
|
|
20
|
+
* and correct.
|
|
21
|
+
*
|
|
22
|
+
* Raw-body-then-verify-then-parse holds exactly as in verify.mjs: `readRawBody` returns bytes, the HMAC is
|
|
23
|
+
* computed over those bytes, and `onVerified` gets the string. Nothing calls JSON.parse before the 401.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHmac, timingSafeEqual, createHash } from "node:crypto";
|
|
27
|
+
import { isJsonContentType, readRawBody, respond } from "./http-body.mjs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Standard Webhooks' replay window. The dedup layer already refuses a repeated delivery id, but that
|
|
31
|
+
* guarantee is RETENTION-bounded (REQ-DEDUP-BY-DELIVERY-GUID states this explicitly): once the job key
|
|
32
|
+
* ages out, a captured delivery replays as new work and is billed. The timestamp is the unbounded half of
|
|
33
|
+
* that defence, so it is checked here rather than left to the queue.
|
|
34
|
+
*
|
|
35
|
+
* Five minutes is the spec's tolerance and is generous next to any plausible clock skew between a forge
|
|
36
|
+
* and its own webhook target.
|
|
37
|
+
*/
|
|
38
|
+
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/** The signing token's documented wrapper: `whsec_` then the base64 of the actual key. */
|
|
41
|
+
const SIGNING_TOKEN_PREFIX = "whsec_";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the verified GitLab handler. `mode` is `"signature"` or `"token"`; `secret` is the signing token
|
|
45
|
+
* (signature mode) or the shared secret token (token mode).
|
|
46
|
+
*
|
|
47
|
+
* `onVerified` receives `({ rawBody, headers, event, delivery }, res)` only after verification -- the same
|
|
48
|
+
* shape verify.mjs passes, so the receiver's two arms differ in how a delivery is trusted and not in what
|
|
49
|
+
* a trusted delivery looks like.
|
|
50
|
+
*/
|
|
51
|
+
export function makeGitLabVerifiedHandler({ mode, secret, bodyLimit = 2 * 1024 * 1024, now = () => Date.now() }, onVerified) {
|
|
52
|
+
if (mode !== "signature" && mode !== "token") {
|
|
53
|
+
throw new Error(`gitlab webhook mode must be "signature" or "token" (got ${JSON.stringify(mode)})`);
|
|
54
|
+
}
|
|
55
|
+
if (typeof secret !== "string" || secret === "") {
|
|
56
|
+
throw new Error("gitlab webhook verification requires a non-empty secret");
|
|
57
|
+
}
|
|
58
|
+
const key = mode === "signature" ? decodeSigningToken(secret) : null;
|
|
59
|
+
|
|
60
|
+
return async function gitlabVerifiedHandler(req, res) {
|
|
61
|
+
let delivery;
|
|
62
|
+
try {
|
|
63
|
+
if (req.method !== "POST") {
|
|
64
|
+
return respond(res, 405, { error: "method not allowed" });
|
|
65
|
+
}
|
|
66
|
+
if (!isJsonContentType(req.headers["content-type"])) {
|
|
67
|
+
return respond(res, 415, { error: "unsupported media type" });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const event = req.headers["x-gitlab-event"];
|
|
71
|
+
if (!event) {
|
|
72
|
+
return respond(res, 400, { error: "missing gitlab event header" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The delivery id is stable across GitLab's own retries, which is exactly the property dedup
|
|
76
|
+
// needs (REQ-DEDUP-BY-DELIVERY-GUID). `webhook-id` is the Standard Webhooks name (19.0+);
|
|
77
|
+
// `Idempotency-Key` is the same value under its original name (17.4+).
|
|
78
|
+
//
|
|
79
|
+
// An instance too old for either is REFUSED rather than served on a synthesised key. A key
|
|
80
|
+
// derived from the payload -- object id plus action, say -- is not stable across a retry that
|
|
81
|
+
// changes nothing an operator can see, so it would dedup some redeliveries and bill for the
|
|
82
|
+
// rest. Naming the minimum version is a message; a weaker key would be a silent second charge.
|
|
83
|
+
delivery = req.headers["webhook-id"] ?? req.headers["idempotency-key"];
|
|
84
|
+
if (!delivery) {
|
|
85
|
+
return respond(res, 400, {
|
|
86
|
+
error: "missing delivery id",
|
|
87
|
+
detail: "no webhook-id or Idempotency-Key header; pi-dispatch needs GitLab 17.4 or later to dedup redeliveries",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const declaredLength = Number(req.headers["content-length"]);
|
|
92
|
+
if (Number.isFinite(declaredLength) && declaredLength > bodyLimit) {
|
|
93
|
+
req.destroy();
|
|
94
|
+
return respond(res, 413, { error: "payload too large" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let buf;
|
|
98
|
+
try {
|
|
99
|
+
buf = await readRawBody(req, bodyLimit);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err && err.code === "E_PAYLOAD_TOO_LARGE") {
|
|
102
|
+
return respond(res, 413, { error: "payload too large" });
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
const raw = buf.toString("utf8");
|
|
107
|
+
|
|
108
|
+
const verdict =
|
|
109
|
+
mode === "signature"
|
|
110
|
+
? verifySignature({ key, headers: req.headers, raw, nowMs: now() })
|
|
111
|
+
: verifyToken(secret, req.headers["x-gitlab-token"]);
|
|
112
|
+
if (!verdict.ok) {
|
|
113
|
+
return respond(res, verdict.status ?? 401, { error: verdict.error });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await onVerified({ rawBody: raw, headers: req.headers, event, delivery }, res);
|
|
117
|
+
} catch {
|
|
118
|
+
// Stable id only: never the secret, the body, or a stack that may carry PII.
|
|
119
|
+
process.stderr.write(`${JSON.stringify({ event: "gitlab_verify_handler_error", delivery: delivery ?? null })}\n`);
|
|
120
|
+
if (!res.headersSent) {
|
|
121
|
+
respond(res, 500, { error: "internal error" });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `whsec_<base64>` -> the raw key bytes. Refused at CONSTRUCTION, not per request: a signing token this
|
|
129
|
+
* cannot decode can never verify anything, so the process must not come up believing it has a gate.
|
|
130
|
+
*/
|
|
131
|
+
export function decodeSigningToken(token) {
|
|
132
|
+
const body = token.startsWith(SIGNING_TOKEN_PREFIX) ? token.slice(SIGNING_TOKEN_PREFIX.length) : token;
|
|
133
|
+
const key = Buffer.from(body, "base64");
|
|
134
|
+
if (key.length === 0) {
|
|
135
|
+
throw new Error("gitlab signing token did not base64-decode to any key bytes");
|
|
136
|
+
}
|
|
137
|
+
return key;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Verify `webhook-signature` over the Standard Webhooks message. Returns `{ ok: true }` or
|
|
142
|
+
* `{ ok: false, error, status }`.
|
|
143
|
+
*
|
|
144
|
+
* The signed message is `{webhook-id}.{webhook-timestamp}.{body}` -- the id and timestamp are INSIDE the
|
|
145
|
+
* MAC, which is what makes the timestamp trustworthy enough to enforce a replay window against. Checking
|
|
146
|
+
* a timestamp that was not covered by the signature would be theatre.
|
|
147
|
+
*
|
|
148
|
+
* The header may carry several space-separated signatures (key rotation). Every `v1,` entry is tried, and
|
|
149
|
+
* each compare is timing-safe; an unknown version prefix is skipped rather than treated as a match.
|
|
150
|
+
*/
|
|
151
|
+
export function verifySignature({ key, headers, raw, nowMs }) {
|
|
152
|
+
const header = headers["webhook-signature"];
|
|
153
|
+
const id = headers["webhook-id"];
|
|
154
|
+
const timestamp = headers["webhook-timestamp"];
|
|
155
|
+
if (!header || !id || !timestamp) {
|
|
156
|
+
return { ok: false, error: "missing signature" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sentMs = Number(timestamp) * 1000;
|
|
160
|
+
if (!Number.isFinite(sentMs) || Math.abs(nowMs - sentMs) > TIMESTAMP_TOLERANCE_MS) {
|
|
161
|
+
// Outside the window the signature is still valid -- which is the point. A captured delivery stays
|
|
162
|
+
// cryptographically good forever, so age is the only thing that can refuse a replay once the
|
|
163
|
+
// queue's dedup key has aged out.
|
|
164
|
+
return { ok: false, error: "stale or invalid timestamp" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${raw}`, "utf8").digest();
|
|
168
|
+
for (const candidate of String(header).split(" ")) {
|
|
169
|
+
const [version, value] = candidate.split(",");
|
|
170
|
+
if (version !== "v1" || !value) continue;
|
|
171
|
+
const got = Buffer.from(value, "base64");
|
|
172
|
+
if (got.length === expected.length && timingSafeEqual(got, expected)) {
|
|
173
|
+
return { ok: true };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ok: false, error: "invalid signature" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Compare `X-Gitlab-Token` against the configured secret in constant time.
|
|
181
|
+
*
|
|
182
|
+
* Both sides are hashed first so the comparands are always 32 bytes: `timingSafeEqual` throws on a length
|
|
183
|
+
* mismatch, and the obvious guard -- return early when the lengths differ -- turns the secret's LENGTH
|
|
184
|
+
* into a timing oracle. Hashing removes length from the comparison entirely.
|
|
185
|
+
*/
|
|
186
|
+
export function verifyToken(secret, presented) {
|
|
187
|
+
if (typeof presented !== "string" || presented === "") {
|
|
188
|
+
return { ok: false, error: "missing token" };
|
|
189
|
+
}
|
|
190
|
+
const a = createHash("sha256").update(presented, "utf8").digest();
|
|
191
|
+
const b = createHash("sha256").update(secret, "utf8").digest();
|
|
192
|
+
return timingSafeEqual(a, b) ? { ok: true } : { ok: false, error: "invalid token" };
|
|
193
|
+
}
|
package/src/verify.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook signature verification -- the trust boundary every downstream gate depends on.
|
|
3
|
+
*
|
|
4
|
+
* CONST-HMAC-OVER-RAW-BODY: the raw request bytes are verified against `X-Hub-Signature-256`
|
|
5
|
+
* with `crypto.timingSafeEqual` BEFORE any parse. `@octokit/webhooks` owns that comparison
|
|
6
|
+
* internally, so this module delegates to `Webhooks#verify` and never hand-rolls HMAC nor
|
|
7
|
+
* compares signatures with `===`. A signature computed over different bytes than were sent
|
|
8
|
+
* fails verification, and such a request is rejected 401 before a single field is read.
|
|
9
|
+
*
|
|
10
|
+
* node:http, not express: `express.json()` consumes the stream and re-serializes the body, so
|
|
11
|
+
* the bytes handed to an HMAC check are no longer the bytes GitHub signed -- verification then
|
|
12
|
+
* either always fails or gets quietly stripped to make things work. A hand-rolled node:http
|
|
13
|
+
* handler reads the raw stream once and verifies those exact bytes. This also owns its own
|
|
14
|
+
* status codes, which `createNodeMiddleware` cannot: its 400 is hardcoded and its options
|
|
15
|
+
* expose only `{ timeout, path, log }`, so a 400->401 remap is impossible through it.
|
|
16
|
+
*
|
|
17
|
+
* Status map: 405 non-POST · 415 non-JSON content type · 401 missing or invalid signature ·
|
|
18
|
+
* 400 missing GitHub event/delivery headers · 413 body over limit · 500 unexpected throw.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Webhooks } from "@octokit/webhooks";
|
|
22
|
+
import { isJsonContentType, readRawBody, respond } from "./http-body.mjs";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Thin wrapper delegating to `webhooks.verify(rawBody, signature)`. `rawBody` is the raw UTF-8
|
|
26
|
+
* request body as a string and `signature` is the `X-Hub-Signature-256` header value. Returns a
|
|
27
|
+
* Promise<boolean>; the timing-safe HMAC comparison lives inside `@octokit/webhooks`.
|
|
28
|
+
*/
|
|
29
|
+
export async function verifySignature(webhooks, rawBody, signature) {
|
|
30
|
+
return webhooks.verify(rawBody, signature);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a `node:http` request handler that verifies GitHub's HMAC over the raw body and hands a
|
|
35
|
+
* verified request off to `onVerified`. The `Webhooks` instance is constructed once, at factory
|
|
36
|
+
* time, so the secret is captured in the closure and never re-read per request.
|
|
37
|
+
*
|
|
38
|
+
* `onVerified` receives `({ rawBody, headers, event, delivery }, res)` only after a good signature.
|
|
39
|
+
* It owns the parse, the trigger filter, the enqueue, and the final response (D4's contract); if it
|
|
40
|
+
* writes no response, that is D4's concern -- this handler's job ends at a successful verify.
|
|
41
|
+
*/
|
|
42
|
+
export function makeVerifiedHandler({ secret, bodyLimit = 2 * 1024 * 1024 }, onVerified) {
|
|
43
|
+
const webhooks = new Webhooks({ secret });
|
|
44
|
+
|
|
45
|
+
return async function verifiedHandler(req, res) {
|
|
46
|
+
let delivery;
|
|
47
|
+
try {
|
|
48
|
+
if (req.method !== "POST") {
|
|
49
|
+
return respond(res, 405, { error: "method not allowed" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!isJsonContentType(req.headers["content-type"])) {
|
|
53
|
+
return respond(res, 415, { error: "unsupported media type" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const signature = req.headers["x-hub-signature-256"];
|
|
57
|
+
if (!signature) {
|
|
58
|
+
return respond(res, 401, { error: "missing signature" });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const event = req.headers["x-github-event"];
|
|
62
|
+
delivery = req.headers["x-github-delivery"];
|
|
63
|
+
if (!event || !delivery) {
|
|
64
|
+
return respond(res, 400, { error: "missing github event headers" });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const declaredLength = Number(req.headers["content-length"]);
|
|
68
|
+
if (Number.isFinite(declaredLength) && declaredLength > bodyLimit) {
|
|
69
|
+
req.destroy();
|
|
70
|
+
return respond(res, 413, { error: "payload too large" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let buf;
|
|
74
|
+
try {
|
|
75
|
+
buf = await readRawBody(req, bodyLimit);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err && err.code === "E_PAYLOAD_TOO_LARGE") {
|
|
78
|
+
return respond(res, 413, { error: "payload too large" });
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const raw = buf.toString("utf8");
|
|
84
|
+
const ok = await verifySignature(webhooks, raw, signature);
|
|
85
|
+
if (!ok) {
|
|
86
|
+
return respond(res, 401, { error: "invalid signature" });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await onVerified({ rawBody: raw, headers: req.headers, event, delivery }, res);
|
|
90
|
+
} catch {
|
|
91
|
+
// Stable id only: never the secret, the body, or a stack that may carry PII.
|
|
92
|
+
process.stderr.write(`${JSON.stringify({ event: "verify_handler_error", delivery: delivery ?? null })}\n`);
|
|
93
|
+
if (!res.headersSent) {
|
|
94
|
+
respond(res, 500, { error: "internal error" });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|