Skip to content

Commit c3aec42

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 c3aec42

4 files changed

Lines changed: 379 additions & 4 deletions

File tree

routes/github/lib/utils/tokens.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ 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+
token.length <= 4
31+
? "*".repeat(token.length)
32+
: "*".repeat(token.length - 4) + token.slice(-4);
3033
const result: Record<string, RateLimit | null> = {};
3134
for (const [token, limits] of LIMITS) {
3235
result[secureToken(token)] = limits;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// GH_TOKEN is set by tests/helpers/env.js before any module loads.
2+
// The tokens module reads it at import time, so we use whatever value
3+
// the helper provided.
4+
const {
5+
getToken,
6+
updateRateLimit,
7+
getLimits,
8+
} = await import(
9+
"../../../../../build/routes/github/lib/utils/tokens.js"
10+
);
11+
12+
describe("routes/github/lib/utils/tokens", () => {
13+
describe("getToken()", () => {
14+
it("returns a token string", () => {
15+
const token = getToken();
16+
expect(typeof token).toBe("string");
17+
expect(token.length).toBeGreaterThan(0);
18+
});
19+
20+
it("returns the configured GH_TOKEN", () => {
21+
const token = getToken();
22+
expect(token).toBe(process.env.GH_TOKEN);
23+
});
24+
25+
it("cycles back to the same token (single-token rotation)", () => {
26+
// With a single token, every call returns the same value
27+
const first = getToken();
28+
const second = getToken();
29+
const third = getToken();
30+
expect(first).toBe(second);
31+
expect(second).toBe(third);
32+
});
33+
});
34+
35+
describe("updateRateLimit()", () => {
36+
it("stores rate limit data for a token", () => {
37+
const token = getToken();
38+
const rateLimit = {
39+
remaining: 4500,
40+
resetAt: new Date("2026-01-01T00:00:00Z"),
41+
limit: 5000,
42+
};
43+
updateRateLimit(token, rateLimit);
44+
45+
const limits = getLimits();
46+
const entries = Object.values(limits);
47+
// The stored rate limit should match what we set
48+
const stored = entries.find(v => v !== null);
49+
expect(stored).toBeDefined();
50+
expect(stored?.remaining).toBe(4500);
51+
expect(stored?.limit).toBe(5000);
52+
});
53+
54+
it("updates existing rate limit data", () => {
55+
const token = getToken();
56+
const first = {
57+
remaining: 4500,
58+
resetAt: new Date("2026-01-01T00:00:00Z"),
59+
limit: 5000,
60+
};
61+
updateRateLimit(token, first);
62+
63+
const second = {
64+
remaining: 100,
65+
resetAt: new Date("2026-01-01T01:00:00Z"),
66+
limit: 5000,
67+
};
68+
updateRateLimit(token, second);
69+
70+
const limits = getLimits();
71+
const entries = Object.values(limits);
72+
const stored = entries.find(v => v !== null);
73+
expect(stored.remaining).toBe(100);
74+
});
75+
});
76+
77+
describe("getLimits()", () => {
78+
it("returns an object keyed by masked tokens", () => {
79+
const limits = getLimits();
80+
expect(typeof limits).toBe("object");
81+
expect(Object.keys(limits).length).toBeGreaterThan(0);
82+
});
83+
84+
it("never exposes the full token in keys", () => {
85+
const fullToken = process.env.GH_TOKEN;
86+
const limits = getLimits();
87+
for (const key of Object.keys(limits)) {
88+
expect(key).not.toBe(fullToken);
89+
}
90+
});
91+
92+
it("masks all but the last 4 characters of the token", () => {
93+
const fullToken = process.env.GH_TOKEN;
94+
const limits = getLimits();
95+
const keys = Object.keys(limits);
96+
expect(keys).toHaveSize(1);
97+
98+
const maskedKey = keys[0];
99+
// Should show only the last 4 characters, rest are asterisks
100+
const expectedSuffix = fullToken.slice(-4);
101+
const expectedStars = "*".repeat(fullToken.length - 4);
102+
expect(maskedKey).toBe(expectedStars + expectedSuffix);
103+
});
104+
105+
it("masked key has the same length as the full token", () => {
106+
const fullToken = process.env.GH_TOKEN;
107+
const limits = getLimits();
108+
const maskedKey = Object.keys(limits)[0];
109+
// Length is preserved (stars replace chars 1:1)
110+
expect(maskedKey.length).toBe(fullToken.length);
111+
});
112+
113+
it("does not leak the token prefix (ghp_ or similar)", () => {
114+
const fullToken = process.env.GH_TOKEN;
115+
const limits = getLimits();
116+
const maskedKey = Object.keys(limits)[0];
117+
// The ghp_ prefix must NOT appear in the masked key
118+
const prefix = fullToken.slice(0, 4);
119+
expect(maskedKey.startsWith(prefix)).toBeFalse();
120+
expect(maskedKey.startsWith("*")).toBeTrue();
121+
});
122+
123+
it("exposes at most 4 characters of the actual token", () => {
124+
const fullToken = process.env.GH_TOKEN;
125+
const limits = getLimits();
126+
const maskedKey = Object.keys(limits)[0];
127+
// Count non-asterisk characters
128+
const visibleChars = [...maskedKey].filter(c => c !== "*").length;
129+
expect(visibleChars).toBeLessThanOrEqual(4);
130+
});
131+
});
132+
});
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+
});

0 commit comments

Comments
 (0)