From e5a7014f1e5d323ce3b6834ed8cabd857a9eef94 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 11:00:35 +1000 Subject: [PATCH 01/29] fix: replace Helmet frameguard:false with CSP frame-ancestors for iframe embedding --- app.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app.ts b/app.ts index ba36c024..f09bbfaf 100644 --- a/app.ts +++ b/app.ts @@ -34,8 +34,14 @@ registerViewEngine(app); // Defaults https://www.npmjs.com/package/helmet#how-it-works app.use( helmet({ - // Allow for UI inclusion as iframe in ReSpec pill. - frameguard: false, + contentSecurityPolicy: { + directives: { + ...helmet.contentSecurityPolicy.getDefaultDirectives(), + // Allow embedding in iframes from any origin (ReSpec pill UI). + // CSP frame-ancestors supersedes X-Frame-Options in modern browsers. + "frame-ancestors": ["*"], + }, + }, }), ); From 18fca0985faa270207cb19cd00ef72880c61ccb7 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 11:02:55 +1000 Subject: [PATCH 02/29] fix: add sliding-window rate limiting to /respec/size routes --- routes/respec/index.ts | 6 +++++- utils/rate-limit.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 utils/rate-limit.ts diff --git a/routes/respec/index.ts b/routes/respec/index.ts index 96488628..903feeab 100644 --- a/routes/respec/index.ts +++ b/routes/respec/index.ts @@ -2,6 +2,7 @@ import path from "node:path"; import express from "express"; import { env, ms } from "../../utils/misc.js"; +import { rateLimit } from "../../utils/rate-limit.js"; import authGithubWebhook from "../../utils/auth-github-webhook.js"; import * as sizeRoute from "./size.js"; @@ -9,9 +10,12 @@ import buildUpdateRoute, { PKG_DIR } from "./builds/update.js"; const router = express.Router({ mergeParams: true }); -router.get("/size", sizeRoute.get); +const sizeRateLimit = rateLimit({ windowMs: ms("1m"), max: 60 }); + +router.get("/size", sizeRateLimit, sizeRoute.get); router.put( "/size", + sizeRateLimit, express.urlencoded({ extended: false, parameterLimit: 4, limit: "128b" }), sizeRoute.put, ); diff --git a/utils/rate-limit.ts b/utils/rate-limit.ts new file mode 100644 index 00000000..4f90f876 --- /dev/null +++ b/utils/rate-limit.ts @@ -0,0 +1,42 @@ +import { NextFunction, Request, Response } from "express"; + +interface RateLimitOptions { + windowMs: number; + max: number; +} + +/** + * Sliding-window rate limiter middleware factory. + * Tracks request timestamps per IP address and rejects requests that + * exceed `max` within the rolling `windowMs` period with HTTP 429. + */ +export function rateLimit({ windowMs, max }: RateLimitOptions) { + const log = new Map(); + + // Periodically evict fully-expired entries to bound memory growth. + const cleanup = setInterval(() => { + const cutoff = Date.now() - windowMs; + for (const [key, timestamps] of log) { + if (timestamps[timestamps.length - 1] < cutoff) { + log.delete(key); + } + } + }, windowMs); + cleanup.unref(); + + return (req: Request, res: Response, next: NextFunction) => { + const key = req.ip ?? "unknown"; + const now = Date.now(); + const cutoff = now - windowMs; + + const timestamps = (log.get(key) ?? []).filter(t => t > cutoff); + if (timestamps.length >= max) { + res.set("Retry-After", String(Math.ceil(windowMs / 1000))); + res.set("Content-Type", "text/plain"); + return res.status(429).send("Too Many Requests"); + } + timestamps.push(now); + log.set(key, timestamps); + next(); + }; +} From 3e5cfe8ab3717c8863d5f93a51b4e18ba3bc29ad Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 11:03:36 +1000 Subject: [PATCH 03/29] chore: suppress intentional code-injection false positive in xref filter worker --- static/xref/filter/worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/xref/filter/worker.js b/static/xref/filter/worker.js index c2061f65..f8a88b4b 100644 --- a/static/xref/filter/worker.js +++ b/static/xref/filter/worker.js @@ -39,7 +39,7 @@ function getQueryFn(rawQuery) { return (${query || true}); `; console.debug(body); - return new Function('entry', body); + return new Function('entry', body); // CodeQL[js/code-injection]: intentional developer filter tool, runs in browser only } /** From ebc3bcb34f5ead3bd1050635bffa63a61cb0675f Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 11:07:26 +1000 Subject: [PATCH 04/29] test: add tests for rate-limit utility and SSRF URL validation --- tests/helpers/env.js | 3 + tests/jasmine.json | 1 + tests/routes/github/lib/utils/rest.test.js | 31 +++++++ tests/utils/rate-limit.test.js | 100 +++++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 tests/helpers/env.js create mode 100644 tests/routes/github/lib/utils/rest.test.js create mode 100644 tests/utils/rate-limit.test.js diff --git a/tests/helpers/env.js b/tests/helpers/env.js new file mode 100644 index 00000000..5062bd30 --- /dev/null +++ b/tests/helpers/env.js @@ -0,0 +1,3 @@ +// Set required env vars so modules that read them at load time don't throw. +process.env.GH_TOKEN = "test-token"; +process.env.DATA_DIR = "/tmp"; diff --git a/tests/jasmine.json b/tests/jasmine.json index 236f3b57..20146f08 100644 --- a/tests/jasmine.json +++ b/tests/jasmine.json @@ -1,6 +1,7 @@ { "spec_dir": "tests", "spec_files": ["**/*.test.js"], + "helpers": ["helpers/env.js"], "jsLoader": "import", "stopSpecOnExpectationFailure": false, "random": true diff --git a/tests/routes/github/lib/utils/rest.test.js b/tests/routes/github/lib/utils/rest.test.js new file mode 100644 index 00000000..a87c8bae --- /dev/null +++ b/tests/routes/github/lib/utils/rest.test.js @@ -0,0 +1,31 @@ +import { requestData } from "../../../../../build/routes/github/lib/utils/rest.js"; + +describe("routes/github/lib/utils/rest - requestData", () => { + it("throws for a non-GitHub API endpoint", async () => { + const gen = requestData("https://evil.example.com/steal"); + await expectAsync(gen.next()).toBeRejectedWithError( + /endpoint must start with https:\/\/api\.github\.com\//, + ); + }); + + it("throws for an http (non-https) GitHub URL", async () => { + const gen = requestData("http://api.github.com/repos/foo/bar"); + await expectAsync(gen.next()).toBeRejectedWithError( + /endpoint must start with https:\/\/api\.github\.com\//, + ); + }); + + it("throws for a GitHub URL that isn't the API subdomain", async () => { + const gen = requestData("https://github.com/speced/respec"); + await expectAsync(gen.next()).toBeRejectedWithError( + /endpoint must start with https:\/\/api\.github\.com\//, + ); + }); + + it("throws for an empty string endpoint", async () => { + const gen = requestData(""); + await expectAsync(gen.next()).toBeRejectedWithError( + /endpoint must start with https:\/\/api\.github\.com\//, + ); + }); +}); diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js new file mode 100644 index 00000000..7ef60eed --- /dev/null +++ b/tests/utils/rate-limit.test.js @@ -0,0 +1,100 @@ +import { rateLimit } from "../../build/utils/rate-limit.js"; + +describe("utils/rate-limit", () => { + function makeReq(ip = "1.2.3.4") { + return { ip }; + } + + function makeRes() { + const headers = {}; + const res = { + _status: null, + _body: null, + headers, + set(key, value) { + headers[key] = value; + return res; + }, + status(code) { + res._status = code; + return res; + }, + send(body) { + res._body = body; + return res; + }, + }; + return res; + } + + it("allows requests under the limit", () => { + const middleware = rateLimit({ windowMs: 60_000, max: 3 }); + const req = makeReq(); + let nextCalled = 0; + + for (let i = 0; i < 3; i++) { + const res = makeRes(); + middleware(req, res, () => nextCalled++); + expect(res._status).toBeNull(); + } + expect(nextCalled).toBe(3); + }); + + it("returns 429 when limit is exceeded", () => { + const middleware = rateLimit({ windowMs: 60_000, max: 2 }); + const req = makeReq(); + let nextCalled = 0; + + middleware(req, makeRes(), () => nextCalled++); + middleware(req, makeRes(), () => nextCalled++); + + const blockedRes = makeRes(); + middleware(req, blockedRes, () => nextCalled++); + + expect(blockedRes._status).toBe(429); + expect(blockedRes._body).toBe("Too Many Requests"); + expect(blockedRes.headers["Retry-After"]).toBeDefined(); + expect(nextCalled).toBe(2); + }); + + it("tracks IPs independently", () => { + const middleware = rateLimit({ windowMs: 60_000, max: 1 }); + let nextCalled = 0; + + middleware(makeReq("1.1.1.1"), makeRes(), () => nextCalled++); + middleware(makeReq("2.2.2.2"), makeRes(), () => nextCalled++); + + expect(nextCalled).toBe(2); + }); + + it("uses 'unknown' as key when req.ip is undefined", () => { + const middleware = rateLimit({ windowMs: 60_000, max: 1 }); + const req = { ip: undefined }; + let nextCalled = 0; + + middleware(req, makeRes(), () => nextCalled++); + expect(nextCalled).toBe(1); + + const blockedRes = makeRes(); + middleware(req, blockedRes, () => {}); + expect(blockedRes._status).toBe(429); + }); + + it("allows requests again after the window expires", async () => { + const middleware = rateLimit({ windowMs: 50, max: 1 }); + const req = makeReq(); + let nextCalled = 0; + + middleware(req, makeRes(), () => nextCalled++); + + const blockedRes = makeRes(); + middleware(req, blockedRes, () => nextCalled++); + expect(blockedRes._status).toBe(429); + expect(nextCalled).toBe(1); + + await new Promise(resolve => setTimeout(resolve, 60)); + + middleware(req, makeRes(), () => nextCalled++); + expect(nextCalled).toBe(2); + }); +}); From 2aec526f926d4849cb5d8a4d61f24254b3b69a6b Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 11:10:31 +1000 Subject: [PATCH 05/29] chore: fix CodeQL suppression syntax; remove redundant comment in app.ts --- app.ts | 1 - static/xref/filter/worker.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app.ts b/app.ts index f09bbfaf..0c7b716a 100644 --- a/app.ts +++ b/app.ts @@ -38,7 +38,6 @@ app.use( directives: { ...helmet.contentSecurityPolicy.getDefaultDirectives(), // Allow embedding in iframes from any origin (ReSpec pill UI). - // CSP frame-ancestors supersedes X-Frame-Options in modern browsers. "frame-ancestors": ["*"], }, }, diff --git a/static/xref/filter/worker.js b/static/xref/filter/worker.js index f8a88b4b..be5ac7e2 100644 --- a/static/xref/filter/worker.js +++ b/static/xref/filter/worker.js @@ -39,7 +39,7 @@ function getQueryFn(rawQuery) { return (${query || true}); `; console.debug(body); - return new Function('entry', body); // CodeQL[js/code-injection]: intentional developer filter tool, runs in browser only + return new Function('entry', body); // codeql[js/code-injection] - intentional developer filter tool, browser context only } /** From 883d6040a9dfcb9eca89444675f6bf1cf568b795 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 11:19:19 +1000 Subject: [PATCH 06/29] chore: simplify worker.js comment; alerts dismissed via security dashboard --- static/xref/filter/worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/xref/filter/worker.js b/static/xref/filter/worker.js index be5ac7e2..cf479dd7 100644 --- a/static/xref/filter/worker.js +++ b/static/xref/filter/worker.js @@ -39,7 +39,7 @@ function getQueryFn(rawQuery) { return (${query || true}); `; console.debug(body); - return new Function('entry', body); // codeql[js/code-injection] - intentional developer filter tool, browser context only + return new Function('entry', body); // intentional: developer filter tool, browser-only context } /** From ed58a57d96b591542c4a317e8a202a7c4c98dcb4 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 11:26:15 +1000 Subject: [PATCH 07/29] fix(rate-limit): throw RangeError for invalid windowMs or max options --- tests/utils/rate-limit.test.js | 26 ++++++++++++++++++++++++++ utils/rate-limit.ts | 6 ++++++ 2 files changed, 32 insertions(+) diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index 7ef60eed..ea552a81 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -97,4 +97,30 @@ describe("utils/rate-limit", () => { middleware(req, makeRes(), () => nextCalled++); expect(nextCalled).toBe(2); }); + + describe("invalid options", () => { + it("throws for windowMs = 0", () => { + expect(() => rateLimit({ windowMs: 0, max: 10 })).toThrowError(RangeError); + }); + + it("throws for negative windowMs", () => { + expect(() => rateLimit({ windowMs: -1000, max: 10 })).toThrowError(RangeError); + }); + + it("throws for non-finite windowMs", () => { + expect(() => rateLimit({ windowMs: Infinity, max: 10 })).toThrowError(RangeError); + }); + + it("throws for max = 0", () => { + expect(() => rateLimit({ windowMs: 60_000, max: 0 })).toThrowError(RangeError); + }); + + it("throws for negative max", () => { + expect(() => rateLimit({ windowMs: 60_000, max: -1 })).toThrowError(RangeError); + }); + + it("throws for non-integer max", () => { + expect(() => rateLimit({ windowMs: 60_000, max: 1.5 })).toThrowError(RangeError); + }); + }); }); diff --git a/utils/rate-limit.ts b/utils/rate-limit.ts index 4f90f876..74909d6b 100644 --- a/utils/rate-limit.ts +++ b/utils/rate-limit.ts @@ -11,6 +11,12 @@ interface RateLimitOptions { * exceed `max` within the rolling `windowMs` period with HTTP 429. */ export function rateLimit({ windowMs, max }: RateLimitOptions) { + if (!Number.isFinite(windowMs) || windowMs <= 0) { + throw new RangeError(`rateLimit: windowMs must be a positive finite number, got ${windowMs}`); + } + if (!Number.isInteger(max) || max <= 0) { + throw new RangeError(`rateLimit: max must be a positive integer, got ${max}`); + } const log = new Map(); // Periodically evict fully-expired entries to bound memory growth. From d17e2deea512d6dc2b7f4b6619c686e89f0982ba Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 11:36:59 +1000 Subject: [PATCH 08/29] revert: stop touching worker.js; alert #21 dismissed via dashboard, not in PR diff --- static/xref/filter/worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/xref/filter/worker.js b/static/xref/filter/worker.js index cf479dd7..c2061f65 100644 --- a/static/xref/filter/worker.js +++ b/static/xref/filter/worker.js @@ -39,7 +39,7 @@ function getQueryFn(rawQuery) { return (${query || true}); `; console.debug(body); - return new Function('entry', body); // intentional: developer filter tool, browser-only context + return new Function('entry', body); } /** From 5de4946440218358107c39e894237bd241736da3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:42:33 +0000 Subject: [PATCH 09/29] fix(rate-limit): compute sliding-window Retry-After precisely Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/94e5f9c7-db01-42a3-9712-c3baf7a4fc1b --- tests/utils/rate-limit.test.js | 19 +++++++++++++++++++ utils/rate-limit.ts | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index ea552a81..c9a6570d 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -57,6 +57,25 @@ describe("utils/rate-limit", () => { expect(nextCalled).toBe(2); }); + it("sets Retry-After to seconds until next request is allowed", () => { + const middleware = rateLimit({ windowMs: 60_000, max: 2 }); + const req = makeReq(); + let nextCalled = 0; + const nowSpy = spyOn(Date, "now").and.returnValues(1_000, 30_000, 31_000); + + middleware(req, makeRes(), () => nextCalled++); + middleware(req, makeRes(), () => nextCalled++); + + const blockedRes = makeRes(); + middleware(req, blockedRes, () => nextCalled++); + + expect(blockedRes._status).toBe(429); + expect(blockedRes.headers["Retry-After"]).toBe("30"); + expect(nextCalled).toBe(2); + + nowSpy.and.callThrough(); + }); + it("tracks IPs independently", () => { const middleware = rateLimit({ windowMs: 60_000, max: 1 }); let nextCalled = 0; diff --git a/utils/rate-limit.ts b/utils/rate-limit.ts index 74909d6b..0047e6b9 100644 --- a/utils/rate-limit.ts +++ b/utils/rate-limit.ts @@ -37,7 +37,9 @@ export function rateLimit({ windowMs, max }: RateLimitOptions) { const timestamps = (log.get(key) ?? []).filter(t => t > cutoff); if (timestamps.length >= max) { - res.set("Retry-After", String(Math.ceil(windowMs / 1000))); + const oldestTimestamp = timestamps[0]; + const retryAfterMs = Math.max(0, oldestTimestamp + windowMs - now); + res.set("Retry-After", String(Math.max(1, Math.ceil(retryAfterMs / 1000)))); res.set("Content-Type", "text/plain"); return res.status(429).send("Too Many Requests"); } From 655c5eb181b07318a96c1e8f131fd2850a73cfaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:43:21 +0000 Subject: [PATCH 10/29] test: use os.tmpdir for DATA_DIR in env helper Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/b9e8d5e9-33f0-4a95-b00a-d59e89c97cd5 --- tests/helpers/env.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/helpers/env.js b/tests/helpers/env.js index 5062bd30..87378aa4 100644 --- a/tests/helpers/env.js +++ b/tests/helpers/env.js @@ -1,3 +1,5 @@ +import os from "node:os"; + // Set required env vars so modules that read them at load time don't throw. process.env.GH_TOKEN = "test-token"; -process.env.DATA_DIR = "/tmp"; +process.env.DATA_DIR = os.tmpdir(); From a76fda15ca2160902e5814f1f240945b2a076a34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:43:44 +0000 Subject: [PATCH 11/29] test(rate-limit): stabilize Retry-After test spy usage Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/94e5f9c7-db01-42a3-9712-c3baf7a4fc1b --- tests/utils/rate-limit.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index c9a6570d..cb045cb4 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -61,7 +61,7 @@ describe("utils/rate-limit", () => { const middleware = rateLimit({ windowMs: 60_000, max: 2 }); const req = makeReq(); let nextCalled = 0; - const nowSpy = spyOn(Date, "now").and.returnValues(1_000, 30_000, 31_000); + spyOn(Date, "now").and.returnValues(1_000, 30_000, 31_000); middleware(req, makeRes(), () => nextCalled++); middleware(req, makeRes(), () => nextCalled++); @@ -72,8 +72,6 @@ describe("utils/rate-limit", () => { expect(blockedRes._status).toBe(429); expect(blockedRes.headers["Retry-After"]).toBe("30"); expect(nextCalled).toBe(2); - - nowSpy.and.callThrough(); }); it("tracks IPs independently", () => { From 086b78e4192bcf0911bf41fbcf38b7a7ff7bc217 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:44:20 +0000 Subject: [PATCH 12/29] test: restore Date.now spy cleanup in rate-limit specs Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/b9e8d5e9-33f0-4a95-b00a-d59e89c97cd5 --- tests/utils/rate-limit.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index cb045cb4..c66d61d9 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -1,6 +1,12 @@ import { rateLimit } from "../../build/utils/rate-limit.js"; describe("utils/rate-limit", () => { + afterEach(() => { + if (jasmine.isSpy(Date.now)) { + Date.now.and.callThrough(); + } + }); + function makeReq(ip = "1.2.3.4") { return { ip }; } From 459944cf6fc3949f77af54febc56558be9e4507b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:45:22 +0000 Subject: [PATCH 13/29] test: make Retry-After spy values fully deterministic Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/b9e8d5e9-33f0-4a95-b00a-d59e89c97cd5 --- tests/utils/rate-limit.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index c66d61d9..7075422e 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -67,7 +67,7 @@ describe("utils/rate-limit", () => { const middleware = rateLimit({ windowMs: 60_000, max: 2 }); const req = makeReq(); let nextCalled = 0; - spyOn(Date, "now").and.returnValues(1_000, 30_000, 31_000); + spyOn(Date, "now").and.returnValues(1_000, 30_000, 31_000, 31_000); middleware(req, makeRes(), () => nextCalled++); middleware(req, makeRes(), () => nextCalled++); From 32c96aac70ae5175cd0e58a410d7a404630deb83 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Wed, 15 Apr 2026 12:15:21 +1000 Subject: [PATCH 14/29] fix(helmet): disable frameguard so CSP frame-ancestors controls iframe embedding alone --- app.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app.ts b/app.ts index 0c7b716a..805be3f1 100644 --- a/app.ts +++ b/app.ts @@ -34,6 +34,10 @@ registerViewEngine(app); // Defaults https://www.npmjs.com/package/helmet#how-it-works app.use( helmet({ + // Disable X-Frame-Options so CSP frame-ancestors takes sole control. + // Helmet's default SAMEORIGIN would block cross-origin embedding + // even when frame-ancestors allows it in older browsers. + frameguard: false, contentSecurityPolicy: { directives: { ...helmet.contentSecurityPolicy.getDefaultDirectives(), From 1144d0c54ae92a90caff50736f6d33b4dc025d6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:54:30 +0000 Subject: [PATCH 15/29] fix: tighten respec rate limit and remove realtime wait in test Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/5eda4770-da11-4af8-afed-b3a5f75f5607 --- routes/respec/index.ts | 2 +- tests/utils/rate-limit.test.js | 5 ++--- utils/rate-limit.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/routes/respec/index.ts b/routes/respec/index.ts index 903feeab..172bd49d 100644 --- a/routes/respec/index.ts +++ b/routes/respec/index.ts @@ -10,7 +10,7 @@ import buildUpdateRoute, { PKG_DIR } from "./builds/update.js"; const router = express.Router({ mergeParams: true }); -const sizeRateLimit = rateLimit({ windowMs: ms("1m"), max: 60 }); +const sizeRateLimit = rateLimit({ windowMs: ms("1m"), max: 10 }); router.get("/size", sizeRateLimit, sizeRoute.get); router.put( diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index 7075422e..2262a74b 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -103,10 +103,11 @@ describe("utils/rate-limit", () => { expect(blockedRes._status).toBe(429); }); - it("allows requests again after the window expires", async () => { + it("allows requests again after the window expires", () => { const middleware = rateLimit({ windowMs: 50, max: 1 }); const req = makeReq(); let nextCalled = 0; + spyOn(Date, "now").and.returnValues(0, 10, 60); middleware(req, makeRes(), () => nextCalled++); @@ -115,8 +116,6 @@ describe("utils/rate-limit", () => { expect(blockedRes._status).toBe(429); expect(nextCalled).toBe(1); - await new Promise(resolve => setTimeout(resolve, 60)); - middleware(req, makeRes(), () => nextCalled++); expect(nextCalled).toBe(2); }); diff --git a/utils/rate-limit.ts b/utils/rate-limit.ts index 0047e6b9..ca5274d7 100644 --- a/utils/rate-limit.ts +++ b/utils/rate-limit.ts @@ -38,7 +38,7 @@ export function rateLimit({ windowMs, max }: RateLimitOptions) { const timestamps = (log.get(key) ?? []).filter(t => t > cutoff); if (timestamps.length >= max) { const oldestTimestamp = timestamps[0]; - const retryAfterMs = Math.max(0, oldestTimestamp + windowMs - now); + const retryAfterMs = Math.max(0, oldestTimestamp - cutoff); res.set("Retry-After", String(Math.max(1, Math.ceil(retryAfterMs / 1000)))); res.set("Content-Type", "text/plain"); return res.status(429).send("Too Many Requests"); From 78aa8936703e6a12968248db325b48dc29225280 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:55:58 +0000 Subject: [PATCH 16/29] fix: keep precise Retry-After math and clarify mocked expiry timestamp Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/5eda4770-da11-4af8-afed-b3a5f75f5607 --- tests/utils/rate-limit.test.js | 2 +- utils/rate-limit.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index 2262a74b..b4f60044 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -107,7 +107,7 @@ describe("utils/rate-limit", () => { const middleware = rateLimit({ windowMs: 50, max: 1 }); const req = makeReq(); let nextCalled = 0; - spyOn(Date, "now").and.returnValues(0, 10, 60); + spyOn(Date, "now").and.returnValues(0, 10, 61); middleware(req, makeRes(), () => nextCalled++); diff --git a/utils/rate-limit.ts b/utils/rate-limit.ts index ca5274d7..0047e6b9 100644 --- a/utils/rate-limit.ts +++ b/utils/rate-limit.ts @@ -38,7 +38,7 @@ export function rateLimit({ windowMs, max }: RateLimitOptions) { const timestamps = (log.get(key) ?? []).filter(t => t > cutoff); if (timestamps.length >= max) { const oldestTimestamp = timestamps[0]; - const retryAfterMs = Math.max(0, oldestTimestamp - cutoff); + const retryAfterMs = Math.max(0, oldestTimestamp + windowMs - now); res.set("Retry-After", String(Math.max(1, Math.ceil(retryAfterMs / 1000)))); res.set("Content-Type", "text/plain"); return res.status(429).send("Too Many Requests"); From 85787e9320b240cd89d9711fa382e93af15d9aa0 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Thu, 23 Apr 2026 22:55:14 +1000 Subject: [PATCH 17/29] fix: use express-rate-limit, tighten CSP and SSRF checks - Replace custom rate limiter with express-rate-limit (fixes memory exhaustion and IP spoofing bypass vectors) - Remove rate limit from PUT /respec/size (authenticated webhook route) - Tighten frame-ancestors from wildcard to 'self' + respec.org - Use URL origin comparison for SSRF check instead of prefix match - Add URL confusion test case for SSRF validation --- app.ts | 4 +- package.json | 1 + pnpm-lock.yaml | 20 +++ routes/github/lib/utils/rest.ts | 20 ++- routes/respec/index.ts | 1 - tests/routes/github/lib/utils/rest.test.js | 15 ++- tests/utils/rate-limit.test.js | 148 --------------------- utils/rate-limit.ts | 51 +------ 8 files changed, 48 insertions(+), 212 deletions(-) delete mode 100644 tests/utils/rate-limit.test.js diff --git a/app.ts b/app.ts index 805be3f1..564b0905 100644 --- a/app.ts +++ b/app.ts @@ -41,8 +41,8 @@ app.use( contentSecurityPolicy: { directives: { ...helmet.contentSecurityPolicy.getDefaultDirectives(), - // Allow embedding in iframes from any origin (ReSpec pill UI). - "frame-ancestors": ["*"], + // Allow embedding in iframes from respec.org (ReSpec pill UI). + "frame-ancestors": ["'self'", "https://respec.org"], }, }, }), diff --git a/package.json b/package.json index 5cd413a7..812b4e1c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.4.0", "helmet": "^8.1.0", "morgan": "^1.10.1", "nanoid": "^5.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa1e8134..bfa40ae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + express-rate-limit: + specifier: ^8.4.0 + version: 8.4.0(express@5.2.1) helmet: specifier: ^8.1.0 version: 8.1.0 @@ -265,6 +268,12 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + express-rate-limit@8.4.0: + resolution: {integrity: sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -335,6 +344,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -793,6 +806,11 @@ snapshots: etag@1.8.1: {} + express-rate-limit@8.4.0(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -905,6 +923,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} is-promise@4.0.0: {} diff --git a/routes/github/lib/utils/rest.ts b/routes/github/lib/utils/rest.ts index 1774140f..a322d74a 100644 --- a/routes/github/lib/utils/rest.ts +++ b/routes/github/lib/utils/rest.ts @@ -1,11 +1,19 @@ import { getToken, updateRateLimit, RateLimit } from "./tokens.js"; -const GITHUB_API_PREFIX = "https://api.github.com/"; +const GITHUB_API_ORIGIN = "https://api.github.com"; + +function assertGitHubAPIUrl(url: string) { + try { + if (new URL(url).origin !== GITHUB_API_ORIGIN) throw 0; + } catch { + throw new Error( + `requestData: endpoint must be a ${GITHUB_API_ORIGIN}/ URL`, + ); + } +} export async function* requestData(endpoint: string, pages = 30) { - if (!endpoint.startsWith(GITHUB_API_PREFIX)) { - throw new Error(`requestData: endpoint must start with ${GITHUB_API_PREFIX}`); - } + assertGitHubAPIUrl(endpoint); let url: string | null = endpoint; do { const token = getToken(); @@ -25,9 +33,7 @@ export async function* requestData(endpoint: string, pages = 30) { yield { url, result }; const next = nextPage(response.headers.get("link") || ""); - if (next !== null && !next.startsWith(GITHUB_API_PREFIX)) { - throw new Error(`requestData: pagination URL must start with ${GITHUB_API_PREFIX}`); - } + if (next !== null) assertGitHubAPIUrl(next); url = next; updateRateLimit(token, getRateLimit(response.headers)); } while (url !== null && --pages > 0); diff --git a/routes/respec/index.ts b/routes/respec/index.ts index 172bd49d..a86c491f 100644 --- a/routes/respec/index.ts +++ b/routes/respec/index.ts @@ -15,7 +15,6 @@ const sizeRateLimit = rateLimit({ windowMs: ms("1m"), max: 10 }); router.get("/size", sizeRateLimit, sizeRoute.get); router.put( "/size", - sizeRateLimit, express.urlencoded({ extended: false, parameterLimit: 4, limit: "128b" }), sizeRoute.put, ); diff --git a/tests/routes/github/lib/utils/rest.test.js b/tests/routes/github/lib/utils/rest.test.js index a87c8bae..1c4a8c99 100644 --- a/tests/routes/github/lib/utils/rest.test.js +++ b/tests/routes/github/lib/utils/rest.test.js @@ -4,28 +4,35 @@ describe("routes/github/lib/utils/rest - requestData", () => { it("throws for a non-GitHub API endpoint", async () => { const gen = requestData("https://evil.example.com/steal"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must start with https:\/\/api\.github\.com\//, + /endpoint must be a https:\/\/api\.github\.com\/ URL/, ); }); it("throws for an http (non-https) GitHub URL", async () => { const gen = requestData("http://api.github.com/repos/foo/bar"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must start with https:\/\/api\.github\.com\//, + /endpoint must be a https:\/\/api\.github\.com\/ URL/, ); }); it("throws for a GitHub URL that isn't the API subdomain", async () => { const gen = requestData("https://github.com/speced/respec"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must start with https:\/\/api\.github\.com\//, + /endpoint must be a https:\/\/api\.github\.com\/ URL/, ); }); it("throws for an empty string endpoint", async () => { const gen = requestData(""); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must start with https:\/\/api\.github\.com\//, + /endpoint must be a https:\/\/api\.github\.com\/ URL/, + ); + }); + + it("throws for a URL with github.com as username (URL confusion)", async () => { + const gen = requestData("https://api.github.com@evil.com/path"); + await expectAsync(gen.next()).toBeRejectedWithError( + /endpoint must be a https:\/\/api\.github\.com\/ URL/, ); }); }); diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js deleted file mode 100644 index b4f60044..00000000 --- a/tests/utils/rate-limit.test.js +++ /dev/null @@ -1,148 +0,0 @@ -import { rateLimit } from "../../build/utils/rate-limit.js"; - -describe("utils/rate-limit", () => { - afterEach(() => { - if (jasmine.isSpy(Date.now)) { - Date.now.and.callThrough(); - } - }); - - function makeReq(ip = "1.2.3.4") { - return { ip }; - } - - function makeRes() { - const headers = {}; - const res = { - _status: null, - _body: null, - headers, - set(key, value) { - headers[key] = value; - return res; - }, - status(code) { - res._status = code; - return res; - }, - send(body) { - res._body = body; - return res; - }, - }; - return res; - } - - it("allows requests under the limit", () => { - const middleware = rateLimit({ windowMs: 60_000, max: 3 }); - const req = makeReq(); - let nextCalled = 0; - - for (let i = 0; i < 3; i++) { - const res = makeRes(); - middleware(req, res, () => nextCalled++); - expect(res._status).toBeNull(); - } - expect(nextCalled).toBe(3); - }); - - it("returns 429 when limit is exceeded", () => { - const middleware = rateLimit({ windowMs: 60_000, max: 2 }); - const req = makeReq(); - let nextCalled = 0; - - middleware(req, makeRes(), () => nextCalled++); - middleware(req, makeRes(), () => nextCalled++); - - const blockedRes = makeRes(); - middleware(req, blockedRes, () => nextCalled++); - - expect(blockedRes._status).toBe(429); - expect(blockedRes._body).toBe("Too Many Requests"); - expect(blockedRes.headers["Retry-After"]).toBeDefined(); - expect(nextCalled).toBe(2); - }); - - it("sets Retry-After to seconds until next request is allowed", () => { - const middleware = rateLimit({ windowMs: 60_000, max: 2 }); - const req = makeReq(); - let nextCalled = 0; - spyOn(Date, "now").and.returnValues(1_000, 30_000, 31_000, 31_000); - - middleware(req, makeRes(), () => nextCalled++); - middleware(req, makeRes(), () => nextCalled++); - - const blockedRes = makeRes(); - middleware(req, blockedRes, () => nextCalled++); - - expect(blockedRes._status).toBe(429); - expect(blockedRes.headers["Retry-After"]).toBe("30"); - expect(nextCalled).toBe(2); - }); - - it("tracks IPs independently", () => { - const middleware = rateLimit({ windowMs: 60_000, max: 1 }); - let nextCalled = 0; - - middleware(makeReq("1.1.1.1"), makeRes(), () => nextCalled++); - middleware(makeReq("2.2.2.2"), makeRes(), () => nextCalled++); - - expect(nextCalled).toBe(2); - }); - - it("uses 'unknown' as key when req.ip is undefined", () => { - const middleware = rateLimit({ windowMs: 60_000, max: 1 }); - const req = { ip: undefined }; - let nextCalled = 0; - - middleware(req, makeRes(), () => nextCalled++); - expect(nextCalled).toBe(1); - - const blockedRes = makeRes(); - middleware(req, blockedRes, () => {}); - expect(blockedRes._status).toBe(429); - }); - - it("allows requests again after the window expires", () => { - const middleware = rateLimit({ windowMs: 50, max: 1 }); - const req = makeReq(); - let nextCalled = 0; - spyOn(Date, "now").and.returnValues(0, 10, 61); - - middleware(req, makeRes(), () => nextCalled++); - - const blockedRes = makeRes(); - middleware(req, blockedRes, () => nextCalled++); - expect(blockedRes._status).toBe(429); - expect(nextCalled).toBe(1); - - middleware(req, makeRes(), () => nextCalled++); - expect(nextCalled).toBe(2); - }); - - describe("invalid options", () => { - it("throws for windowMs = 0", () => { - expect(() => rateLimit({ windowMs: 0, max: 10 })).toThrowError(RangeError); - }); - - it("throws for negative windowMs", () => { - expect(() => rateLimit({ windowMs: -1000, max: 10 })).toThrowError(RangeError); - }); - - it("throws for non-finite windowMs", () => { - expect(() => rateLimit({ windowMs: Infinity, max: 10 })).toThrowError(RangeError); - }); - - it("throws for max = 0", () => { - expect(() => rateLimit({ windowMs: 60_000, max: 0 })).toThrowError(RangeError); - }); - - it("throws for negative max", () => { - expect(() => rateLimit({ windowMs: 60_000, max: -1 })).toThrowError(RangeError); - }); - - it("throws for non-integer max", () => { - expect(() => rateLimit({ windowMs: 60_000, max: 1.5 })).toThrowError(RangeError); - }); - }); -}); diff --git a/utils/rate-limit.ts b/utils/rate-limit.ts index 0047e6b9..8dfb0486 100644 --- a/utils/rate-limit.ts +++ b/utils/rate-limit.ts @@ -1,50 +1 @@ -import { NextFunction, Request, Response } from "express"; - -interface RateLimitOptions { - windowMs: number; - max: number; -} - -/** - * Sliding-window rate limiter middleware factory. - * Tracks request timestamps per IP address and rejects requests that - * exceed `max` within the rolling `windowMs` period with HTTP 429. - */ -export function rateLimit({ windowMs, max }: RateLimitOptions) { - if (!Number.isFinite(windowMs) || windowMs <= 0) { - throw new RangeError(`rateLimit: windowMs must be a positive finite number, got ${windowMs}`); - } - if (!Number.isInteger(max) || max <= 0) { - throw new RangeError(`rateLimit: max must be a positive integer, got ${max}`); - } - const log = new Map(); - - // Periodically evict fully-expired entries to bound memory growth. - const cleanup = setInterval(() => { - const cutoff = Date.now() - windowMs; - for (const [key, timestamps] of log) { - if (timestamps[timestamps.length - 1] < cutoff) { - log.delete(key); - } - } - }, windowMs); - cleanup.unref(); - - return (req: Request, res: Response, next: NextFunction) => { - const key = req.ip ?? "unknown"; - const now = Date.now(); - const cutoff = now - windowMs; - - const timestamps = (log.get(key) ?? []).filter(t => t > cutoff); - if (timestamps.length >= max) { - const oldestTimestamp = timestamps[0]; - const retryAfterMs = Math.max(0, oldestTimestamp + windowMs - now); - res.set("Retry-After", String(Math.max(1, Math.ceil(retryAfterMs / 1000)))); - res.set("Content-Type", "text/plain"); - return res.status(429).send("Too Many Requests"); - } - timestamps.push(now); - log.set(key, timestamps); - next(); - }; -} +export { default as rateLimit } from "express-rate-limit"; From fcaea5a4e2a59a582cef5f6d7252317ed04b4a76 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Fri, 24 Apr 2026 15:24:21 +1000 Subject: [PATCH 18/29] fix: address Copilot review findings - Rewrite assertGitHubAPIUrl to parse URL explicitly (no throw 0 trick) - Separate error messages for invalid URL vs wrong origin - Guard env helper with ??= to preserve developer's local config - Install express-rate-limit as direct dependency --- routes/github/lib/utils/rest.ts | 10 ++++++++-- tests/helpers/env.js | 7 ++++--- tests/routes/github/lib/utils/rest.test.js | 10 +++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/routes/github/lib/utils/rest.ts b/routes/github/lib/utils/rest.ts index a322d74a..8ed6a9e4 100644 --- a/routes/github/lib/utils/rest.ts +++ b/routes/github/lib/utils/rest.ts @@ -3,11 +3,17 @@ import { getToken, updateRateLimit, RateLimit } from "./tokens.js"; const GITHUB_API_ORIGIN = "https://api.github.com"; function assertGitHubAPIUrl(url: string) { + let origin: string; try { - if (new URL(url).origin !== GITHUB_API_ORIGIN) throw 0; + origin = new URL(url).origin; } catch { throw new Error( - `requestData: endpoint must be a ${GITHUB_API_ORIGIN}/ URL`, + `requestData: endpoint is not a valid URL: ${url}`, + ); + } + if (origin !== GITHUB_API_ORIGIN) { + throw new Error( + `requestData: endpoint origin must be ${GITHUB_API_ORIGIN}, got ${origin}`, ); } } diff --git a/tests/helpers/env.js b/tests/helpers/env.js index 87378aa4..cf0c15af 100644 --- a/tests/helpers/env.js +++ b/tests/helpers/env.js @@ -1,5 +1,6 @@ import os from "node:os"; -// Set required env vars so modules that read them at load time don't throw. -process.env.GH_TOKEN = "test-token"; -process.env.DATA_DIR = os.tmpdir(); +// Set required env vars only when not already configured, +// so local developer settings are preserved during test runs. +process.env.GH_TOKEN ??= "test-token"; +process.env.DATA_DIR ??= os.tmpdir(); diff --git a/tests/routes/github/lib/utils/rest.test.js b/tests/routes/github/lib/utils/rest.test.js index 1c4a8c99..47853214 100644 --- a/tests/routes/github/lib/utils/rest.test.js +++ b/tests/routes/github/lib/utils/rest.test.js @@ -4,35 +4,35 @@ describe("routes/github/lib/utils/rest - requestData", () => { it("throws for a non-GitHub API endpoint", async () => { const gen = requestData("https://evil.example.com/steal"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must be a https:\/\/api\.github\.com\/ URL/, + /endpoint origin must be https:\/\/api\.github\.com/, ); }); it("throws for an http (non-https) GitHub URL", async () => { const gen = requestData("http://api.github.com/repos/foo/bar"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must be a https:\/\/api\.github\.com\/ URL/, + /endpoint origin must be https:\/\/api\.github\.com/, ); }); it("throws for a GitHub URL that isn't the API subdomain", async () => { const gen = requestData("https://github.com/speced/respec"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must be a https:\/\/api\.github\.com\/ URL/, + /endpoint origin must be https:\/\/api\.github\.com/, ); }); it("throws for an empty string endpoint", async () => { const gen = requestData(""); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must be a https:\/\/api\.github\.com\/ URL/, + /endpoint is not a valid URL/, ); }); it("throws for a URL with github.com as username (URL confusion)", async () => { const gen = requestData("https://api.github.com@evil.com/path"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint must be a https:\/\/api\.github\.com\/ URL/, + /endpoint origin must be https:\/\/api\.github\.com/, ); }); }); From 2f3802a9981311887f3d673eed87d71d534a7c4a Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Fri, 24 Apr 2026 20:47:22 +1000 Subject: [PATCH 19/29] fix: address Sid's review comments - Use tryURL() helper in SSRF validation per Sid's suggestion, matching the codebase convention from utils/logging.ts. - Remove CSP frame-ancestors directive: the service intentionally allows embedding by any site for the ReSpec pill UI. - Update test to match unified error message. --- app.ts | 11 +---------- routes/github/lib/utils/rest.ts | 17 +++++++++-------- tests/routes/github/lib/utils/rest.test.js | 2 +- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/app.ts b/app.ts index 564b0905..19ae963f 100644 --- a/app.ts +++ b/app.ts @@ -34,17 +34,8 @@ registerViewEngine(app); // Defaults https://www.npmjs.com/package/helmet#how-it-works app.use( helmet({ - // Disable X-Frame-Options so CSP frame-ancestors takes sole control. - // Helmet's default SAMEORIGIN would block cross-origin embedding - // even when frame-ancestors allows it in older browsers. + // ReSpec pill UI is embedded via iframe on any spec-hosting site. frameguard: false, - contentSecurityPolicy: { - directives: { - ...helmet.contentSecurityPolicy.getDefaultDirectives(), - // Allow embedding in iframes from respec.org (ReSpec pill UI). - "frame-ancestors": ["'self'", "https://respec.org"], - }, - }, }), ); diff --git a/routes/github/lib/utils/rest.ts b/routes/github/lib/utils/rest.ts index 8ed6a9e4..6e3bd635 100644 --- a/routes/github/lib/utils/rest.ts +++ b/routes/github/lib/utils/rest.ts @@ -2,18 +2,19 @@ import { getToken, updateRateLimit, RateLimit } from "./tokens.js"; const GITHUB_API_ORIGIN = "https://api.github.com"; -function assertGitHubAPIUrl(url: string) { - let origin: string; +function tryURL(url: string) { try { - origin = new URL(url).origin; + return new URL(url); } catch { - throw new Error( - `requestData: endpoint is not a valid URL: ${url}`, - ); + return null; } - if (origin !== GITHUB_API_ORIGIN) { +} + +function assertGitHubAPIUrl(url: string) { + const parsed = tryURL(url); + if (parsed?.origin !== GITHUB_API_ORIGIN) { throw new Error( - `requestData: endpoint origin must be ${GITHUB_API_ORIGIN}, got ${origin}`, + `requestData: endpoint origin must be ${GITHUB_API_ORIGIN}, got ${parsed?.origin ?? "invalid URL"}`, ); } } diff --git a/tests/routes/github/lib/utils/rest.test.js b/tests/routes/github/lib/utils/rest.test.js index 47853214..ceae9128 100644 --- a/tests/routes/github/lib/utils/rest.test.js +++ b/tests/routes/github/lib/utils/rest.test.js @@ -25,7 +25,7 @@ describe("routes/github/lib/utils/rest - requestData", () => { it("throws for an empty string endpoint", async () => { const gen = requestData(""); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint is not a valid URL/, + /endpoint origin must be https:\/\/api\.github\.com/, ); }); From 07977a48d1d56292ad8ef380ce8d8abdc0967552 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Fri, 24 Apr 2026 20:50:23 +1000 Subject: [PATCH 20/29] test: add smoke tests for rate-limit re-export Verify the express-rate-limit re-export works and returns middleware when called with valid options. --- tests/utils/rate-limit.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/utils/rate-limit.test.js diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js new file mode 100644 index 00000000..0a4ec748 --- /dev/null +++ b/tests/utils/rate-limit.test.js @@ -0,0 +1,12 @@ +import { rateLimit } from "../../build/utils/rate-limit.js"; + +describe("utils/rate-limit", () => { + it("exports a function", () => { + expect(typeof rateLimit).toBe("function"); + }); + + it("returns middleware when called with valid options", () => { + const middleware = rateLimit({ windowMs: 60_000, max: 10 }); + expect(typeof middleware).toBe("function"); + }); +}); From 9c5c53ffe69b7149994c5b67677fcaff63695ab7 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Fri, 24 Apr 2026 20:57:15 +1000 Subject: [PATCH 21/29] test: add behavior tests for rate limiting middleware Tests call the middleware directly with mock req/res objects, verifying the site's rate-limiting behavior independent of which library implements it. --- tests/utils/rate-limit.test.js | 52 ++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index 0a4ec748..d052839e 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -1,12 +1,52 @@ import { rateLimit } from "../../build/utils/rate-limit.js"; -describe("utils/rate-limit", () => { - it("exports a function", () => { - expect(typeof rateLimit).toBe("function"); +function makeReq(ip = "127.0.0.1") { + return { ip, headers: {}, method: "GET", url: "/" }; +} + +function makeRes() { + const headers = {}; + return { + _status: null, + _body: null, + headers, + setHeader(key, value) { headers[key.toLowerCase()] = value; return this; }, + getHeader(key) { return headers[key.toLowerCase()]; }, + status(code) { this._status = code; return this; }, + send(body) { this._body = body; return this; }, + end() { return this; }, + headersSent: false, + }; +} + +describe("rate limiting behavior", () => { + it("allows requests under the limit", async () => { + const middleware = rateLimit({ windowMs: 60_000, max: 3, validate: false }); + for (let i = 0; i < 3; i++) { + const res = makeRes(); + await new Promise((resolve) => middleware(makeReq(), res, resolve)); + expect(res._status).toBeNull(); + } + }); + + it("returns 429 when limit is exceeded", async () => { + const middleware = rateLimit({ windowMs: 60_000, max: 2, validate: false }); + const req = makeReq(); + await new Promise((resolve) => middleware(req, makeRes(), resolve)); + await new Promise((resolve) => middleware(req, makeRes(), resolve)); + + const blockedRes = makeRes(); + let nextCalled = false; + await middleware(req, blockedRes, () => { nextCalled = true; }); + expect(blockedRes._status).toBe(429); + expect(nextCalled).toBe(false); }); - it("returns middleware when called with valid options", () => { - const middleware = rateLimit({ windowMs: 60_000, max: 10 }); - expect(typeof middleware).toBe("function"); + it("tracks IPs independently", async () => { + const middleware = rateLimit({ windowMs: 60_000, max: 1, validate: false }); + let count = 0; + await new Promise((resolve) => middleware(makeReq("1.1.1.1"), makeRes(), () => { count++; resolve(); })); + await new Promise((resolve) => middleware(makeReq("2.2.2.2"), makeRes(), () => { count++; resolve(); })); + expect(count).toBe(2); }); }); From e89ae29c9bdba1c169e71090ce9937983dfeebcb Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Fri, 24 Apr 2026 21:00:39 +1000 Subject: [PATCH 22/29] fix: override html-minifier with html-minifier-terser ucontent depends on the unmaintained html-minifier@4 which has a high-severity ReDoS vulnerability (dependabot alert #7). Override with the maintained fork html-minifier-terser@7 via pnpm overrides. API-compatible, no code changes needed. --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index 812b4e1c..52b8525a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,11 @@ "update-data-sources": "node build/scripts/update-data-sources.js" }, "packageManager": "pnpm@9.8.0", + "pnpm": { + "overrides": { + "html-minifier": "npm:html-minifier-terser@^7.2.0" + } + }, "simple-git-hooks": { "post-merge": "pnpm i && pnpm build" }, From 087e94b45d5d23bce8d48899341efe1a44e573e6 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Fri, 24 Apr 2026 21:18:50 +1000 Subject: [PATCH 23/29] fix(helmet): remove frame-ancestors from default CSP Helmet's default CSP includes frame-ancestors: 'self' which blocks cross-origin iframe embedding. Delete the directive from defaults so the ReSpec pill UI can be embedded on any spec-hosting site. --- app.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app.ts b/app.ts index 19ae963f..30264dfb 100644 --- a/app.ts +++ b/app.ts @@ -32,10 +32,15 @@ registerViewEngine(app); // Security // Defaults https://www.npmjs.com/package/helmet#how-it-works +// ReSpec pill UI is embedded via iframe on any spec-hosting site, +// so we must not send frame-ancestors or X-Frame-Options. +const cspDirectives = helmet.contentSecurityPolicy.getDefaultDirectives(); +delete cspDirectives["frame-ancestors"]; + app.use( helmet({ - // ReSpec pill UI is embedded via iframe on any spec-hosting site. frameguard: false, + contentSecurityPolicy: { directives: cspDirectives }, }), ); From ed82db5da1abbf2608aaf512794b57822227f90a Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Fri, 24 Apr 2026 21:44:53 +1000 Subject: [PATCH 24/29] test: assert Retry-After header on 429 response --- tests/utils/rate-limit.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index d052839e..4207145d 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -39,6 +39,7 @@ describe("rate limiting behavior", () => { let nextCalled = false; await middleware(req, blockedRes, () => { nextCalled = true; }); expect(blockedRes._status).toBe(429); + expect(blockedRes.headers["retry-after"]).toBeDefined(); expect(nextCalled).toBe(false); }); From 4e12de126b0b3a05c7179aca4bd05d2dc7724b8f Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Fri, 24 Apr 2026 21:46:44 +1000 Subject: [PATCH 25/29] fix: reject non-https schemes in SSRF check, expand tests Add protocol check to assertGitHubAPIUrl so blob: URLs (whose origin matches api.github.com) are rejected. Add positive test for valid URL, pagination URL validation test, and blob:/data: scheme rejection tests. --- routes/github/lib/utils/rest.ts | 2 +- tests/routes/github/lib/utils/rest.test.js | 55 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/routes/github/lib/utils/rest.ts b/routes/github/lib/utils/rest.ts index 6e3bd635..b54f545d 100644 --- a/routes/github/lib/utils/rest.ts +++ b/routes/github/lib/utils/rest.ts @@ -12,7 +12,7 @@ function tryURL(url: string) { function assertGitHubAPIUrl(url: string) { const parsed = tryURL(url); - if (parsed?.origin !== GITHUB_API_ORIGIN) { + if (parsed?.protocol !== "https:" || parsed.origin !== GITHUB_API_ORIGIN) { throw new Error( `requestData: endpoint origin must be ${GITHUB_API_ORIGIN}, got ${parsed?.origin ?? "invalid URL"}`, ); diff --git a/tests/routes/github/lib/utils/rest.test.js b/tests/routes/github/lib/utils/rest.test.js index ceae9128..f5247dbc 100644 --- a/tests/routes/github/lib/utils/rest.test.js +++ b/tests/routes/github/lib/utils/rest.test.js @@ -35,4 +35,59 @@ describe("routes/github/lib/utils/rest - requestData", () => { /endpoint origin must be https:\/\/api\.github\.com/, ); }); + + it("throws for a blob: URL with matching origin", async () => { + const gen = requestData("blob:https://api.github.com/some-uuid"); + await expectAsync(gen.next()).toBeRejectedWithError( + /endpoint origin must be https:\/\/api\.github\.com/, + ); + }); + + it("throws for a data: URL", async () => { + const gen = requestData("data:text/html,

hi

"); + await expectAsync(gen.next()).toBeRejectedWithError( + /endpoint origin must be https:\/\/api\.github\.com/, + ); + }); + + it("accepts a valid GitHub API URL", async () => { + const original = globalThis.fetch; + globalThis.fetch = async () => new Response(JSON.stringify([]), { + status: 200, + headers: { + "x-ratelimit-remaining": "10", + "x-ratelimit-reset": "9999999999", + "x-ratelimit-limit": "60", + }, + }); + try { + const gen = requestData("https://api.github.com/repos/w3c/respec/issues"); + const { value } = await gen.next(); + expect(value.url).toBe("https://api.github.com/repos/w3c/respec/issues"); + } finally { + globalThis.fetch = original; + } + }); + + it("rejects a malicious pagination URL in Link header", async () => { + const original = globalThis.fetch; + globalThis.fetch = async () => new Response(JSON.stringify([]), { + status: 200, + headers: { + link: '; rel="next"', + "x-ratelimit-remaining": "10", + "x-ratelimit-reset": "9999999999", + "x-ratelimit-limit": "60", + }, + }); + try { + const gen = requestData("https://api.github.com/repos/w3c/respec/issues"); + await gen.next(); + await expectAsync(gen.next()).toBeRejectedWithError( + /endpoint origin must be https:\/\/api\.github\.com/, + ); + } finally { + globalThis.fetch = original; + } + }); }); From 02cebe0d9398da9c197536db525732c450366937 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sat, 25 Apr 2026 17:18:34 +1000 Subject: [PATCH 26/29] chore: regenerate lockfile after dep-updates merge --- pnpm-lock.yaml | 174 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 125 insertions(+), 49 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfa40ae6..b487a19c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + html-minifier: npm:html-minifier-terser@^7.2.0 + importers: .: @@ -78,6 +81,22 @@ packages: '@jasminejs/reporters@1.0.0': resolution: {integrity: sha512-rM3GG4vx2H1Gp5kYCTr9aKlOEJFd43pzpiMAiy5b1+FUc2ub4e6bS6yCi/WQNDzAa5MVp9++dwcoEtcIfoEnhA==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -132,6 +151,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -163,16 +187,20 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - camel-case@3.0.0: - resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - clean-css@4.2.4: - resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==} - engines: {node: '>= 4.0'} + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -234,6 +262,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -249,6 +280,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -317,10 +352,6 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true - helmet@8.1.0: resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} engines: {node: '>=18.0.0'} @@ -328,9 +359,9 @@ packages: html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - html-minifier@4.0.0: - resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==} - engines: {node: '>=6'} + html-minifier-terser@7.2.0: + resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} + engines: {node: ^14.13.1 || >=16.0.0} hasBin: true http-errors@2.0.1: @@ -362,8 +393,8 @@ packages: resolution: {integrity: sha512-dvYt7bidcu0JvvSbiUnSDW7UQQiflUwDr6C+5wzoZ0J7RY9u+UcoSIzyhMPj6fnU/tC7KinJ5QrjwD2Y9p4T4w==} hasBin: true - lower-case@1.1.4: - resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} lru-cache@11.3.5: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} @@ -423,8 +454,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - no-case@2.3.2: - resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} non-error@0.1.0: resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} @@ -453,13 +484,16 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - param-case@2.1.1: - resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -559,10 +593,18 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + terser@5.46.2: + resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} + engines: {node: '>=10'} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -579,11 +621,6 @@ packages: ucontent@2.0.0: resolution: {integrity: sha512-yGV/qZpKW8KlWJea43V+pRvNNGuF2Ei9MTO5MwWF5yiQVenQlEDhkjedF91Szg8w0SdRSIzKEgMuaj4RweKqYg==} - uglify-js@3.17.4: - resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} - engines: {node: '>=0.8.0'} - hasBin: true - uhyphen@0.1.0: resolution: {integrity: sha512-o0QVGuFg24FK765Qdd5kk0zU/U4dEsCtN/GSiwNI9i8xsSVtjIAOdTaVhLwZ1nrbWxFVMxNDDl+9fednsOMsBw==} @@ -600,9 +637,6 @@ packages: uparser@0.2.1: resolution: {integrity: sha512-hTwK8e+bAaER8gJBOysoFGjRPsX/xOthSPR/ieI9EgTf1rts9ey25tbDXIU0rDr/cGcLsvkCo1n8J/eccfAvvw==} - upper-case@1.1.3: - resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -614,6 +648,25 @@ snapshots: '@jasminejs/reporters@1.0.0': {} + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -683,6 +736,8 @@ snapshots: acorn@8.11.3: {} + acorn@8.16.0: {} + balanced-match@4.0.4: {} basic-auth@2.0.1: @@ -721,17 +776,19 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - camel-case@3.0.0: + camel-case@4.1.2: dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 + pascal-case: 3.1.2 + tslib: 2.8.1 chalk@5.6.2: {} - clean-css@4.2.4: + clean-css@5.3.3: dependencies: source-map: 0.6.1 + commander@10.0.1: {} + commander@2.20.3: {} compressible@2.0.18: @@ -782,6 +839,11 @@ snapshots: depd@2.0.0: {} + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + dotenv@17.4.2: {} dunder-proto@1.0.1: @@ -794,6 +856,8 @@ snapshots: encodeurl@2.0.0: {} + entities@4.5.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -893,21 +957,19 @@ snapshots: dependencies: function-bind: 1.1.2 - he@1.2.0: {} - helmet@8.1.0: {} html-escaper@3.0.3: {} - html-minifier@4.0.0: + html-minifier-terser@7.2.0: dependencies: - camel-case: 3.0.0 - clean-css: 4.2.4 - commander: 2.20.3 - he: 1.2.0 - param-case: 2.1.1 + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 10.0.1 + entities: 4.5.0 + param-case: 3.0.4 relateurl: 0.2.7 - uglify-js: 3.17.4 + terser: 5.46.2 http-errors@2.0.1: dependencies: @@ -937,7 +999,9 @@ snapshots: glob: 13.0.6 jasmine-core: 6.2.0 - lower-case@1.1.4: {} + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 lru-cache@11.3.5: {} @@ -981,9 +1045,10 @@ snapshots: negotiator@1.0.0: {} - no-case@2.3.2: + no-case@3.0.4: dependencies: - lower-case: 1.1.4 + lower-case: 2.0.2 + tslib: 2.8.1 non-error@0.1.0: {} @@ -1005,12 +1070,18 @@ snapshots: dependencies: wrappy: 1.0.2 - param-case@2.1.1: + param-case@3.0.4: dependencies: - no-case: 2.3.2 + dot-case: 3.0.4 + tslib: 2.8.1 parseurl@1.3.3: {} + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + path-scurry@2.0.2: dependencies: lru-cache: 11.3.5 @@ -1136,8 +1207,17 @@ snapshots: source-map: 0.6.1 source-map-support: 0.5.21 + terser@5.46.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + toidentifier@1.0.1: {} + tslib@2.8.1: {} + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -1154,14 +1234,12 @@ snapshots: dependencies: csso: 4.2.0 html-escaper: 3.0.3 - html-minifier: 4.0.0 + html-minifier: html-minifier-terser@7.2.0 terser: 4.8.1 uhyphen: 0.1.0 umap: 1.0.2 uparser: 0.2.1 - uglify-js@3.17.4: {} - uhyphen@0.1.0: {} umap@1.0.2: {} @@ -1172,8 +1250,6 @@ snapshots: uparser@0.2.1: {} - upper-case@1.1.3: {} - vary@1.1.2: {} wrappy@1.0.2: {} From 5d7c182ab923eba9fb93bf9b698b8edb7c407098 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sat, 25 Apr 2026 19:35:33 +1000 Subject: [PATCH 27/29] fix: address Sid's review comments on PR #480 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract tryURL to utils/misc.ts as shared utility (Sid: "can import existing tryURL") - Change env.js helper from ??= to ||= so empty-string env vars get defaults (Sid) - Fix error message wording: "endpoint origin must be" → "expected" (Sid: "origin includes protocol") --- routes/github/lib/utils/rest.ts | 11 ++--------- tests/helpers/env.js | 4 ++-- tests/routes/github/lib/utils/rest.test.js | 16 ++++++++-------- utils/misc.ts | 8 ++++++++ 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/routes/github/lib/utils/rest.ts b/routes/github/lib/utils/rest.ts index b54f545d..7342626b 100644 --- a/routes/github/lib/utils/rest.ts +++ b/routes/github/lib/utils/rest.ts @@ -1,20 +1,13 @@ import { getToken, updateRateLimit, RateLimit } from "./tokens.js"; +import { tryURL } from "../../../../utils/misc.js"; const GITHUB_API_ORIGIN = "https://api.github.com"; -function tryURL(url: string) { - try { - return new URL(url); - } catch { - return null; - } -} - function assertGitHubAPIUrl(url: string) { const parsed = tryURL(url); if (parsed?.protocol !== "https:" || parsed.origin !== GITHUB_API_ORIGIN) { throw new Error( - `requestData: endpoint origin must be ${GITHUB_API_ORIGIN}, got ${parsed?.origin ?? "invalid URL"}`, + `requestData: expected ${GITHUB_API_ORIGIN}, got ${parsed?.origin ?? "invalid URL"}`, ); } } diff --git a/tests/helpers/env.js b/tests/helpers/env.js index cf0c15af..26f7bf85 100644 --- a/tests/helpers/env.js +++ b/tests/helpers/env.js @@ -2,5 +2,5 @@ import os from "node:os"; // Set required env vars only when not already configured, // so local developer settings are preserved during test runs. -process.env.GH_TOKEN ??= "test-token"; -process.env.DATA_DIR ??= os.tmpdir(); +process.env.GH_TOKEN ||= "test-token"; +process.env.DATA_DIR ||= os.tmpdir(); diff --git a/tests/routes/github/lib/utils/rest.test.js b/tests/routes/github/lib/utils/rest.test.js index f5247dbc..5a20bd96 100644 --- a/tests/routes/github/lib/utils/rest.test.js +++ b/tests/routes/github/lib/utils/rest.test.js @@ -4,49 +4,49 @@ describe("routes/github/lib/utils/rest - requestData", () => { it("throws for a non-GitHub API endpoint", async () => { const gen = requestData("https://evil.example.com/steal"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint origin must be https:\/\/api\.github\.com/, + /expected https:\/\/api\.github\.com/, ); }); it("throws for an http (non-https) GitHub URL", async () => { const gen = requestData("http://api.github.com/repos/foo/bar"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint origin must be https:\/\/api\.github\.com/, + /expected https:\/\/api\.github\.com/, ); }); it("throws for a GitHub URL that isn't the API subdomain", async () => { const gen = requestData("https://github.com/speced/respec"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint origin must be https:\/\/api\.github\.com/, + /expected https:\/\/api\.github\.com/, ); }); it("throws for an empty string endpoint", async () => { const gen = requestData(""); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint origin must be https:\/\/api\.github\.com/, + /expected https:\/\/api\.github\.com/, ); }); it("throws for a URL with github.com as username (URL confusion)", async () => { const gen = requestData("https://api.github.com@evil.com/path"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint origin must be https:\/\/api\.github\.com/, + /expected https:\/\/api\.github\.com/, ); }); it("throws for a blob: URL with matching origin", async () => { const gen = requestData("blob:https://api.github.com/some-uuid"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint origin must be https:\/\/api\.github\.com/, + /expected https:\/\/api\.github\.com/, ); }); it("throws for a data: URL", async () => { const gen = requestData("data:text/html,

hi

"); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint origin must be https:\/\/api\.github\.com/, + /expected https:\/\/api\.github\.com/, ); }); @@ -84,7 +84,7 @@ describe("routes/github/lib/utils/rest - requestData", () => { const gen = requestData("https://api.github.com/repos/w3c/respec/issues"); await gen.next(); await expectAsync(gen.next()).toBeRejectedWithError( - /endpoint origin must be https:\/\/api\.github\.com/, + /expected https:\/\/api\.github\.com/, ); } finally { globalThis.fetch = original; diff --git a/utils/misc.ts b/utils/misc.ts index 2564c701..6bf2bb51 100644 --- a/utils/misc.ts +++ b/utils/misc.ts @@ -55,3 +55,11 @@ export class HTTPError extends Error { super(message); } } + +export function tryURL(url: string, base?: string) { + try { + return new URL(url, base); + } catch { + return null; + } +} From 44be310c34ee0f5eb47fbb9697d8bf7d67f10ab0 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sat, 25 Apr 2026 19:38:37 +1000 Subject: [PATCH 28/29] refactor: replace tryURL with native URL.parse() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 22+ provides URL.parse() which returns null on invalid input instead of throwing — exactly what tryURL did. Remove the custom helper from both utils/misc.ts and utils/logging.ts. --- routes/github/lib/utils/rest.ts | 3 +-- utils/logging.ts | 15 +++------------ utils/misc.ts | 8 -------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/routes/github/lib/utils/rest.ts b/routes/github/lib/utils/rest.ts index 7342626b..b7b687a0 100644 --- a/routes/github/lib/utils/rest.ts +++ b/routes/github/lib/utils/rest.ts @@ -1,10 +1,9 @@ import { getToken, updateRateLimit, RateLimit } from "./tokens.js"; -import { tryURL } from "../../../../utils/misc.js"; const GITHUB_API_ORIGIN = "https://api.github.com"; function assertGitHubAPIUrl(url: string) { - const parsed = tryURL(url); + const parsed = URL.parse(url); if (parsed?.protocol !== "https:" || parsed.origin !== GITHUB_API_ORIGIN) { throw new Error( `requestData: expected ${GITHUB_API_ORIGIN}, got ${parsed?.origin ?? "invalid URL"}`, diff --git a/utils/logging.ts b/utils/logging.ts index 6a824714..b1f92741 100644 --- a/utils/logging.ts +++ b/utils/logging.ts @@ -33,22 +33,13 @@ const prettyJSON = (() => { .join(" "); })(); -const tryURL = (url?: string, base?: string) => { - try { - if (!url) return null; - return new URL(url, base); - } catch { - return null; - } -}; - const formatter: FormatFn = (tokens, req, res) => { const date = tokens.date(req, res, "iso"); const remoteAddr = tokens["remote-addr"](req, res); const method = tokens.method(req, res); const status = parseInt(tokens.status(req, res) || "", 10); - const url = tryURL(tokens.url(req, res)!, "https://respec.org/")!; - const referrer = tryURL(tokens.referrer(req, res)); + const url = URL.parse(tokens.url(req, res)!, "https://respec.org/")!; + const referrer = URL.parse(tokens.referrer(req, res) ?? ""); const contentLength = res.getHeader("content-length") as number | undefined; const responseTime = tokens["response-time"](req, res); const locals = Object.keys(res.locals).length ? { ...res.locals } : null; @@ -90,7 +81,7 @@ const skipCommon = (req: Request, res: Response) => { const { method, query } = req; const { statusCode } = res; const ref = req.get("referer") || req.get("referrer"); - const referrer = tryURL(ref); + const referrer = URL.parse(ref ?? ""); return ( // successful pre-flight requests diff --git a/utils/misc.ts b/utils/misc.ts index 6bf2bb51..2564c701 100644 --- a/utils/misc.ts +++ b/utils/misc.ts @@ -55,11 +55,3 @@ export class HTTPError extends Error { super(message); } } - -export function tryURL(url: string, base?: string) { - try { - return new URL(url, base); - } catch { - return null; - } -} From fe7452a18669491e55454954da6a5b65af22623d Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sat, 25 Apr 2026 19:45:33 +1000 Subject: [PATCH 29/29] refactor: inline express-rate-limit import, remove wrapper Delete utils/rate-limit.ts (was just a re-export). Import express-rate-limit directly so CodeQL can trace the rate limiter. --- routes/respec/index.ts | 2 +- tests/utils/rate-limit.test.js | 2 +- utils/rate-limit.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 utils/rate-limit.ts diff --git a/routes/respec/index.ts b/routes/respec/index.ts index a86c491f..4d992d9e 100644 --- a/routes/respec/index.ts +++ b/routes/respec/index.ts @@ -2,7 +2,7 @@ import path from "node:path"; import express from "express"; import { env, ms } from "../../utils/misc.js"; -import { rateLimit } from "../../utils/rate-limit.js"; +import rateLimit from "express-rate-limit"; import authGithubWebhook from "../../utils/auth-github-webhook.js"; import * as sizeRoute from "./size.js"; diff --git a/tests/utils/rate-limit.test.js b/tests/utils/rate-limit.test.js index 4207145d..f9e2096a 100644 --- a/tests/utils/rate-limit.test.js +++ b/tests/utils/rate-limit.test.js @@ -1,4 +1,4 @@ -import { rateLimit } from "../../build/utils/rate-limit.js"; +import rateLimit from "express-rate-limit"; function makeReq(ip = "127.0.0.1") { return { ip, headers: {}, method: "GET", url: "/" }; diff --git a/utils/rate-limit.ts b/utils/rate-limit.ts deleted file mode 100644 index 8dfb0486..00000000 --- a/utils/rate-limit.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as rateLimit } from "express-rate-limit";