Coherent 5-edit Wave 1 GREEN landing per Plan 04-02 Task 2; RED gate from
Task 1 (`tests/build/no-new-function-in-sw-chunk.test.ts` 1-hit assertion)
flips GREEN with 0 hits of `new Function` in any SW chunk
(`dist/assets/index.ts-*.js` glob).
## Threat T-04-02-01 mitigation (Elevation of Privilege — `new Function` literal)
Three layered mechanisms cooperate to drop the CSP-unsafe `new Function`
literal from the SW chunk while preserving JSZip's zip-assembly correctness
end-to-end (REVISION iter-2 WARNING 1 empirically pinned at UAT harness 33/33):
1. **Runtime polyfill prelude** at top-of-module of `src/background/index.ts`
(BEFORE the first `import`): an inline `queueMicrotask`-based polyfill
installs `globalThis.setImmediate` at SW boot. JSZip's pre-bundled
`dist/jszip.min.js` IIFE guards its internal setimmediate polyfill behind
`if(!s.setImmediate){...}`, so the upstream offending body never executes
at runtime once our prelude has installed the safe fast-path.
2. **`vite-plugin-node-polyfills` `exclude: ['setimmediate']`** in vite.config.ts:
prevents the plugin from injecting its node-stdlib-browser-aliased
setimmediate polyfill into the chunk. NOTE: this alone is insufficient
because JSZip's `dist/jszip.min.js` ships its OWN bundled-in setimmediate
(via the package.json `"browser"` field that maps `./lib/index` →
`./dist/jszip.min.js`); the plugin's `exclude` only filters the plugin's
own contributions.
3. **`resolve.alias.setimmediate`** redirects bare-specifier `setimmediate`
requires to `src/shared/setimmediate-stub.ts` (a 22-LOC TS module that
installs the same `queueMicrotask`-based polyfill via side-effect import).
This catches any future direct `import 'setimmediate'` consumer that
bypasses the prelude.
4. **`stripSetimmediateNewFunction()` Rollup post-transform plugin** in
vite.config.ts: surgically replaces the single occurrence of
`(I=new Function(""+I))` with `(I=function(){})` in any output chunk
that contains the JSZip-bundled setimmediate IIFE. The replacement is
observably equivalent in our codepath (the parent `typeof I!="function"&&`
guard means the body never runs when I is already a function — which is
the only form JSZip ever uses — AND the runtime prelude makes the entire
IIFE body unreachable regardless). Without this plugin, JSZip's
pre-bundled distribution embeds the upstream setimmediate package's
`setImmediate.js` verbatim inside its internal CJS module registry
(slot 54), unreachable by Vite's resolve.alias or the polyfill plugin's
exclude.
## Architecture decision log
**Option α (force JSZip unbundled `lib/index.js` via `resolve.alias.jszip`)
was attempted and reverted 2026-05-21** (between commits 630d40c and this).
Empirically broke UAT harness A30+ because the unbundled entry's transitive
readable-stream-browser browser-field mapping did not propagate correctly
through Vite's resolver — the async zip-write pipeline silently produced
an empty events.json. The post-transform plugin (Option β) is the
minimum-surface fix that preserves JSZip's runtime behavior verbatim while
satisfying the textual `new Function` count = 0 invariant.
## Verification
**Build / static gates:**
- `npm run build` exits 0; SW chunk `dist/assets/index.ts-DfBxWCT9.js`
(378.92 kB) contains 0 occurrences of `new Function` (was 1 in pre-fix
`index.ts-8LkXuqac.js`).
- `npx tsc --noEmit` exits 0.
- `grep -rn 'permissions.request' src/` returns 0 hits (Plan 04-02 ROADMAP
SC #4 regression pin GREEN).
- `node generate-icons.cjs` exits 0; old `generate-icons.js` no longer
exists (rename via `git mv` preserves history).
- `grep -c "exclude: \\['setimmediate'\\]" vite.config.ts` returns 1.
- `grep -c "queueMicrotask" src/background/index.ts` returns ≥1.
- `grep -c "Resolved in Phase 4 Plan 04-02" .planning/phases/01-stabilize-video-pipeline/deferred-items.md` returns ≥1.
**Test gates:**
- Focused: `npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run` → 3/3 GREEN (Task 1's RED gate flipped GREEN).
- Full vitest: 183/183 GREEN on the clean run (180 baseline + 3 net new
from Plan 04-02 Task 1's two new files). Pre-existing intermittent flakes
per 04-01-SUMMARY Issues Encountered (blob-url-download / webm-remux /
webm-playback ffmpeg dry-run) persist across SUMMARY runs and are owned
by Plan 04-03.
**Pre-checkpoint bundle gates (per saved memory feedback-pre-checkpoint-bundle-gates.md):**
1. Tier-1 FORBIDDEN_HOOK_STRINGS: 13/13 tests GREEN; inventory unchanged at
12 strings (Plan 04-02 added no harness hooks).
2. SW CSP-safety grep: `grep -rn 'new Function\\|eval(' dist/assets/` returns
0 hits — polarity flipped from the pre-existing 1 documented exception
(the setimmediate literal). T-04-02-01 mitigation pin lands.
3. Node-globals: `Buffer.copy / .isView / .length / .push / .shift / .slice
/ .write` in SW chunk (pre-existing JSZip internals; unchanged from
04-01-SUMMARY).
4. DOM-globals: `document.createElement / .createTextNode / .documentElement
/ .F` + `window.Math / .console / .localStorage / .process` (pre-existing
JSZip text encoder fallback paths; unchanged from 04-01-SUMMARY).
5. manifest.json: present, MV3, `name: __MSG_extName__` (chrome.i18n intact).
**Empirical UAT harness (REVISION iter-2 WARNING 1):**
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` → 33/33 assertions
passed (verbatim `UAT harness: 33/33 assertions passed` in stdout).
Confirms JSZip's full SAVE → zip pipeline (A24-A32 inclusive, exercising
the in-memory MediaRecorder segments + base64 port wire + remux + zip
assembly + chrome.downloads + events.json + meta.json + screenshot)
operates correctly under the new bundle. The setimmediate polyfill
replacement preserves zip-write behavior end-to-end at the empirical
layer.
## Files
- **vite.config.ts**: imports `node:url` (fileURLToPath/URL) + `Plugin`
type from vite; adds `nodePolyfills.exclude: ['setimmediate']`;
adds `resolve.alias.setimmediate` → `src/shared/setimmediate-stub.ts`;
adds `stripSetimmediateNewFunction()` Rollup post-transform plugin
with full rationale comment.
- **src/background/index.ts**: 17-line top-of-module prelude inserted
BEFORE the first `import { Logger } ...` line. Inline `queueMicrotask`-based
setimmediate polyfill with typed widening cast (no `as any` per
CLAUDE.md). Reversible by `git revert`.
- **src/shared/setimmediate-stub.ts** (NEW): 50-LOC TS module providing
the same `queueMicrotask`-based polyfill via side-effect import.
Documented as the resolve.alias target.
- **generate-icons.js → generate-icons.cjs**: `git mv` preserving history.
Node 14+ treats `.cjs` as CJS regardless of `package.json` "type":
"module" per https://nodejs.org/api/packages.html#determining-module-system.
No code change; `require('fs')` + `require('path')` resolve cleanly.
No other references to the old `.js` path elsewhere in the codebase
outside the `.planning/` audit trail.
- **.planning/phases/01-stabilize-video-pipeline/deferred-items.md**:
appended "Resolved in Phase 4 Plan 04-02" closure block citing this
commit; details the 4-mechanism layered mitigation; documents the
Option α attempt + reversion.
References:
- .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1
- .planning/phases/04-harden-clean-up-optional/04-PATTERNS.md
§vite.config.ts + §src/background/index.ts
- Plan 04-02 threat model T-04-02-01 (Elevation of Privilege) +
T-04-02-02 (DoS — JSZip fallback compatibility; verified by UAT 33/33)
- node_modules/jszip/lib/utils.js:7 (upstream `require("setimmediate")`)
- node_modules/setimmediate/setImmediate.js (upstream polyfill source)
- Plan 01-12 Wave 7 deferred-items.md disclosure (Phase 5 → Phase 4 target)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
8.3 KiB
TypeScript
171 lines
8.3 KiB
TypeScript
import { fileURLToPath, URL } from 'node:url';
|
||
|
||
import { defineConfig, type Plugin } from 'vite';
|
||
import { crx } from '@crxjs/vite-plugin';
|
||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||
import manifest from './manifest.json';
|
||
|
||
/**
|
||
* Plan 04-02 CSP hardening (RESEARCH §Q1, Option β — refined #3).
|
||
*
|
||
* Surgically excises the `new Function("" + I)` CSP-unsafe literal from JSZip's
|
||
* pre-bundled `dist/jszip.min.js` after Vite has rolled it into the SW chunk.
|
||
*
|
||
* Context: JSZip's `package.json` `"browser"` field maps `./lib/index` →
|
||
* `./dist/jszip.min.js`, a pre-bundled CJS distribution that ships its own
|
||
* internal CommonJS module registry (including the `setimmediate` polyfill in
|
||
* slot 54). The pre-bundled form bypasses BOTH:
|
||
* - `vite-plugin-node-polyfills` `exclude: ['setimmediate']` (which only
|
||
* filters `node-stdlib-browser`-aliased polyfills);
|
||
* - Our `resolve.alias.setimmediate` (which only intercepts bare-specifier
|
||
* `require("setimmediate")` calls, not jszip's internal `r("setimmediate")`
|
||
* against its own slot table).
|
||
*
|
||
* Alternative considered (Option α — force JSZip's unbundled `lib/index.js`
|
||
* entry via `resolve.alias.jszip`): tested empirically 2026-05-21 against the
|
||
* UAT harness; broke A30+ assertions because JSZip's async-write pipeline
|
||
* (readable-stream-browser) was not transitively wired correctly through Vite's
|
||
* resolver. Reverted.
|
||
*
|
||
* The runtime IS already CSP-safe under the current bundle: the SW chunk's
|
||
* top-of-module `queueMicrotask`-based polyfill prelude lands BEFORE JSZip is
|
||
* imported, so the setimmediate IIFE's `if(!s.setImmediate){...}` guard skips
|
||
* the offending body at runtime. The `new Function` literal becomes statically
|
||
* present but runtime-unreachable. This plugin removes the static literal so
|
||
* the build-gate `tests/build/no-new-function-in-sw-chunk.test.ts` passes
|
||
* AND no future static-analysis auditor (or tightened MV3 CSP) flags it.
|
||
*
|
||
* Replacement: `(I=new Function(""+I))` → `(I=function(){})` — a no-op IIFE.
|
||
* Since the parent `typeof I!="function"&&` guard predicates the assignment on
|
||
* I being a non-function, and our prelude ensures the entire IIFE body never
|
||
* executes, the replacement is observably equivalent in our codepath.
|
||
*
|
||
* Why not the empty arrow `()=>{}`: keeps the same `function` keyword family
|
||
* as the surrounding code (mangler-friendly + bytecount-parity).
|
||
*
|
||
* Scope: applies ONLY to chunks with `setImmediate`-pattern content (a guarded
|
||
* check before the substring replacement avoids touching unrelated chunks).
|
||
*
|
||
* References:
|
||
* - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1
|
||
* - .planning/phases/04-harden-clean-up-optional/04-02-PLAN.md threat model
|
||
* T-04-02-01 (Elevation of Privilege — `new Function` literal)
|
||
* - node_modules/jszip/lib/utils.js:7 (the upstream `require("setimmediate")` site)
|
||
* - node_modules/setimmediate/setImmediate.js (the upstream polyfill source)
|
||
*/
|
||
function stripSetimmediateNewFunction(): Plugin {
|
||
// Exact substring from `setimmediate` package's setImmediate.js, present
|
||
// verbatim in JSZip's pre-bundled output. The replacement is byte-shorter
|
||
// (no `new Function("" + I)` constructor invocation); we accept the size
|
||
// delta in favor of pinning a unique substring for the find-and-replace.
|
||
// If this string changes in a future JSZip / setimmediate upgrade, the
|
||
// build-gate test catches the regression (count goes back to 1).
|
||
const NEEDLE = '(I=new Function(""+I))';
|
||
const REPLACEMENT = '(I=function(){})';
|
||
|
||
return {
|
||
name: 'plan-04-02-strip-setimmediate-new-function',
|
||
enforce: 'post',
|
||
generateBundle(_options, bundle) {
|
||
for (const fileName of Object.keys(bundle)) {
|
||
const chunk = bundle[fileName];
|
||
if (chunk.type !== 'chunk') continue;
|
||
if (!chunk.code.includes(NEEDLE)) continue;
|
||
chunk.code = chunk.code.split(NEEDLE).join(REPLACEMENT);
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
crx({
|
||
manifest,
|
||
contentScripts: {
|
||
injectCss: false,
|
||
},
|
||
}),
|
||
nodePolyfills({
|
||
include: ['buffer'],
|
||
// Plan 04-02 CSP hardening (RESEARCH §Q1 + Plan 01-12 deferred-items flip):
|
||
// exclude the transitive `setimmediate` polyfill that vite-plugin-node-polyfills
|
||
// bundles alongside `buffer`. NOTE: this only filters node-stdlib-browser-aliased
|
||
// polyfills; JSZip's direct `require("setimmediate")` in its pre-bundled
|
||
// dist/jszip.min.js bypasses this exclusion. See stripSetimmediateNewFunction()
|
||
// plugin below for the post-bundle excision of the JSZip-side literal.
|
||
exclude: ['setimmediate'],
|
||
globals: {
|
||
Buffer: true,
|
||
global: false,
|
||
process: false,
|
||
},
|
||
protocolImports: false,
|
||
}),
|
||
// Plan 04-02 Wave 1 — post-bundle excision of the JSZip-internal
|
||
// setimmediate polyfill's `new Function("" + I)` literal. See the plugin
|
||
// definition + multi-paragraph rationale above (file top).
|
||
stripSetimmediateNewFunction(),
|
||
],
|
||
resolve: {
|
||
alias: {
|
||
ebml: 'ebml/lib/ebml.js',
|
||
// Plan 04-02 CSP hardening (RESEARCH §Q1, refined): redirect the
|
||
// upstream `setimmediate` npm package (which contains a CSP-unsafe
|
||
// `new Function("" + I)` fallback for the never-called string-form
|
||
// `setImmediate(string)`) to a local stub that installs a safe
|
||
// `queueMicrotask`-based polyfill. JSZip's `require("setimmediate")`
|
||
// at `node_modules/jszip/lib/utils.js:7` bypasses
|
||
// vite-plugin-node-polyfills' `exclude` option, so the alias is the
|
||
// canonical interception point. See src/shared/setimmediate-stub.ts
|
||
// for the polyfill source + threat-mitigation rationale (T-04-02-01).
|
||
// Pinned by tests/build/no-new-function-in-sw-chunk.test.ts (1 → 0).
|
||
setimmediate: fileURLToPath(new URL('./src/shared/setimmediate-stub.ts', import.meta.url)),
|
||
},
|
||
},
|
||
// `define` text-replaces token symbols at bundle time. We declare
|
||
// __MOKOSH_UAT__ as `false` in the production config so the gated
|
||
// hook-import branches in src/background/index.ts + src/offscreen/
|
||
// recorder.ts are static dead branches that Rollup tree-shakes. The
|
||
// test-only build (vite.test.config.ts) overrides this to `true`. We
|
||
// chose a dedicated token rather than gating on import.meta.env.MODE
|
||
// because vitest also uses MODE='test' by default — gating on MODE
|
||
// would activate the hooks during unit tests and overwrite the
|
||
// vi.fn() chrome.* mocks the existing 83-test baseline relies on.
|
||
// Reference: https://vite.dev/config/shared-options.html#define
|
||
define: {
|
||
__MOKOSH_UAT__: 'false',
|
||
// Plan 01-12 Wave 5 (RESEARCH §12 + D-09 spirit-satisfaction):
|
||
// Defensive token reserved for any future inline smoke-mode check.
|
||
// Currently the smoke harness lives entirely in `smoke.sh` outside
|
||
// Vite's input set — verified by `grep -rn 'smoke\|SMOKE\|data:text/html' src/`
|
||
// returning empty. Activated by setting the env var `VITE_DEV=1`
|
||
// before invoking `vite` / `vite build`; defaults to `false` so the
|
||
// gated branch (if any future plan adds one) is statically tree-
|
||
// shaken out of production. See scripts/README.md for the wider
|
||
// dev-script isolation invariant.
|
||
__VITE_DEV__: JSON.stringify(process.env.VITE_DEV === '1'),
|
||
},
|
||
build: {
|
||
// Plan 01-11: bump from default ES2020 to ES2022 so gated top-level
|
||
// await (`if (__MOKOSH_UAT__) { await import(...); }` in
|
||
// src/background/index.ts + src/offscreen/recorder.ts) compiles.
|
||
// The extension targets MV3 (Chrome ≥88); top-level await landed in
|
||
// Chrome 89 / Edge 89 / Firefox 89 / Safari 15 per MDN — comfortably
|
||
// inside the MV3 compatibility envelope.
|
||
// Reference: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/await#browser_compatibility
|
||
// Reference: https://vite.dev/config/build-options.html#build-target
|
||
target: 'es2022',
|
||
rollupOptions: {
|
||
input: {
|
||
offscreen: 'src/offscreen/index.html',
|
||
// Plan 01-10 D-17-onboarding: welcome page bundled into both
|
||
// production and test builds. The page is reached via
|
||
// chrome.runtime.getURL('src/welcome/welcome.html') from the
|
||
// SW's openWelcomeIfFirstInstall helper; it requires the
|
||
// manifest's web_accessible_resources block to be navigable.
|
||
welcome: 'src/welcome/welcome.html',
|
||
},
|
||
},
|
||
},
|
||
});
|