|
| 1 | +# ZeroProxy Phase 3 Plan: Strict Script Compatibility and Module Correctness |
| 2 | + |
| 3 | +Status: proposed. This plan is based on the current Phase 2 implementation plus the compatibility investigation performed after Phase 2. Existing unit, Go, and Puppeteer E2E tests pass, but they do not cover several script paths that still break compatibility or fail the strict rewrite boundary. |
| 4 | + |
| 5 | +Phase 3 is a clean cutover plan for script correctness. The goal is not to widen the network boundary. The goal is to make script execution predictable: every executable target script is either rewritten under the ZeroProxy membrane, resolved through the same-origin script API, or blocked with an explicit ZeroProxy error. |
| 6 | + |
| 7 | +## Goals |
| 8 | + |
| 9 | +- Close script-created inline execution gaps without executing unrewritten target code. |
| 10 | +- Make module resolution compatible with relative imports, absolute imports, bare specifiers, import maps, dynamic `import()`, and `import.meta.url`. |
| 11 | +- Fix rewriter lexical-scope correctness so local bindings are never mistaken for globals and globals are never missed. |
| 12 | +- Make strict-mode rewrite failures fail closed consistently across external scripts, inline scripts, event handlers, workers, and dynamic compilation paths. |
| 13 | +- Keep the transport invariant unchanged: target network, navigation, worker, and WebSocket traffic must stay inside `Service Worker -> Go WASM -> WebSocket/yamux -> SOCKS5 -> uTLS/HTTP`. |
| 14 | +- Add browser E2E coverage for the exact compatibility breakages listed below. |
| 15 | + |
| 16 | +## Non-goals |
| 17 | + |
| 18 | +- Full target Service Worker emulation. Target Service Worker registration remains blocked. |
| 19 | +- Anti-bot stealth or CAPTCHA bypass. |
| 20 | +- Perfect native origin impersonation. Native browser `Location` and the real address bar remain proxy-origin-backed. |
| 21 | +- Raw blob/data script execution without a rewritten or contained execution path. |
| 22 | +- Reintroducing native `fetch(event.request)` or native cross-origin script fallbacks. |
| 23 | + |
| 24 | +## Current compatibility failures and strict-boundary gaps |
| 25 | + |
| 26 | +### 1. Script-created inline scripts execute outside the rewrite pipeline |
| 27 | + |
| 28 | +Observed behavior: |
| 29 | + |
| 30 | +```js |
| 31 | +const s = document.createElement('script'); |
| 32 | +s.textContent = "window.__ran = { href: location.href }"; |
| 33 | +document.head.appendChild(s); |
| 34 | +``` |
| 35 | + |
| 36 | +The script runs. Inside that script, `location.href` observes the real proxy `/p/...#k=...` URL instead of the virtual target URL. Dynamic HTML sinks such as `innerHTML = '<script>...</script>'` are already made inert, but direct script element text is not rewritten or blocked before insertion. |
| 37 | + |
| 38 | +Affected code: |
| 39 | + |
| 40 | +- `web/runtime-prelude.js`: script URL laundering handles `script.src`, but insertion hooks do not block or rewrite script text. |
| 41 | +- `web/runtime-prelude.js`: dynamic HTML `transformHTML()` blocks string-created `<script>` tags only. |
| 42 | + |
| 43 | +Required Phase 3 behavior: |
| 44 | + |
| 45 | +- Before a script element can execute, classify it as one of: |
| 46 | + - external executable script with `src` -> launder through `/__zp/api/script`; |
| 47 | + - inline classic script -> rewrite synchronously or block; |
| 48 | + - inline module script -> rewrite synchronously or block; |
| 49 | + - non-executable script data type -> leave inert; |
| 50 | + - import map -> parse and register/rewrite as import-map metadata, not execute as code. |
| 51 | +- Patch insertion paths that can activate detached script nodes: `appendChild`, `insertBefore`, `replaceChild`, `append`, `prepend`, `before`, `after`, `replaceWith`, and equivalent document-fragment insertion. |
| 52 | +- Patch `HTMLScriptElement.text`, `textContent`, `innerText`, and child text node mutations where the browser would execute script content on insertion. |
| 53 | +- Prefer blocking over executing source when synchronous rewrite is unavailable. |
| 54 | + |
| 55 | +### 2. Bare module imports and import maps are currently broken |
| 56 | + |
| 57 | +Observed behavior: |
| 58 | + |
| 59 | +```js |
| 60 | +import React from 'react'; |
| 61 | +``` |
| 62 | + |
| 63 | +The rewriter turns this into a target-relative URL such as: |
| 64 | + |
| 65 | +```js |
| 66 | +import React from "/__zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Freact"; |
| 67 | +``` |
| 68 | + |
| 69 | +That is not browser module semantics. A bare specifier must be resolved by the page import map or fail as a browser module-resolution error. Blindly resolving it against the module URL breaks sites that depend on import maps, package-style specifiers, or build-system import-map shims. |
| 70 | + |
| 71 | +Affected code: |
| 72 | + |
| 73 | +- `web/js-rewriter.js`: `moduleSpecifier()` resolves every specifier with `new URL(specifier, moduleTargetURL)`. |
| 74 | +- `internal/htmltx/transform.go`: `type="importmap"` is not handled as a first-class module-resolution input. |
| 75 | + |
| 76 | +Required Phase 3 behavior: |
| 77 | + |
| 78 | +- Distinguish specifier classes: |
| 79 | + - relative-like: `./x.js`, `../x.js`, `/x.js`; |
| 80 | + - absolute URL: `https://...`, `http://...`; |
| 81 | + - special schemes: `data:`, `blob:`, `node:`, etc.; |
| 82 | + - bare: `react`, `@scope/pkg`, `pkg/subpath`. |
| 83 | +- Rewrite only relative-like and HTTP(S) absolute specifiers directly. |
| 84 | +- Preserve or resolve bare specifiers according to a parsed import map. Do not treat them as URL paths. |
| 85 | +- Transform import maps before module execution: |
| 86 | + - parse JSON safely; |
| 87 | + - rewrite mapped HTTP(S)/relative addresses to `/__zp/api/script?kind=module&u=...`; |
| 88 | + - preserve invalid import-map behavior as close to the browser as practical; |
| 89 | + - block import-map entries with executable or unsupported schemes. |
| 90 | +- Add tests for bare specifier with import map, bare specifier without import map, scoped import-map entries, and absolute/relative module imports. |
| 91 | + |
| 92 | +### 3. `import.meta.url` remains proxy API URL-backed |
| 93 | + |
| 94 | +Observed behavior: |
| 95 | + |
| 96 | +```js |
| 97 | +export const rel = new URL('./chunk.js', import.meta.url).href; |
| 98 | +``` |
| 99 | +
|
| 100 | +The rewriter leaves `import.meta.url` unchanged. Because rewritten modules execute from `/__zp/api/script?...`, code that resolves URLs against `import.meta.url` can resolve relative chunks against the proxy API URL instead of the original target module URL. |
| 101 | +
|
| 102 | +Required Phase 3 behavior: |
| 103 | +
|
| 104 | +- Rewriter must replace `import.meta.url` with the original target module URL string, or an equivalent immutable helper value. |
| 105 | +- `new URL('./chunk.js', import.meta.url)` must produce the original target-relative URL, then any subsequent module/script load must be routed through ZeroProxy. |
| 106 | +- Add E2E coverage for a module that creates a Worker and dynamic import from `new URL(..., import.meta.url)`. |
| 107 | +
|
| 108 | +### 4. Non-literal dynamic `import()` is not rewritten |
| 109 | +
|
| 110 | +Observed behavior: |
| 111 | +
|
| 112 | +```js |
| 113 | +export async function load(name) { |
| 114 | + return import('./chunks/' + name + '.js'); |
| 115 | +} |
| 116 | +``` |
| 117 | +
|
| 118 | +Literal dynamic imports are rewritten, but expression-based imports are left untouched. That can later route through the Service Worker with the wrong module kind or resolve against proxy-owned URLs. |
| 119 | +
|
| 120 | +Required Phase 3 behavior: |
| 121 | +
|
| 122 | +- Rewrite expression dynamic imports to a helper path, for example: |
| 123 | +
|
| 124 | +```js |
| 125 | +import(__zp_module_url(expr, originalModuleURL)) |
| 126 | +``` |
| 127 | +
|
| 128 | +- The helper must: |
| 129 | + - resolve relative-like and HTTP(S) absolute specifiers against the original target module URL; |
| 130 | + - consult the transformed import-map registry for bare specifiers; |
| 131 | + - return a same-origin `/__zp/api/script?kind=module&u=...` URL; |
| 132 | + - fail closed for unsupported schemes. |
| 133 | +- Service Worker classification must preserve module kind for module subresource fetches instead of falling back to classic script rewriting. |
| 134 | +
|
| 135 | +### 5. Rewriter lexical scoping mishandles `var` hoisting |
| 136 | +
|
| 137 | +Observed behavior: |
| 138 | +
|
| 139 | +```js |
| 140 | +function f(x) { |
| 141 | + if (x) { var location = { href: 'local' }; } |
| 142 | + return location.href; |
| 143 | +} |
| 144 | +``` |
| 145 | +
|
| 146 | +The current output rewrites the final `location` as global `location`, even though `var location` is function-scoped. This is a correctness bug, not only a compatibility bug. |
| 147 | +
|
| 148 | +Affected code: |
| 149 | +
|
| 150 | +- `web/js-rewriter.js`: scope collection treats block body declarations too uniformly and does not model `var` hoisting to the nearest function/program scope. |
| 151 | +
|
| 152 | +Required Phase 3 behavior: |
| 153 | +
|
| 154 | +- Implement a real scope model: |
| 155 | + - program scope; |
| 156 | + - function scope; |
| 157 | + - block scope; |
| 158 | + - catch scope; |
| 159 | + - class scope where relevant; |
| 160 | + - module import/export bindings; |
| 161 | + - `var` and function-declaration hoisting to function/program scope; |
| 162 | + - `let`/`const`/class bindings to block scope; |
| 163 | + - parameter and function-name scopes. |
| 164 | +- Add rewriter unit tests for shadowing across blocks, functions, loops, catch clauses, destructuring, imports, class names, and nested functions. |
| 165 | +- Fail closed only for unsupported syntax or ambiguous transformations, not for valid local-shadowing code. |
| 166 | + |
| 167 | +### 6. Inline classic and event-handler fallback is not strict fail-closed |
| 168 | + |
| 169 | +Current behavior: |
| 170 | + |
| 171 | +- External script rewrite failure returns a throwing script. |
| 172 | +- Inline module fallback returns a throwing script. |
| 173 | +- Inline classic script and event-handler fallback can execute original source wrapped in `__zp_runClassic` / `__zp_runEvent` when the OXC rewriter is unavailable. |
| 174 | + |
| 175 | +Affected code: |
| 176 | + |
| 177 | +- `internal/htmltx/transform.go`: `rewriteInlineScript()` and `rewriteEventHandler()` compatibility fallback. |
| 178 | +- `cmd/wasm-kernel/main.go`: `rewriteScript()` returns false when the JS rewriter is unavailable or fails. |
| 179 | + |
| 180 | +Required Phase 3 behavior: |
| 181 | + |
| 182 | +- Default strict path: inline classic scripts and event handlers must block when OXC rewrite fails. |
| 183 | +- If a compatibility mode is retained, it must be explicit, test-named, and documented as lower assurance. |
| 184 | +- The default E2E path must prove parse/rewrite failures do not execute original inline source. |
| 185 | + |
| 186 | +### 7. Compatibility passthrough allowlist remains an acceptance-boundary exception |
| 187 | + |
| 188 | +Current behavior: |
| 189 | + |
| 190 | +- `/__zp/api/script` bypasses rewriting for selected third-party challenge/tag-manager hosts. |
| 191 | +- This is not a parse-failure fallback, but it is still a strict-mode exception. |
| 192 | + |
| 193 | +Affected code: |
| 194 | + |
| 195 | +- `web/sw.js`: `shouldPassthroughScript()`. |
| 196 | + |
| 197 | +Required Phase 3 behavior: |
| 198 | + |
| 199 | +- Decide one strict default: |
| 200 | + - remove passthrough from strict mode; or |
| 201 | + - move passthrough behind an explicit compatibility policy flag with host/path allowlist tests. |
| 202 | +- Strict/high-assurance acceptance must not depend on passthrough scripts executing unrewritten. |
| 203 | + |
| 204 | +### 8. Worker script API compatibility remains partial |
| 205 | + |
| 206 | +Current behavior: |
| 207 | + |
| 208 | +- Worker `fetch` and `importScripts` are routed. |
| 209 | +- Worker XHR, WebSocket, EventSource, WebRTC/WebTransport, device APIs, and blob/data worker scripts are blocked or prototype-level. |
| 210 | + |
| 211 | +Required Phase 3 behavior: |
| 212 | + |
| 213 | +- Keep blocked APIs explicit and test-covered. |
| 214 | +- Add compatibility only where it can preserve the transport boundary: |
| 215 | + - worker XHR over `/__zp/api/fetch` if needed; |
| 216 | + - worker EventSource over fetch stream if needed; |
| 217 | + - worker WebSocket only through the existing `ZP_WS_OPEN`/`__zp_stream` path with per-tab capability. |
| 218 | +- Do not silently expose native worker networking. |
| 219 | + |
| 220 | +## Implementation sequence |
| 221 | + |
| 222 | +### Gate 0: Add failing fixtures first |
| 223 | + |
| 224 | +Add tests before changing behavior: |
| 225 | + |
| 226 | +- Rewriter unit tests: |
| 227 | + - `var` hoisting shadowing of `location`, `window`, `document`, `Function`, `WebSocket`; |
| 228 | + - bare module imports with and without import-map metadata; |
| 229 | + - `import.meta.url` replacement; |
| 230 | + - literal and expression dynamic `import()`; |
| 231 | + - inline module rewrite failure path. |
| 232 | +- Runtime unit/static tests: |
| 233 | + - script-created inline text is not executed unrewritten; |
| 234 | + - script insertion hooks cover all activation methods; |
| 235 | + - import-map scripts are not treated as executable classic/module scripts. |
| 236 | +- Browser E2E tests: |
| 237 | + - script-created inline `textContent` cannot observe proxy `/p/...#k=...` location; |
| 238 | + - module fixture using import map and `import.meta.url` loads through `/__zp/api/script` and observes target URL; |
| 239 | + - expression dynamic import loads target chunk through the script API; |
| 240 | + - malformed inline classic/event-handler source blocks in strict mode. |
| 241 | + |
| 242 | +### Gate 1: Runtime script element activation policy |
| 243 | + |
| 244 | +Update `web/runtime-prelude.js` so script elements are normalized immediately before activation. |
| 245 | + |
| 246 | +Required changes: |
| 247 | + |
| 248 | +- Add `prepareScriptElement(el)` and call it from every insertion hook before native insertion. |
| 249 | +- If `el.src` is executable, route through `setScriptSource()`. |
| 250 | +- If inline executable code exists, synchronously rewrite through the page-local rewriter when available; otherwise set an inert blocked type or replace content with a throwing strict block. |
| 251 | +- Preserve non-executable script data blocks, including JSON and import maps. |
| 252 | +- Ensure document fragments are recursively inspected before insertion. |
| 253 | +- Keep `document.currentScript` behavior as close as practical for rewritten inline scripts. |
| 254 | + |
| 255 | +### Gate 2: Import map and module resolver design |
| 256 | + |
| 257 | +Add a single module-resolution contract shared by the HTML transformer, runtime prelude, and Service Worker rewriter. |
| 258 | + |
| 259 | +Required changes: |
| 260 | + |
| 261 | +- Introduce an import-map parser/normalizer for target documents. |
| 262 | +- Transform static `<script type="importmap">` blocks in `internal/htmltx`. |
| 263 | +- Transform dynamic import-map insertions in `runtime-prelude.js` before the browser observes them. |
| 264 | +- Add a runtime helper for expression dynamic imports. |
| 265 | +- Extend `web/js-rewriter.js` with specifier classification instead of `new URL()` on every specifier. |
| 266 | +- Pass the original target module URL into every module rewrite operation and expose it to rewritten code for `import.meta.url` replacement. |
| 267 | + |
| 268 | +### Gate 3: Rewriter scope model rewrite |
| 269 | + |
| 270 | +Refactor `web/js-rewriter.js` scope handling before expanding more syntax rewrites. |
| 271 | + |
| 272 | +Required changes: |
| 273 | + |
| 274 | +- Separate declaration collection from expression rewrite traversal. |
| 275 | +- Model `var`/function hoisting to function/program scope. |
| 276 | +- Model lexical block bindings for `let`, `const`, class, catch, and import bindings. |
| 277 | +- Cover destructuring and default parameter initializers. |
| 278 | +- Avoid rewriting identifiers in declarations, property keys, labels, and type-like positions. |
| 279 | +- Keep replacement ordering deterministic and reject overlapping replacements that would produce invalid syntax. |
| 280 | + |
| 281 | +### Gate 4: Strict fail-closed inline policy |
| 282 | + |
| 283 | +Update the Go HTML transform and WASM kernel integration. |
| 284 | + |
| 285 | +Required changes: |
| 286 | + |
| 287 | +- Make strict inline classic/event-handler rewrite failure return a throwing script or inert handler, not `__zp_runClassic`/`__zp_runEvent` around original source. |
| 288 | +- If a compatibility mode remains, thread an explicit option from configuration to `htmltx.Options` and name it in tests. |
| 289 | +- Add safe ZeroProxy error diagnostics for blocked inline code without exposing source or secrets. |
| 290 | + |
| 291 | +### Gate 5: Service Worker script-kind preservation |
| 292 | + |
| 293 | +Update `web/sw.js` script APIs. |
| 294 | + |
| 295 | +Required changes: |
| 296 | + |
| 297 | +- Preserve module/classic/worker kind across `/__zp/api/script` and subresource classification. |
| 298 | +- Ensure dynamic-import generated API URLs always carry `kind=module`. |
| 299 | +- Remove or policy-gate `shouldPassthroughScript()` from strict mode. |
| 300 | +- Keep `scriptResponseHeaders()` strict: `nosniff`, `no-store`, and ZeroProxy CSP. |
| 301 | + |
| 302 | +### Gate 6: Worker compatibility boundary |
| 303 | + |
| 304 | +Document and test worker behavior after module fixes. |
| 305 | + |
| 306 | +Required changes: |
| 307 | + |
| 308 | +- Keep worker blocked APIs explicit. |
| 309 | +- Add worker module import and dynamic import coverage. |
| 310 | +- Only add worker XHR/EventSource/WebSocket compatibility if implemented through existing ZeroProxy APIs. |
| 311 | + |
| 312 | +## Verification plan |
| 313 | + |
| 314 | +Run these after each gate that changes behavior: |
| 315 | + |
| 316 | +```sh |
| 317 | +npm run test:js |
| 318 | +go test ./... |
| 319 | +npm run test:e2e |
| 320 | +``` |
| 321 | + |
| 322 | +Add targeted browser fixtures for: |
| 323 | + |
| 324 | +- script-created inline text; |
| 325 | +- dynamic document-fragment insertion containing scripts; |
| 326 | +- import map + bare module import; |
| 327 | +- `import.meta.url` relative chunk loading; |
| 328 | +- expression dynamic import; |
| 329 | +- inline parse failure strict block; |
| 330 | +- worker module import path; |
| 331 | +- passthrough-policy disabled strict mode. |
| 332 | + |
| 333 | +Verification claims must distinguish: |
| 334 | + |
| 335 | +- static policy coverage; |
| 336 | +- unit rewriter coverage; |
| 337 | +- Go HTML-transform coverage; |
| 338 | +- browser E2E runtime coverage; |
| 339 | +- strict-mode versus compatibility-mode behavior. |
| 340 | + |
| 341 | +## Acceptance criteria |
| 342 | + |
| 343 | +Phase 3 is accepted when all of the following are true: |
| 344 | + |
| 345 | +- A script element created with inline text cannot execute original target code outside the rewriter/membrane path. |
| 346 | +- All script activation paths either rewrite, launder, or block before browser execution. |
| 347 | +- Bare module specifiers are not blindly converted to target-relative URLs. |
| 348 | +- Import maps are transformed or honored well enough for bare specifier E2E fixtures. |
| 349 | +- `import.meta.url` in rewritten modules exposes the original target module URL semantics required by relative chunk loading. |
| 350 | +- Literal and expression dynamic imports load module chunks through `/__zp/api/script?kind=module&u=...` or fail closed for unsupported schemes. |
| 351 | +- Rewriter scope tests prove `var` hoisting and lexical shadowing do not cause global helper rewrites for local bindings. |
| 352 | +- Inline classic and event-handler rewrite failures fail closed in strict mode. |
| 353 | +- Strict mode does not depend on unrewritten third-party passthrough scripts. |
| 354 | +- Worker script imports remain routed through ZeroProxy, and blocked worker APIs are explicit in tests. |
| 355 | +- `npm run test:js`, `go test ./...`, and `npm run test:e2e` pass with the new fixtures. |
| 356 | + |
| 357 | +## Files expected to change |
| 358 | + |
| 359 | +- `web/js-rewriter.js` |
| 360 | +- `web/runtime-prelude.js` |
| 361 | +- `web/worker-prelude.js` |
| 362 | +- `web/sw.js` |
| 363 | +- `internal/htmltx/transform.go` |
| 364 | +- `internal/htmltx/transform_test.go` |
| 365 | +- `cmd/wasm-kernel/main.go` |
| 366 | +- `test/js/rewriter.test.js` |
| 367 | +- `test/js/static-policy.test.js` |
| 368 | +- `test/js/compat-pipeline.test.js` |
| 369 | +- `test/e2e/proxy.test.js` |
| 370 | +- `ARCHITECTURE.md` |
| 371 | + |
| 372 | +## Open design decisions |
| 373 | + |
| 374 | +1. Strict default for compatibility passthrough: |
| 375 | + - recommended: remove passthrough from strict mode and add an explicit lower-assurance compatibility flag if needed. |
| 376 | +2. Import-map implementation location: |
| 377 | + - recommended: parse and rewrite import maps in both static HTML transform and runtime dynamic insertion path, with one shared JS resolver contract for module rewriting. |
| 378 | +3. Inline script rewrite location: |
| 379 | + - recommended: use the already-loaded page-local OXC rewriter for runtime-created inline scripts; block when unavailable. |
| 380 | +4. Worker API parity: |
| 381 | + - recommended: keep worker XHR/WebSocket/EventSource blocked until each can be routed through existing ZeroProxy APIs with E2E coverage. |
0 commit comments