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'); + }); +});