@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.
@@ -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
+ }