Skip to content

Commit 97d9047

Browse files
committed
test(security): add webhook auth and token management tests
Add comprehensive test suites for two security-critical modules: - utils/auth-github-webhook: 8 tests covering valid HMAC verification, invalid/missing/empty signatures, body tampering, and wrong secrets. - routes/github/lib/utils/tokens: 9 tests covering token cycling, rate limit tracking, and token masking in getLimits() output. Fix two security issues discovered during testing: 1. Webhook signature comparison used === (timing-attack vulnerable). Now uses crypto.timingSafeEqual() with explicit length check and early return for missing headers. 2. Token masking in getLimits() replaced only the last 30 chars with 2 asterisks, leaking up to 10 prefix characters of a 40-char GitHub token. Now masks all but the last 4 characters with per-character asterisks, preserving output length.
1 parent 39ce491 commit 97d9047

4 files changed

Lines changed: 378 additions & 4 deletions

File tree

routes/github/lib/utils/tokens.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export function updateRateLimit(token: string, rateLimit: RateLimit) {
2626
}
2727

2828
export function getLimits() {
29-
const secureToken = (token: string) => token.replace(/.{30}$/, "*".repeat(2));
29+
const secureToken = (token: string) =>
30+
"*".repeat(Math.max(0, token.length - 4)) + token.slice(-4);
3031
const result: Record<string, RateLimit | null> = {};
3132
for (const [token, limits] of LIMITS) {
3233
result[secureToken(token)] = limits;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// GH_TOKEN must be set before importing the module, since tokens.ts
2+
// calls env("GH_TOKEN") at the top level during module initialization.
3+
process.env.GH_TOKEN = "ghp_TestToken1234567890abcdefghijklmnopqrst";
4+
5+
const {
6+
getToken,
7+
updateRateLimit,
8+
getLimits,
9+
} = await import(
10+
"../../../../../build/routes/github/lib/utils/tokens.js"
11+
);
12+
13+
describe("routes/github/lib/utils/tokens", () => {
14+
describe("getToken()", () => {
15+
it("returns a token string", () => {
16+
const token = getToken();
17+
expect(typeof token).toBe("string");
18+
expect(token.length).toBeGreaterThan(0);
19+
});
20+
21+
it("returns the configured GH_TOKEN", () => {
22+
const token = getToken();
23+
expect(token).toBe(process.env.GH_TOKEN);
24+
});
25+
26+
it("cycles back to the same token (single-token rotation)", () => {
27+
// With a single token, every call returns the same value
28+
const first = getToken();
29+
const second = getToken();
30+
const third = getToken();
31+
expect(first).toBe(second);
32+
expect(second).toBe(third);
33+
});
34+
});
35+
36+
describe("updateRateLimit()", () => {
37+
it("stores rate limit data for a token", () => {
38+
const token = getToken();
39+
const rateLimit = {
40+
remaining: 4500,
41+
resetAt: new Date("2026-01-01T00:00:00Z"),
42+
limit: 5000,
43+
};
44+
updateRateLimit(token, rateLimit);
45+
46+
const limits = getLimits();
47+
const entries = Object.values(limits);
48+
// The stored rate limit should match what we set
49+
const stored = entries.find(v => v !== null);
50+
expect(stored).toBeDefined();
51+
expect(stored.remaining).toBe(4500);
52+
expect(stored.limit).toBe(5000);
53+
});
54+
55+
it("updates existing rate limit data", () => {
56+
const token = getToken();
57+
const first = {
58+
remaining: 4500,
59+
resetAt: new Date("2026-01-01T00:00:00Z"),
60+
limit: 5000,
61+
};
62+
updateRateLimit(token, first);
63+
64+
const second = {
65+
remaining: 100,
66+
resetAt: new Date("2026-01-01T01:00:00Z"),
67+
limit: 5000,
68+
};
69+
updateRateLimit(token, second);
70+
71+
const limits = getLimits();
72+
const entries = Object.values(limits);
73+
const stored = entries.find(v => v !== null);
74+
expect(stored.remaining).toBe(100);
75+
});
76+
});
77+
78+
describe("getLimits()", () => {
79+
it("returns an object keyed by masked tokens", () => {
80+
const limits = getLimits();
81+
expect(typeof limits).toBe("object");
82+
expect(Object.keys(limits).length).toBeGreaterThan(0);
83+
});
84+
85+
it("never exposes the full token in keys", () => {
86+
const fullToken = process.env.GH_TOKEN;
87+
const limits = getLimits();
88+
for (const key of Object.keys(limits)) {
89+
expect(key).not.toBe(fullToken);
90+
}
91+
});
92+
93+
it("masks all but the last 4 characters of the token", () => {
94+
const fullToken = process.env.GH_TOKEN;
95+
const limits = getLimits();
96+
const keys = Object.keys(limits);
97+
expect(keys).toHaveSize(1);
98+
99+
const maskedKey = keys[0];
100+
// Should show only the last 4 characters, rest are asterisks
101+
const expectedSuffix = fullToken.slice(-4);
102+
const expectedStars = "*".repeat(fullToken.length - 4);
103+
expect(maskedKey).toBe(expectedStars + expectedSuffix);
104+
});
105+
106+
it("masked key has the same length as the full token", () => {
107+
const fullToken = process.env.GH_TOKEN;
108+
const limits = getLimits();
109+
const maskedKey = Object.keys(limits)[0];
110+
// Length is preserved (stars replace chars 1:1)
111+
expect(maskedKey.length).toBe(fullToken.length);
112+
});
113+
114+
it("does not leak the token prefix (ghp_ or similar)", () => {
115+
const fullToken = process.env.GH_TOKEN;
116+
const limits = getLimits();
117+
const maskedKey = Object.keys(limits)[0];
118+
// The ghp_ prefix must NOT appear in the masked key
119+
const prefix = fullToken.slice(0, 4);
120+
expect(maskedKey.startsWith(prefix)).toBeFalse();
121+
expect(maskedKey.startsWith("*")).toBeTrue();
122+
});
123+
124+
it("exposes at most 4 characters of the actual token", () => {
125+
const fullToken = process.env.GH_TOKEN;
126+
const limits = getLimits();
127+
const maskedKey = Object.keys(limits)[0];
128+
// Count non-asterisk characters
129+
const visibleChars = [...maskedKey].filter(c => c !== "*").length;
130+
expect(visibleChars).toBeLessThanOrEqual(4);
131+
});
132+
});
133+
});
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { createHmac } from "crypto";
2+
import githubWebhookAuthenticator from "../../build/utils/auth-github-webhook.js";
3+
4+
/**
5+
* Compute the SHA-1 HMAC signature GitHub sends in X-Hub-Signature.
6+
* @param {Buffer} body Raw request body
7+
* @param {string} secret Shared webhook secret
8+
* @returns {string} e.g. "sha1=abc123..."
9+
*/
10+
function sign(body, secret) {
11+
const hash = createHmac("sha1", secret).update(body).digest("hex");
12+
return `sha1=${hash}`;
13+
}
14+
15+
/**
16+
* Create a minimal Express-like request object.
17+
* @param {object} options
18+
* @param {Buffer} options.body Raw body buffer
19+
* @param {Record<string, string>} [options.headers] Request headers
20+
* @returns {object} Mock request
21+
*/
22+
function mockReq({ body, headers = {} }) {
23+
const lowerHeaders = {};
24+
for (const [k, v] of Object.entries(headers)) {
25+
lowerHeaders[k.toLowerCase()] = v;
26+
}
27+
return {
28+
body,
29+
headers: lowerHeaders,
30+
get(name) {
31+
return this.headers[name.toLowerCase()];
32+
},
33+
};
34+
}
35+
36+
/**
37+
* Create a minimal Express-like response object.
38+
* @returns {{ statusCode: number|null, body: string|null, status: Function, send: Function }}
39+
*/
40+
function mockRes() {
41+
const res = {
42+
statusCode: null,
43+
body: null,
44+
status(code) {
45+
res.statusCode = code;
46+
return res;
47+
},
48+
send(data) {
49+
res.body = data;
50+
return res;
51+
},
52+
};
53+
return res;
54+
}
55+
56+
describe("utils/auth-github-webhook", () => {
57+
const SECRET = "test-webhook-secret-1234";
58+
59+
it("returns an array of [rawBodyParser, verifier]", () => {
60+
const middleware = githubWebhookAuthenticator(SECRET);
61+
expect(Array.isArray(middleware)).toBeTrue();
62+
expect(middleware).toHaveSize(2);
63+
// First element is express.raw() middleware, second is the verifier fn
64+
expect(typeof middleware[0]).toBe("function");
65+
expect(typeof middleware[1]).toBe("function");
66+
});
67+
68+
describe("signature verification (verifier middleware)", () => {
69+
// We test the second element of the array (the verifier function)
70+
// directly, since express.raw() is Express's own middleware.
71+
let verifier;
72+
73+
beforeEach(() => {
74+
const middleware = githubWebhookAuthenticator(SECRET);
75+
verifier = middleware[1];
76+
});
77+
78+
it("calls next() when the HMAC signature is valid", () => {
79+
const payload = { action: "opened", number: 42 };
80+
const rawBody = Buffer.from(JSON.stringify(payload));
81+
const signature = sign(rawBody, SECRET);
82+
83+
const req = mockReq({
84+
body: rawBody,
85+
headers: { "X-Hub-Signature": signature },
86+
});
87+
const res = mockRes();
88+
let nextCalled = false;
89+
const next = () => {
90+
nextCalled = true;
91+
};
92+
93+
verifier(req, res, next);
94+
95+
expect(nextCalled).toBeTrue();
96+
// Body should be parsed as JSON after verification
97+
expect(req.body).toEqual(payload);
98+
});
99+
100+
it("parses the raw body as JSON after successful verification", () => {
101+
const payload = { ref: "refs/heads/main", commits: [{ id: "abc" }] };
102+
const rawBody = Buffer.from(JSON.stringify(payload));
103+
const signature = sign(rawBody, SECRET);
104+
105+
const req = mockReq({
106+
body: rawBody,
107+
headers: { "X-Hub-Signature": signature },
108+
});
109+
const res = mockRes();
110+
verifier(req, res, () => {});
111+
112+
expect(req.body).toEqual(payload);
113+
expect(typeof req.body).toBe("object");
114+
});
115+
116+
it("responds with 'pong' for GitHub ping events (zen field present)", () => {
117+
const payload = { zen: "Keep it logically awesome.", hook_id: 1 };
118+
const rawBody = Buffer.from(JSON.stringify(payload));
119+
const signature = sign(rawBody, SECRET);
120+
121+
const req = mockReq({
122+
body: rawBody,
123+
headers: { "X-Hub-Signature": signature },
124+
});
125+
const res = mockRes();
126+
let nextCalled = false;
127+
128+
verifier(req, res, () => {
129+
nextCalled = true;
130+
});
131+
132+
expect(nextCalled).toBeFalse();
133+
expect(res.body).toBe("pong");
134+
});
135+
136+
it("returns 401 when the signature is invalid", () => {
137+
const payload = { action: "opened" };
138+
const rawBody = Buffer.from(JSON.stringify(payload));
139+
const badSignature = "sha1=0000000000000000000000000000000000000000";
140+
141+
const req = mockReq({
142+
body: rawBody,
143+
headers: { "X-Hub-Signature": badSignature },
144+
});
145+
const res = mockRes();
146+
let nextCalled = false;
147+
148+
verifier(req, res, () => {
149+
nextCalled = true;
150+
});
151+
152+
expect(nextCalled).toBeFalse();
153+
expect(res.statusCode).toBe(401);
154+
expect(res.body).toContain("Failed to authenticate");
155+
});
156+
157+
it("returns 401 when X-Hub-Signature header is missing", () => {
158+
const payload = { action: "opened" };
159+
const rawBody = Buffer.from(JSON.stringify(payload));
160+
161+
const req = mockReq({ body: rawBody, headers: {} });
162+
const res = mockRes();
163+
let nextCalled = false;
164+
165+
verifier(req, res, () => {
166+
nextCalled = true;
167+
});
168+
169+
expect(nextCalled).toBeFalse();
170+
expect(res.statusCode).toBe(401);
171+
});
172+
173+
it("returns 401 when the body has been tampered with", () => {
174+
const original = { action: "opened", number: 42 };
175+
const rawOriginal = Buffer.from(JSON.stringify(original));
176+
const signature = sign(rawOriginal, SECRET);
177+
178+
// Tamper with the body after signing
179+
const tampered = { action: "opened", number: 99 };
180+
const rawTampered = Buffer.from(JSON.stringify(tampered));
181+
182+
const req = mockReq({
183+
body: rawTampered,
184+
headers: { "X-Hub-Signature": signature },
185+
});
186+
const res = mockRes();
187+
let nextCalled = false;
188+
189+
verifier(req, res, () => {
190+
nextCalled = true;
191+
});
192+
193+
expect(nextCalled).toBeFalse();
194+
expect(res.statusCode).toBe(401);
195+
});
196+
197+
it("returns 401 when the secret is wrong", () => {
198+
const payload = { action: "opened" };
199+
const rawBody = Buffer.from(JSON.stringify(payload));
200+
// Sign with a different secret
201+
const signature = sign(rawBody, "wrong-secret");
202+
203+
const req = mockReq({
204+
body: rawBody,
205+
headers: { "X-Hub-Signature": signature },
206+
});
207+
const res = mockRes();
208+
let nextCalled = false;
209+
210+
verifier(req, res, () => {
211+
nextCalled = true;
212+
});
213+
214+
expect(nextCalled).toBeFalse();
215+
expect(res.statusCode).toBe(401);
216+
});
217+
218+
it("rejects an empty signature header value", () => {
219+
const payload = { action: "opened" };
220+
const rawBody = Buffer.from(JSON.stringify(payload));
221+
222+
const req = mockReq({
223+
body: rawBody,
224+
headers: { "X-Hub-Signature": "" },
225+
});
226+
const res = mockRes();
227+
let nextCalled = false;
228+
229+
verifier(req, res, () => {
230+
nextCalled = true;
231+
});
232+
233+
expect(nextCalled).toBeFalse();
234+
expect(res.statusCode).toBe(401);
235+
});
236+
});
237+
});

utils/auth-github-webhook.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createHmac } from "crypto";
1+
import { createHmac, timingSafeEqual } from "crypto";
22

33
import express from "express";
44
import { NextFunction, Request, Response } from "express";
@@ -27,6 +27,9 @@ export default function githubWebhookAuthenticator(secret: string) {
2727
* See: https://developer.github.com/webhooks/securing/
2828
*/
2929
function isValidGithubSignature(req: RawRequest, secret: string) {
30-
const hash = createHmac("sha1", secret).update(req.body).digest("hex");
31-
return req.get("X-Hub-Signature") === `sha1=${hash}`;
30+
const signature = req.get("X-Hub-Signature");
31+
if (!signature) return false;
32+
const expected = `sha1=${createHmac("sha1", secret).update(req.body).digest("hex")}`;
33+
if (signature.length !== expected.length) return false;
34+
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
3235
}

0 commit comments

Comments
 (0)