From 8a54b331e37617df587a27ae2cc6433584c6ad70 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 18 Aug 2026 23:24:22 +0200 Subject: [PATCH] fix(deps): clear production npm advisories, fix sw.js caching regression Resolves the four advisories that reach the production dependency tree. The other 16 npm audit reports are devDependencies-only (Remotion, Puppeteer, postcss, the eslint/tsx toolchain) and never ship to users. - @fastify/static 9.1.3 -> 10.1.3 GHSA-8pvw-jcv7-9cmj (authz bypass via non-canonical URL paths). Covers <=10.1.1, so all of 9.x is affected and the fix exists only on the 10.x line. - find-my-way 9.6.0 -> 9.8.0 GHSA-c96f-x56v-gq3h (HTTP/2 DDoS) - fast-uri 3.1.2 -> 3.1.5 GHSA-v2hh-gcrm-f6hx (host confusion) - brace-expansion -> 5.0.9/1.1.18 GHSA-3jxr-9vmj-r5cp (expansion DoS) The last three are transitive and needed only a lockfile re-resolve, so no overrides were introduced. The @fastify/static major changes setHeaders' first argument from a Node ServerResponse to a FastifyReply. Two consequences: 1. res.setHeader() -> reply.header(). The v9 body throws TypeError from inside the plugin on every static request. 2. Precedence flips, silently. The callback used to write to the raw response and lose to the route's staged reply headers; it now writes to the reply and wins. That gave /sw.js a year of immutable in place of the no-cache, no-store its route sets, pinning a service worker on every client with no server-side recovery. A route that already set Cache-Control now keeps it. Verified against v9 to confirm the sw.js behaviour is a regression and not a pre-existing bug. ws appears in npm audit but production is on 8.21.0, outside the vulnerable range; the only affected copy is bundled under @remotion/renderer (dev-only, and remotion is pinned at 4.0.473 because the compositor refuses to start on a version mismatch). Adds test/static-cache-headers.test.ts, which drives a real server and covers a caching contract that had no test at all. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/46d20534.md | 23 ++++++++ package-lock.json | 69 +++++++++++++--------- package.json | 2 +- src/web/server.ts | 20 +++++-- test/dependency-security.test.ts | 24 ++++++-- test/static-cache-headers.test.ts | 97 +++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 37 deletions(-) create mode 100644 .changeset/46d20534.md create mode 100644 test/static-cache-headers.test.ts diff --git a/.changeset/46d20534.md b/.changeset/46d20534.md new file mode 100644 index 00000000..0e81fc02 --- /dev/null +++ b/.changeset/46d20534.md @@ -0,0 +1,23 @@ +--- +"aicodeman": patch +--- + +Clear every production-reachable npm advisory, and fix a service-worker caching regression the upgrade exposed. + +`npm audit` reported 20 advisories, but 16 were devDependencies-only (Remotion, Puppeteer, postcss, the eslint/tsx toolchain) and never reached anyone installing the package. Four reached production and are now resolved: + +- **`@fastify/static` 9.1.3 to 10.1.3** — GHSA-8pvw-jcv7-9cmj, authorization bypass via non-canonical URL paths. The advisory covers `<=10.1.1`, so the entire 9.x line is affected and the fix only exists on 10.x. +- **`find-my-way` 9.6.0 to 9.8.0** — GHSA-c96f-x56v-gq3h (HTTP/2 DDoS). Not exploitable here since Codeman does not enable HTTP/2, fixed anyway. +- **`fast-uri` 3.1.2 to 3.1.5** — GHSA-v2hh-gcrm-f6hx, host confusion via a literal backslash authority delimiter. +- **`brace-expansion` to 5.0.9 / 1.1.18** — GHSA-3jxr-9vmj-r5cp, exponential-time expansion DoS. + +The last three were transitive and only needed a lockfile re-resolve; no `overrides` were added. + +The `@fastify/static` major changes the `setHeaders` callback's first argument from a Node `ServerResponse` to a `FastifyReply`, which required two fixes: + +- `res.setHeader()` became `reply.header()`. A v9-style body throws `TypeError: res.setHeader is not a function` from inside the plugin on every static request. +- **That change also flips precedence, silently.** The callback used to write to the raw response and be overwritten by the route's staged reply headers; it now writes to the reply and wins instead. That handed `/sw.js` a year of `immutable` in place of the `no-cache, no-store` its route sets, which would pin a service worker on every client with no server-side way to recover. A route that already set `Cache-Control` now keeps it. + +`ws` also appears in `npm audit` but production is already on 8.21.0, outside the vulnerable range; the only affected copy is bundled under `@remotion/renderer` and is dev-only. + +Adds `test/static-cache-headers.test.ts`, which drives a real server and covers the caching contract that had no test at all, and moves the floors in `test/dependency-security.test.ts` up to the patched versions. diff --git a/package-lock.json b/package-lock.json index 196b0beb..5079ac8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.0.0", - "@fastify/static": "^9.1.3", + "@fastify/static": "^10.1.3", "@fastify/websocket": "^11.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-serialize": "^0.14.0", @@ -1454,9 +1454,9 @@ } }, "node_modules/@fastify/static": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", - "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz", + "integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==", "funding": [ { "type": "github", @@ -1470,13 +1470,30 @@ "license": "MIT", "dependencies": { "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", "@fastify/send": "^4.0.0", - "content-disposition": "^1.0.1", - "fastify-plugin": "^5.0.0", + "content-disposition": "^2.0.1", + "fastify-plugin": "^6.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, + "node_modules/@fastify/static/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/websocket": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", @@ -4136,16 +4153,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -5078,9 +5095,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -5431,9 +5448,9 @@ } }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", "license": "MIT", "engines": { "node": ">=18" @@ -6552,9 +6569,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -6661,9 +6678,9 @@ } }, "node_modules/find-my-way": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", - "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.8.0.tgz", + "integrity": "sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6905,15 +6922,15 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { diff --git a/package.json b/package.json index a6eb1947..0e0884e6 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.0.0", - "@fastify/static": "^9.1.3", + "@fastify/static": "^10.1.3", "@fastify/websocket": "^11.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-serialize": "^0.14.0", diff --git a/src/web/server.ts b/src/web/server.ts index a2dd30c2..de27a19b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -781,19 +781,31 @@ export class WebServer extends EventEmitter { // Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively. // HTML must revalidate every time so browsers pick up new hashed filenames after deploys. - // cacheControl disabled so setHeaders has full control (fastify-static's reply.headers() overwrites setHeaders otherwise). + // cacheControl disabled so setHeaders owns Cache-Control for plain static assets. // preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression await this.app.register(fastifyStatic, { root: join(__dirname, 'public'), prefix: '/', cacheControl: false, preCompressed: true, - setHeaders: (res, path) => { + // ⚠️ @fastify/static v10 changed this callback's first argument from a Node + // `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and + // NOT `res.setHeader()`. A v9-style body throws TypeError on every static + // request, which is every page load. See the v10.0.0 release notes. + setHeaders: (reply, path) => { + // ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this + // callback wrote to the raw response and Fastify's staged reply headers then + // overwrote it, so a route that set its own Cache-Control before .sendFile() + // won. Under v10 the callback writes to the reply itself and now wins instead, + // which handed `/sw.js` a year of `immutable` in place of the `no-cache, + // no-store` its route asks for — a service worker that can never update. + // So: a route that already decided keeps its answer. + if (reply.getHeader('Cache-Control') !== undefined) return; // Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz if (path.includes('.html')) { - res.setHeader('Cache-Control', 'no-cache'); + reply.header('Cache-Control', 'no-cache'); } else { - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + reply.header('Cache-Control', 'public, max-age=31536000, immutable'); } }, }); diff --git a/test/dependency-security.test.ts b/test/dependency-security.test.ts index ec224e60..dc038607 100644 --- a/test/dependency-security.test.ts +++ b/test/dependency-security.test.ts @@ -90,10 +90,12 @@ function expectNoVulnerableBraceExpansion(lock: PackageLock): void { expect(versions, 'brace-expansion should be present in package-lock.json').not.toHaveLength(0); for (const version of versions) { const major = Number(version.split('.')[0]); + // GHSA-3jxr-9vmj-r5cp (exponential-time expansion DoS) covers <=1.1.17 || 3.0.0 - 5.0.8, + // which is why both live branches moved up rather than just the 5.x one. if (major === 1) { expect( - compareVersions(version, '1.1.13'), - `brace-expansion@${version} should be >= 1.1.13` + compareVersions(version, '1.1.18'), + `brace-expansion@${version} should be >= 1.1.18` ).toBeGreaterThanOrEqual(0); } else if (major === 4) { expect( @@ -101,7 +103,7 @@ function expectNoVulnerableBraceExpansion(lock: PackageLock): void { `brace-expansion@${version} should not remain on vulnerable 4.x` ).toBeGreaterThanOrEqual(0); } else if (major === 5) { - expect(compareVersions(version, '5.0.6'), `brace-expansion@${version} should be >= 5.0.6`).toBeGreaterThanOrEqual( + expect(compareVersions(version, '5.0.9'), `brace-expansion@${version} should be >= 5.0.9`).toBeGreaterThanOrEqual( 0 ); } @@ -113,7 +115,7 @@ describe('dependency security policy', () => { const rootPackage = readJson('package.json'); const xtermPackage = readJson('packages/xterm-zerolag-input/package.json'); - expect(rootPackage.dependencies?.['@fastify/static']).toBe('^9.1.3'); + expect(rootPackage.dependencies?.['@fastify/static']).toBe('^10.1.3'); expect(rootPackage.dependencies?.fastify).toBe('^5.8.5'); expect(rootPackage.dependencies?.uuid).toBe('^14.0.0'); expect(rootPackage.devDependencies?.['@remotion/cli']).toBe('4.0.473'); @@ -130,11 +132,21 @@ describe('dependency security policy', () => { expectEveryLockedVersionAtLeast(lock, 'vitest', '4.1.0'); expectEveryLockedVersionAtLeast(lock, '@vitest/coverage-v8', '4.1.0'); expectEveryLockedVersionAtLeast(lock, 'fastify', '5.8.5'); - expectEveryLockedVersionAtLeast(lock, '@fastify/static', '9.1.3'); + // GHSA-8pvw-jcv7-9cmj (authorization bypass via non-canonical URL paths) covers + // <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line. + expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2'); expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0'); expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0'); + // ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of + // GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion + // is pinned to 4.0.473 on purpose (the compositor refuses to start on a version + // mismatch). That copy is devDependencies-only and never ships to users. expectEveryLockedVersionAtLeast(lock, 'ws', '8.20.1'); - expectEveryLockedVersionAtLeast(lock, 'fast-uri', '3.1.2'); + // GHSA-v2hh-gcrm-f6hx (host confusion via literal backslash authority delimiter) + // covers 3.0.0 - 3.1.4. + expectEveryLockedVersionAtLeast(lock, 'fast-uri', '3.1.5'); + // GHSA-c96f-x56v-gq3h (HTTP/2 DDoS) covers <=9.6.0. + expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0'); expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1'); expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2'); expectNoVulnerableBraceExpansion(lock); diff --git a/test/static-cache-headers.test.ts b/test/static-cache-headers.test.ts new file mode 100644 index 00000000..9bad8785 --- /dev/null +++ b/test/static-cache-headers.test.ts @@ -0,0 +1,97 @@ +/** + * @fileoverview `@fastify/static`'s `setHeaders` callback is what sets Cache-Control + * on every asset Codeman serves, and v10 silently changed its contract. + * + * In v9 the first argument was a Node `ServerResponse`, so the body called + * `res.setHeader(...)`. In v10 it is a `FastifyReply`, which has no `setHeader`, so + * the v9 body throws `TypeError: res.setHeader is not a function` from inside + * `@fastify/static` on EVERY static request. Nothing in the type-checker catches a + * revert (the callback's parameter is inferred), and nothing else in the suite reads + * these headers, so without this file the whole caching contract is untested. + * + * That contract is load-bearing: assets are served `immutable` for a year, and + * `index.html` must revalidate every time or a deploy leaves browsers on stale + * markup (see `cacheBustAssets` in server.ts). + * + * These tests drive a REAL WebServer on purpose. Asserting against an inline + * re-registration of the plugin would keep passing after a revert in server.ts. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { WebServer } from '../src/web/server.js'; + +const TEST_PORT = 3183; + +/** + * Fetch and fully drain the body. Both halves matter for teardown: an unconsumed + * body leaves undici holding the socket, and a pooled keep-alive socket makes + * `server.stop()` wait for it, which times out the afterAll hook. + */ +async function get(url: string): Promise { + const res = await fetch(url, { headers: { connection: 'close' } }); + await res.arrayBuffer(); + return res; +} + +describe('static asset Cache-Control headers', () => { + let server: WebServer; + let baseUrl: string; + + beforeAll(async () => { + server = new WebServer(TEST_PORT, false, true); + await server.start(); + baseUrl = `http://localhost:${TEST_PORT}`; + }); + + afterAll(async () => { + await server.stop(); + }, 60000); + + it('serves a static asset at all (proves setHeaders did not throw)', async () => { + // The v9-form regression surfaces here first: @fastify/static invokes setHeaders + // while streaming, so a TypeError inside it takes the response down rather than + // merely omitting a header. + const res = await get(`${baseUrl}/app.js`); + expect(res.status).toBe(200); + }); + + it('marks long-lived assets immutable', async () => { + const res = await get(`${baseUrl}/app.js`); + expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + }); + + it('makes static HTML revalidate so deploys are picked up', async () => { + // ⚠️ upload.html, NOT index.html. `/index.html` has its own explicit route that + // answers from renderIndexHtml() and never reaches @fastify/static, so asserting + // on it passes even with setHeaders fully broken (verified: reverting server.ts + // to the v9 form fails the two /app.js tests and leaves an index.html assertion + // green). upload.html has no route of its own, so it is the only HTML that + // actually exercises the `.html` branch of setHeaders. + const res = await get(`${baseUrl}/upload.html`); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-cache'); + }); + + it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => { + // Regression guard for the OTHER half of the v10 change. setHeaders runs for + // sendFile() too, and v10 moved it from the raw response onto the reply, which + // flipped precedence: the callback started overriding routes instead of being + // overridden by them. That gave /sw.js `immutable, max-age=31536000` in place of + // the `no-cache, no-store` its route sets — a service worker pinned for a year, + // which is unrecoverable from the server side because clients stop asking. + // Measured against v9 to confirm this is the historical behaviour, not a guess. + const res = await get(`${baseUrl}/sw.js`); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-cache, no-store'); + }); + + it('serves the rendered index with no-cache too, via its own route', async () => { + // Different code path from the above, same contract: index.html is generated per + // request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would + // pin browsers to the asset versions current at deploy time. + const res = await get(`${baseUrl}/index.html`); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toContain('no-cache'); + }); +});