From 81e6069b77fe554481ca7f129866362a2b6b6fa6 Mon Sep 17 00:00:00 2001 From: sbc1-code <207095575+sbc1-code@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:05:59 -0700 Subject: [PATCH] fix(deps): use the Typography selector-parser security backport --- docs/REFRESH-OPERATIONS.md | 35 +++++++++++++++ package-lock.json | 26 ++++++----- package.json | 8 +++- scripts/test-typography-parser.mjs | 70 ++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 scripts/test-typography-parser.mjs diff --git a/docs/REFRESH-OPERATIONS.md b/docs/REFRESH-OPERATIONS.md index c885101c..026b4e2a 100644 --- a/docs/REFRESH-OPERATIONS.md +++ b/docs/REFRESH-OPERATIONS.md @@ -336,6 +336,41 @@ and this performance follow-up. Version 6.1.5 is the CSS parser, not Router. An inverse-patch check against main passed with a temporary index; no rollback was executed. Avoid rolling the whole site back to an older data bundle. +## Local Typography parser backport candidate — 2026-10-23 UTC + +This local candidate scopes an npm override to `@tailwindcss/typography` only: +its parser changes from 6.0.10 to **6.1.5**. Typography stays at 0.5.19; no other +package or dependency edge changes. npm hoists the replacement from the nested +Typography directory to `node_modules/postcss-selector-parser`. This is a local +review candidate, not a production publication receipt. + +The [maintainer advisory](https://github.com/postcss/postcss-selector-parser/security/advisories/GHSA-rj75-hqrm-r3gf) +and [6.1.5 release](https://github.com/postcss/postcss-selector-parser/releases/tag/6.1.5) +identify the security backport for the 6.x line. The separate +[reviewed advisory range](https://github.com/advisories/GHSA-rj75-hqrm-r3gf) +still says `<7.1.6`, so npm may keep reporting the two moderate package entries. +Keep those findings visible: no advisory exception, audit threshold change, +forced downgrade, or unrelated 7.x migration is part of this candidate. + +The inspected consumer is the CSS build plugin in `src/index.css`, parsing +repository sources. No browser/API import or visitor-controlled selector +parsing endpoint was found. That limited review does not establish zero risk +for future consumers or other dependencies. + +`npm run test:css-parser` resolves the parser through Typography itself, checks +the patched version and pseudo-element transformations, and parses a flat +400KB class/id selector in a heap-limited worker with a five-second deadline. +The old parser fails that bounded control; the backport completes it. The test +stops only its own worker if parsing regresses, without leaving a blocked test +process. It is also part of the full `npm test` gate. + +For review or a future parent upgrade, perform a clean `npm ci`, build and full +test suite; compare generated CSS against the same source/data input before +the override. Recheck the actual Typography resolution and advisory metadata. +Remove this override only when the parent itself supplies a patched compatible +parser, then repeat the output and route checks. Preserve the existing JS/CSS +budgets and the dated historical review below. + ## Bounded dependency review — 2026-10-22 UTC Official npm audit reports **eight moderate package entries**, zero high or diff --git a/package-lock.json b/package-lock.json index 16c9fc52..e2e1e09a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3400,18 +3400,6 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, - "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { - "version": "6.0.10", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -5860,6 +5848,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", diff --git a/package.json b/package.json index 77d53dab..80269775 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "test": "npm run test:ads && npm run test:writer && npm run test:refresh && npm run test:publish && npm run test:hours && npm run test:trust && npm run test:mdx && npm run test:metadata && npm run test:audit && npm run test:bundle && npm run test:routes", + "test": "npm run test:ads && npm run test:writer && npm run test:refresh && npm run test:publish && npm run test:hours && npm run test:trust && npm run test:mdx && npm run test:css-parser && npm run test:metadata && npm run test:audit && npm run test:bundle && npm run test:routes", "test:metadata": "node --test scripts/test-faq-scope.mjs scripts/test-hreflang.mjs scripts/test-crossing-snapshot.mjs", "test:ads": "node --test scripts/test-ad-slot-diagnostics.mjs && node scripts/check-google-ads.mjs", "test:publish": "node --test scripts/test-publish-data.mjs scripts/test-public-crossings.mjs", @@ -37,6 +37,7 @@ "test:audit": "node scripts/check-audit.mjs", "test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.mjs scripts/test-browser-state.mjs scripts/test-historical-pattern.mjs scripts/test-router-navigation.mjs", "test:mdx": "node --test scripts/test-mdx-frontmatter.mjs", + "test:css-parser": "node --test scripts/test-typography-parser.mjs", "test:writer": "node --test scripts/test-generated-writer.mjs", "test:refresh": "node --test scripts/test-data-refresh.mjs" }, @@ -113,5 +114,10 @@ "tailwindcss": "^4.3.3", "typescript": "^5.8.2", "vite": "^6.4.4" + }, + "overrides": { + "@tailwindcss/typography": { + "postcss-selector-parser": "6.1.5" + } } } diff --git a/scripts/test-typography-parser.mjs b/scripts/test-typography-parser.mjs new file mode 100644 index 00000000..e6133f05 --- /dev/null +++ b/scripts/test-typography-parser.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { Worker } from 'node:worker_threads'; + +// Resolve the parser used by the CSS plugin, including a nested dependency. +const require = createRequire(import.meta.url); +const typographyRequire = createRequire(require.resolve('@tailwindcss/typography/package.json')); +const parserPath = typographyRequire.resolve('postcss-selector-parser'); +const version = typographyRequire('postcss-selector-parser/package.json').version; +const utils = typographyRequire('./src/utils.js'); + +test('Typography resolves a version patched for GHSA-rj75-hqrm-r3gf', () => { + assert.match(version, /^\d+\.\d+\.\d+$/); + const [major, minor, patch] = version.split('.').map(Number); + const patched = (major === 6 && (minor > 1 || (minor === 1 && patch >= 5))) + || major > 7 || (major === 7 && (minor > 1 || (minor === 1 && patch >= 6))); + assert.ok(patched, 'Typography actually resolves vulnerable parser ' + version); +}); + +test('Typography preserves pseudo-element and selector-list transformations', () => { + const cases = [ + ['.alpha::before', ['::before', '.alpha']], + ['.alpha::before, .beta::before', ['::before', '.alpha, .beta']], + ['.alpha:hover', [null, '.alpha:hover']], + ['.alpha:not(.beta)::after', ['::after', '.alpha:not(.beta)']], + ['[data-state="open"] > a::before', ['::before', '[data-state="open"] > a']], + ['section > p::before, section > a::after', [null, 'section > p::before, section > a::after']], + ]; + for (const [selector, expected] of cases) { + assert.deepEqual(utils.commonTrailingPseudos(selector), expected); + } +}); + +test('flat class/id selectors parse within a bounded worker deadline', { timeout: 8_000 }, async t => { + // The advisory uses a flat 400KB selector: nesting guards cannot protect it. + // Bound both time and heap; a regression stops only this test's own worker. + const source = [ + "const { parentPort, workerData } = require('node:worker_threads');", + "const { performance } = require('node:perf_hooks');", + 'const parser = require(workerData.parserPath);', + "const selector = '.a#b'.repeat(100_000);", + 'const started = performance.now();', + 'const ast = parser().astSync(selector);', + 'parentPort.postMessage({ nodes: ast.first.nodes.length,', + ' roundTrip: ast.toString() === selector, elapsedMs: performance.now() - started });', + ].join('\n'); + const worker = new Worker(source, { + eval: true, + workerData: { parserPath }, + resourceLimits: { maxOldGenerationSizeMb: 256, maxYoungGenerationSizeMb: 32 }, + }); + let deadline; + try { + const result = await new Promise((resolve, reject) => { + deadline = setTimeout(() => reject(new Error('Flat selector parsing exceeded the 5-second safety deadline')), 5_000); + worker.once('message', resolve); + worker.once('error', reject); + worker.once('exit', code => { + if (code !== 0) reject(new Error('Parser worker exited with code ' + code)); + }); + }); + assert.equal(result.nodes, 200_000); + assert.equal(result.roundTrip, true); + t.diagnostic('Parser ' + version + ': 200000 selector nodes in ' + Math.round(result.elapsedMs) + 'ms'); + } finally { + clearTimeout(deadline); + await worker.terminate(); + } +});