diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a41a937..35a2f2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI on: push: - branches: [main] pull_request: workflow_dispatch: @@ -37,8 +36,9 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24.16.0 cache: npm + cache-dependency-path: package-lock.json - name: Print toolchain versions run: | go version @@ -50,6 +50,14 @@ jobs: - name: Install wasm-bindgen CLI run: cargo install wasm-bindgen-cli --version 0.2.122 --locked + - name: Cache Puppeteer browser + uses: actions/cache@v4 + with: + path: .puppeteer-cache + key: puppeteer-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + puppeteer-${{ runner.os }}- + - name: Install Node dependencies run: npm ci @@ -59,8 +67,68 @@ jobs: - name: Run Go tests run: go test ./... + - name: Run Go js/wasm tests + run: npm run test:wasm + - name: Run JavaScript and Puppeteer tests run: npm test - name: Build deployable artifacts run: npm run build + + lint: + name: Lint (Go, Rust, JavaScript) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: clippy, rustfmt + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.16.0 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install golangci-lint v2.12.2 + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.12.2/install.sh \ + | sh -s -- -b "$(go env GOPATH)/bin" v2.12.2 + golangci-lint version + + - name: Verify golangci-lint config + run: golangci-lint config verify + + - name: Run golangci-lint (native) + run: golangci-lint run --timeout=5m + + - name: Run golangci-lint (js/wasm) + run: GOOS=js GOARCH=wasm golangci-lint run --timeout=5m + + - name: Check Rust formatting + run: cargo fmt --manifest-path rewriter-rs/Cargo.toml --all --check + + - name: Run clippy + run: cargo clippy --manifest-path rewriter-rs/Cargo.toml --all-targets -- -D warnings + + - name: Install Node dependencies + run: npm ci + env: + PUPPETEER_SKIP_DOWNLOAD: "true" + + - name: Run Biome + run: npx biome ci web scripts test diff --git a/.gitignore b/.gitignore index d4860a5..f585fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,11 @@ web/wasm_exec.js web/oxc_parser_wasm_bg.wasm node_modules/ coverage/ +.cache/ +artifacts/ rewriter-rs/target/ -wasm-kernel +/wasm-kernel +GOAL.md +.claude/ +.npm-cache +.puppeteer-cache diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4d71b97 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,195 @@ +# golangci-lint v2 configuration (schema version "2"). +# +# All enabled linters land HARD (CI must be green). This INCLUDES the +# complexity gates (cyclop / gocognit / nestif): they are ACTIVE and enforcing +# — new code over budget fails CI. See linters.settings for the thresholds and +# linters.exclusions for the test-file carve-out. +# +# A few pre-existing, *intrinsic* findings are narrowly excluded with a +# "# TODO(ratchet):" comment (e.g. SHA-1 mandated by the RFC6455 WebSocket +# handshake, protocol byte encodings, err-shadowing). The only remaining +# complexity suppressions are narrow inline //nolint: // TODO(complexity) +# at the few wasm-tagged (js && wasm) protocol/membrane sites; every other +# function is decomposed under budget. +version: "2" + +run: + timeout: 5m + # Lint test files too. The js/wasm pass (GOOS=js GOARCH=wasm) is invoked + # separately and is the only pass that covers the //go:build js && wasm + # files: bridge_js.go / conn_js.go / cmd/wasm-kernel/main.go. + tests: true + +linters: + # Start from an empty set so the enabled linters are exactly the spec: + # this guarantees no complexity linter is silently active. + default: none + enable: + - govet + - staticcheck + - errcheck + - ineffassign + - unused + - unparam + - unconvert + - misspell + - gosec + # A.2: complexity gates flipped to HARD error (see settings + exclusions). + - cyclop + - gocognit + - nestif + + settings: + govet: + # Enable every vet analyzer except fieldalignment (too noisy / churny). + enable-all: true + disable: + - fieldalignment + # TODO(ratchet): `shadow` flags idiomatic `if _, err := ...` blocks + # across app and test code. Satisfying it requires renaming variables + # in application logic, which A.1 must not touch. Re-enable after a + # dedicated shadow-cleanup pass. Reported as a concern. + - shadow + misspell: + locale: US + # TODO(ratchet): "cancelled" (British spelling) appears as a local + # variable in security-membrane code (internal/swhttp/bridge_js.go). + # A.1 must not reformat/edit the membrane. Ignore the word here rather + # than rename the variable. Reported as a concern. + # (v2 schema: misspell uses `ignore-rules`, not the v1 `ignore-words`.) + ignore-rules: + - cancelled + # NOTE: staticcheck is intentionally left at its golangci-lint default + # check set (which already excludes the ST10xx stylecheck rules). Do NOT + # add `checks: [all, ...]` here: that switches the entire ST family on and + # surfaces unrelated structural findings (e.g. ST1000 package comments). + # QF1003 is deferred via linters.exclusions.rules below instead. + errcheck: + # TODO(ratchet): unchecked errors on best-effort write paths. + # `out` is a *bufio.Writer; WriteString on it can only fail if the + # underlying writer errors, and an immediate Flush already surfaces that. + # Fixing the remaining sites edits application logic (forbidden in A.1). + # Reported as a concern. (Deferred Close/SetDeadline are covered by the + # std-error-handling preset and the SetDeadline rule below.) + exclude-functions: + - (*bufio.Writer).WriteString + gosec: + # TODO(ratchet): the findings below are INTRINSIC to a TLS-intercepting + # proxy / SOCKS5 / WebSocket protocol implementation and cannot be + # "fixed" without changing security-membrane behavior (forbidden in A.1). + # Each excluded sub-rule is reported as a concern for the A.2 ratchet: + # G101 - false positive: "zp-streamiso-v1\x00" is a protocol prefix, + # not a credential. + # G114 - http.ListenAndServe in the dev server (server hardening is a + # separate task). + # G115 - int->byte/uint16 conversions are deliberate protocol-frame + # length/port encodings (SOCKS5 / WS). + # G124 - cookie jar mirrors upstream Set-Cookie attributes verbatim by + # design; it must not inject Secure/HttpOnly/SameSite. + # G304/G703 - os.Open of an operator-supplied path (config/asset + # loading); both fire on the same call site. + # G401/G505 - SHA-1 is MANDATED by the RFC6455 WebSocket handshake. + # G710 - http.Redirect target is policy-validated upstream. + # + # G104 is a different class: it is gosec's GENERIC unchecked-error rule, + # fully redundant with errcheck (which stays enabled globally as the + # authoritative, more configurable unchecked-error linter). Excluding + # G104 removes double-reporting on lines where errcheck is deliberately + # excluded; it does NOT reduce unchecked-error coverage. + excludes: + - G101 + - G104 + - G114 + - G115 + - G124 + - G304 + - G401 + - G505 + - G703 + - G710 + + # ---------------------------------------------------------------------- + # A.2: complexity gates are now HARD errors. New code over budget fails CI. + # Pre-existing residuals are handled HONESTLY: _test.go is excluded below + # (test-function complexity is out of scope), and the remaining wasm-tagged + # (js && wasm) protocol/membrane functions still over budget carry a narrow + # inline `//nolint: // TODO(complexity): ...` at each site. Native + # offenders have been decomposed under budget. cyclop.package-average is + # intentionally omitted (fragile). + cyclop: + max-complexity: 10 + gocognit: + min-complexity: 15 + nestif: + min-complexity: 4 + # ---------------------------------------------------------------------- + + exclusions: + # Be lax on generated files (e.g. files with a generated-code header). + generated: lax + # Opt into golangci-lint's built-in "std-error-handling" preset (the old + # EXC0001): excludes unchecked errors from best-effort cleanup calls such + # as deferred Close/Flush. v2 ships NO default exclusions, so this is an + # explicit, narrow opt-in rather than a blanket relaxation. + presets: + - std-error-handling + # Skip vendored / build-output / non-Go trees entirely. + paths: + - dist + - bin + - rewriter-rs/target + - node_modules + rules: + # Test files: relax rules that are noisy or low-value in tests. + # A.2: cyclop/gocognit/nestif are excluded here too — test-function + # complexity is OUT OF SCOPE (e.g. table-driven / scenario bodies like + # relay_test.go TestBridgeInternalSOCKS, jar_test, transform_*_test). + # Production complexity stays HARD. + - path: _test.go + linters: + - gosec + - errcheck + - unparam + - cyclop + - gocognit + - nestif + # TODO(ratchet): QF1003 ("use tagged switch") is a stylistic suggestion; + # acting on it edits application logic. Deferred. Reported as a concern. + - linters: + - staticcheck + text: "QF1003" + # TODO(ratchet): deferred SetDeadline reset on a connection (best-effort + # cleanup) in the SOCKS5 client. The receiver is an anonymous interface, + # so it is excluded by path+text here rather than via + # errcheck.exclude-functions. Scoped to this one call site. Reported as + # a concern. + - path: internal/socks5/client\.go + linters: + - errcheck + text: "c\\.SetDeadline" + # TODO(ratchet): in-flight scaffolding on the feature branch. These + # specific symbols/params are wired for the nextgen rewriter and are not + # safe to delete in a config-only task. Scoped by name so genuinely dead + # code added later is still caught. Reported as a concern; revisit in A.2. + - path: internal/htmltx/transform\.go + linters: + - unused + text: "rewriteEventHandler|pathEscape" + - path: internal/htmltx/transform\.go + linters: + - unparam + text: "wrapAttrURL - nav is unused" + - path: cmd/zeroproxy-server/main\.go + text: "workerBootstrap - r is unused" + linters: + - unparam + +formatters: + enable: + - gofumpt + exclusions: + paths: + - dist + - bin + - rewriter-rs/target + - node_modules diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..1553fcb --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +cache=.npm-cache diff --git a/.puppeteerrc.cjs b/.puppeteerrc.cjs new file mode 100644 index 0000000..0e5be37 --- /dev/null +++ b/.puppeteerrc.cjs @@ -0,0 +1,5 @@ +const path = require('node:path'); + +module.exports = { + cacheDirectory: path.join(__dirname, '.puppeteer-cache'), +}; diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..355e6c2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# AGENTS.md — ZeroProxy + +ZeroProxy is a **human-in-the-loop** virtual-browsing privacy membrane: a real person drives a real browser, and target traffic egresses only through `Service Worker → Go WASM kernel → WebSocket/yamux → SOCKS5 → uTLS`. `ARCHITECTURE.md` holds the data-flow diagram and the full **Core invariants** list — read it before touching membrane/transport code; this file only adds what that doesn't, the conventions and traps that are expensive to rediscover. + +## Membrane/protocol refactor discipline (load-bearing) + +- A behavior-preserving change to membrane or protocol code must be proven by a **transient differential harness**, not a green suite: freeze the pre-change function verbatim under a new name, drive both old and new over a generated + edge corpus through the package's existing test seam (`scriptedRW` in socks5, `net.Pipe`/`pipeMux` in wsproto/zphttp), assert **0 mismatches** (return value, error string, bytes on the wire), then **delete the harness — never commit it** (`zz_*` scaffolding is correctly rejected in review). Keep a *permanent* characterization/adversarial oracle. *Why:* the `transform.go` decomposition passed the full suite but silently changed a marker; only a differential caught it. Suite-green ≠ behavior-preserved. + +- Removing a complexity `//nolint` is only real if the gate actually fires on that file. Prove it **red-before**, not just green-after: drop the pre-decomposition original (nolint stripped) at the path, confirm golangci **fails** on the complexity linter, then restore the decomposed file byte-identical (md5). *Why:* a stale `.golangci.yml` header once claimed the gates were "disabled" while they were live — green-after alone would have been hollow. + +## Lint / complexity gates + +- Complexity gates are **live and hard**: golangci `cyclop` ≤10 / `gocognit` ≤15 / `nestif` ≤4 (`_test.go` excluded), clippy `cognitive_complexity = "deny"` @15, Biome `noExcessiveCognitiveComplexity` @15. **Decompose to satisfy them — do not add new suppressions.** *Why:* the campaign is burning these down, not accumulating them. +- Known, deliberate remaining suppressions (a burn-down tail, not free license) take three forms — keep them straight, they are easy to conflate, and verify against `git grep` not this list, which drifts as the burn-down lands: **(1)** inline `//nolint:cyclop // TODO(complexity)` on exactly one wasm-tagged kernel function — `cmd/wasm-kernel/main.go` `relayEnsure` (cyclop 11; its only safe decomposition splits the engine mutex region and is not differentially verifiable without live `wsconn.Dial`); **(2)** a Biome glob override turning `noExcessiveCognitiveComplexity` **off** for exactly two files — `web/runtime-prelude.js` (the ~4.4k-line membrane, ~72 fns over budget — the one genuinely large remaining decomposition workstream) and `web/index.html` (its inline bootstrap) — plus `test/**` (test bodies out of scope); **(3)** a single inline `biome-ignore lint/complexity/noExcessiveCognitiveComplexity` at `web/worker-prelude.js` (module IIFE — no inner fn exceeds 15; the count is the wrapper-guard aggregate, so splitting relocates the global-exposure boundary rather than cutting complexity). `web/zp-core.js` carries **no** inline complexity ignores (both decomposed). The separate `web/**` Biome override disables only the **formatter** (so `biome ci` never reformats the membrane), **not** the complexity linter — the cognitive-complexity gate stays **hard on every other `web/` file, including `sw.js`**. All of these need a differential-harness decomposition, not a quick edit. + +## Build / verify traps + +- **wasm-tagged files** (`//go:build js && wasm`: `cmd/wasm-kernel/main.go`, `internal/swhttp/bridge_js.go`, `internal/wsconn/conn_js.go`) are **skipped by `go test ./...` and the native golangci pass.** Lint/build coverage is `GOOS=js GOARCH=wasm golangci-lint run` and `GOOS=js GOARCH=wasm go build ./cmd/wasm-kernel` (`npm run lint:go` runs both golangci passes). Wasm-tagged **tests** (e.g. `internal/swhttp/bridge_js_test.go`, `cmd/wasm-kernel/wsstream_test.go`) are likewise skipped by `go test ./...`; they run only under `npm run test:wasm`, which executes them via the Go `go_js_wasm_exec` runner (CI runs this as its own step). The script wraps the run in `env -i` preserving only `PATH`/`HOME`/`GOCACHE`/`GOMODCACHE` — the wasm runtime copies the whole env into a bounded argv+env buffer, so an unstripped (large) env overflows it (`total length of command line and environment variables exceeds limit`). Run `npm run test:wasm` after any transport/bridge change or you have verified nothing for that code. +- Run `golangci-lint cache clean` before trusting lint results — the results cache serves stale issues from deleted worktrees (paths like `../../../../tmp/…`, "can't read file"). +- Use the npm test scripts (`npm test` / `test:js` / `test:e2e` → `node scripts/test.mjs [js|e2e]`). **Do not** run `node --test test/js` — Node 24 treats the directory as a module and reports a spurious failure. +- Rust rewriter behavior is not covered by `npm test`: run `cargo test --manifest-path rewriter-rs/Cargo.toml` after rewriter changes. `npm run lint:rust` is clippy + fmt only, and CI runs the Rust tests as a separate gate. *Why:* the Rust WASM rewriter is the static compiler pipeline for target scripts/CSS, so a green JS/Puppeteer suite alone can miss parser/rewriter regressions. +- Do not blanket-format `web/**`. Biome formatting is intentionally disabled there, and `npm run fmt:fix` formats Go/Rust plus `scripts`/`test`, not the membrane web assets. *Why:* large membrane files keep reviewable hand-shaped layout until a differential-harness decomposition proves the behavior-preserving change. +- **E2E flake:** the two heavy Puppeteer tests can mutually starve under load — one times out at the ~31.5s page deadline while the other passes, and *which* one fails migrates between runs. A migrating failure is environmental, not a regression (a real regression fails the same test deterministically); re-run, or run the e2e tests individually, before blaming a code change. + +## Commits + +- Conventional Commits, one concern per commit; substantive commits carry an `Op: compress|extend|correct` trailer (plus `Restores: …` for `correct`). Use the configured git identity — **no** `--author`, `Co-Authored-By`, `Signed-off-by`, or any agent trailer; do not mutate git config. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 88142a4..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,247 +0,0 @@ -# ZeroProxy Architecture - -Assessment basis: the current repository implementation as of 2026-05-28. - -ZeroProxy is a client-owned virtual browsing prototype. The browser renders target pages as same-origin proxy documents, while target HTTP/TLS/WebSocket traffic is intended to leave only through the controlled transport stack below: - -```text -Browser top-level target document - ├─ /zp/sw.js Service Worker - │ ├─ request classifier for every controlled fetch under `/zp/` - │ ├─ in-memory tab/history/context state plus inherited relay-server list - │ ├─ encrypted /zp/p route activation - │ └─ Go WASM kernel exports - │ ├─ __go_jshttp(request) -> target HTTP/2 or HTTP/1.1 fetch - │ ├─ __zp_stream(options) -> target WebSocket stream - │ ├─ __zp_kernel_init() -> transport readiness - │ └─ __zp_cookie_set(request) -> document.cookie bridge - ├─ /zp/assets/rust-rewriter.js - │ └─ Rust WASM AST walker returning rewritten JavaScript - ├─ /zp/assets/runtime-prelude.js - │ ├─ fetch/XHR/EventSource/WebSocket/sendBeacon wrappers routed through same-origin runtime APIs - │ ├─ navigation, form, history, location, and getter masking hooks - │ ├─ storage namespace facades - │ ├─ worker and iframe containment hooks - │ ├─ dynamic HTML parser traversal and stealth membrane - │ └─ WebRTC/WebTransport/device API blocking stubs plus `WebSocketStream` - └─ transformed target HTML as the top-level document - -Proxy origin server - ├─ static assets: /zp/, /zp/sw.js, /zp/assets/*, /zp/kernel.wasm - └─ /zp/ws-pipe WebSocket endpoint - └─ yamux server session - └─ per-stream SOCKS5 handling - ├─ external Tor SOCKS5 byte bridge (`-socks host:port`) - └─ internal SOCKS5 parser + direct relay dialer (`-socks internal`, tests only) -``` -The relay server terminates only the browser WebSocket and yamux session. It uses `github.com/gorilla/websocket` for `/zp/ws-pipe`, disables WebSocket compression, wraps binary WebSocket messages as a stream-oriented `net.Conn`, and either byte-bridges yamux streams to the configured Tor SOCKS5 listener or, when launched with `-socks internal`, parses the kernel's SOCKS5 greeting/auth/CONNECT request itself and directly dials the requested target from the relay process. It does not parse target HTTP, TLS, redirects, cookies, or HTML. Those responsibilities live in the Go WASM kernel and browser runtime. Internal mode is a non-anonymous test/development mode. - -## Core invariants - -- Target document navigations use encrypted `/zp/p/#k=&server=...` routes on the proxy origin. -- The `#k` fragment is decrypted in the browser shell, kept visible with canonical `server=` relay parameters, and never sent to the HTTP server. -- Every Service Worker-controlled request is classified. Unknown requests are blocked; there is no native `fetch(event.request)` fallback. -- Privileged runtime-to-Service-Worker control messages require a per-tab capability token injected into the runtime prelude and removed from target-visible DOM before target code runs. -- Target TCP connections are opened through WebSocket -> yamux -> SOCKS5 DOMAINNAME. With a Tor `-socks` address the relay byte-bridges to Tor; with `-socks internal` the relay validates the SOCKS5 CONNECT bytes and direct-dials the target for Tor-free testing. The kernel does not call `http.Transport` for target egress. -- HTTPS uses uTLS over the SOCKS5 stream with ALPN selecting HTTP/2 when available and HTTP/1.1 fallback; target WebSocket upgrade pins HTTP/1.1. -- Target response headers are passed through a constructor policy before the browser receives a `Response`. -- Anti-bot spoofing is not a project goal. The runtime applies limited self-fingerprint masking only to reduce trivial detection of its own hooks and host-resource leaks (`Function.prototype.toString`, Canvas/Audio extraction jitter, and speech voice lists). - -## Main components - -| Area | Files | Responsibility | -|---|---|---| -| Static shell | `web/index.html`, `web/zp-core.js` | Service Worker registration, target URL canonicalization, share URL encryption/decryption, initial target open. | -| Share URL envelope | `web/zp-core.js`, `internal/shareurl/*` | Compatible JavaScript and Go implementations of `/zp/p/#k=&server=...` using AES-256-CBC, HMAC-SHA256, HKDF, raw base64url, and inherited relay-server fragments. | -| Service Worker | `web/sw.js` | Classifies every controlled request under `/zp/`, blocks unknowns, manages in-memory tab/entry state and inherited relay servers, requires per-tab capability tokens on privileged runtime bridge messages, calls the WASM kernel, exposes runtime bridge APIs. | -| Runtime prelude | `web/runtime-prelude.js`, `web/worker-prelude.js` | Installs target-realm containment hooks before target scripts run. Main-window fetch/XHR/EventSource/WebSocket/sendBeacon, navigation/form/history/location/storage/worker/iframe/device APIs are hooked; main-window and worker `fetch` bridge through `/zp/api/fetch`. Runtime membrane helpers (`__zp_get`, `__zp_set`, `__zp_assign`, `__zp_update`, `__zp_call`, `__zp_construct`, `__zp_getOwnPropertyDescriptor`, `__zp_ownKeys`) and dynamic compilation wrappers execute `Function`/`eval`/string timer bodies under the virtual global scope. | -| WASM kernel | `cmd/wasm-kernel/main.go`, `internal/swhttp/*` | Converts JS `Request`/`Response`, initializes transport, owns target HTTP and WebSocket execution. | -| Transport | `internal/wsconn/*`, `internal/yamuxconn/*`, `internal/socks5/*`, `internal/utlskernel/*`, `internal/zphttp/*`, `internal/wsproto/*` | Browser WebSocket `net.Conn`, yamux streams, SOCKS5 DOMAINNAME CONNECT, uTLS, HTTP/2 and HTTP/1.1 target fetch, target WebSocket upgrade/framing. | -| HTML/header/cookie policy | `internal/htmltx/*`, `internal/headers/*`, `internal/cookiejar/*`, `internal/zpiso/*` | HTML transformation, Rust WASM inline script/event-handler rewrite entrypoints, safe response header constructor policy, target cookie jar, and relay-server inheritance metadata. | -| Relay server | `cmd/zeroproxy-server/main.go` | Serves prefixed assets, accepts `/zp/ws-pipe` with Gorilla WebSocket, and routes yamux streams either to the configured Tor SOCKS5 address or the `-socks internal` SOCKS5 parser/direct dialer. | - -## Request flow - -1. The shell registers `/zp/sw.js` with `scope: '/zp/'`, waits for a controller, canonicalizes an `http:` or `https:` target, encrypts it, and navigates to `/zp/p/#k=&server=...` on the proxy origin. -2. The shell loaded on `/zp/p/#k=&server=...` decrypts the fragment key in window context, validates the HMAC before decryption, normalizes repeated or missing `server=` relay fragments, keeps the canonical fragment visible, and sends `ZP_OPEN_SHARE` to the Service Worker. -3. The Service Worker stores the decrypted target plus relay-server list in in-memory tab/entry maps and activates `/zp/p/` as a proxy document route. -4. A `/zp/p/` document request is resolved back to the target URL. The Service Worker calls `__go_jshttp` with `X-ZP-*` internal metadata plus inherited relay-server headers. -5. The WASM kernel ensures one long-lived WebSocket connection to the selected relay server or `/zp/ws-pipe`, wraps it in a yamux client, and opens one yamux stream per target TCP connection. -6. Each target connection performs SOCKS5 `CONNECT` with DOMAINNAME ATYP and a Tor `IsolateSOCKSAuth` username derived from the tab stream-isolation key and target site. In `-socks internal` mode the relay accepts that same binary SOCKS5 handshake locally and direct-dials the requested host:port; no external Tor process is used. -7. HTTPS fetch targets advertise `h2` and `http/1.1` through uTLS ALPN; target WebSocket connections advertise only `http/1.1`. -8. `internal/zphttp` dispatches negotiated `h2` connections through `golang.org/x/net/http2.ClientConn`; HTTP/1.1 fallback writes a direct request and reads the response with `http.ReadResponse`. -9. Redirects are followed inside the kernel so raw `Location` headers are not exposed to browser code. -10. HTML document responses are transformed: Rust rewrite asset plus runtime prelude are injected, document navigation URLs are rewritten to encrypted `/zp/p/#k=&server=...` routes, risky tags and headers are removed, and the browser receives a same-origin `Response` with ZeroProxy CSP. - -## Shared URL flow - -Shared links use this envelope: - -```text -/zp/p/#k=&server=... -``` - -`web/zp-core.js` and `internal/shareurl` derive separate HKDF-SHA256 AES-CBC and HMAC keys from the 64-byte seed. The MAC covers a fixed version prefix, IV, and ciphertext. Decryption verifies HMAC first, then decrypts and canonicalizes the target URL. Only `http:` and `https:` targets are accepted for document/fetch traffic; WebSocket wrappers accept only `ws:` and `wss:`. - -The Go HTML transformer uses `internal/shareurl.NewWithServers` when laundering document-navigation attributes, so transformed links/forms/frames keep using encrypted `/zp/p` routes with inherited relay-server fragments instead of legacy virtual URL paths. - -## Service Worker classification - -`web/sw.js` calls `event.respondWith(handleFetch(event))` for every fetch event. Classification currently distinguishes: - -- internal assets under `/zp/`, `/zp/sw.js`, `/zp/assets/*`, and `/zp/kernel.wasm`; -- activated encrypted `/zp/p/` proxy documents; -- runtime APIs under `/zp/api/*`; -- virtual subresources with an existing client or referrer context; -- unknown requests, which are blocked with a safe error page for navigations or `Response.error()` for subresources. - -The Service Worker keeps tab, entry, client-context, route, and stream maps in memory. It tracks active entries, scroll positions, and a document-cookie bridge, but does not persist encrypted state to IndexedDB. - -## Transport and HTTP behavior - -The Go WASM kernel exposes `__zp_kernel_init`, `__go_jshttp`, `__zp_stream`, and `__zp_cookie_set` to the Service Worker. Initialization creates a browser WebSocket to `/zp/ws-pipe`; the relay accepts it with Gorilla WebSocket and adapts binary messages to a stream-oriented `net.Conn`. A yamux client/server session runs over that connection. Per-target yamux streams always carry a SOCKS5 CONNECT from the kernel. The relay either forwards those bytes to Tor unchanged or, in `-socks internal`, consumes the SOCKS5 handshake, accepts no-auth or username/password auth, reads IPv4/domain/IPv6 CONNECT addresses, sends a SOCKS5 success/failure reply, and pumps bytes to a direct `net.Dialer` connection. - -`internal/zphttp` builds sanitized target requests, applies the cookie jar, follows redirects up to `MaxRedirects`, dispatches HTTPS fetches to HTTP/2 when ALPN selects `h2`, and falls back to direct HTTP/1.1 request/response handling otherwise. HTTP/2 client connections and reusable HTTP/1.1 idle connections are pooled only within the same target authority, tab, and Tor isolation token. Idle target connections use a browser-style 90 second timeout; HTTP/1.1 connections are reused only after the response body reaches EOF and are closed on partial body cancellation or `Connection: close`. Target WebSocket support stays HTTP/1.1 Upgrade through `internal/wsproto` and the runtime `WebSocket` wrapper. - -`internal/swhttp.ResponseToJS` constructs JavaScript `Response` objects with a `ReadableStream` backed by the Go response body. Document HTML transformation uses `htmltx.TransformTo` through an `io.Pipe`, so transformed HTML can flow to the browser without first buffering the full document. Request/upload body conversion and browser backpressure/cancellation fidelity are still prototype-level. - -## HTML, header, and runtime policy - -`internal/htmltx` uses `golang.org/x/net/html` tokenization. It injects `zp-core.js`, `rust-rewriter.js`, inert JSON boot data, and `runtime-prelude.js`; removes base/meta refresh/ping/preload-style escape vectors from static documents; rewrites iframe/frame document URLs to encrypted `/zp/p` routes; preserves author-visible anchor/form attributes for runtime click/submit interception; proxies executable external script sources through `/zp/api/script?u=&kind=`; routes inline scripts and event handlers through the Rust WASM rewriter from the target realm; injects prelude code into `srcdoc`; and replaces blocked embed/object content with inert placeholders. - -`internal/headers.ConstructorPolicy` strips target-controlled policy, storage, network-control, hop-by-hop, redirect, and transformed-body headers before constructing a browser `Response`. It defaults cache behavior to `Cache-Control: no-store`. - -`web/runtime-prelude.js` installs hooks for high-risk browser APIs from inside the target realm. Main-window fetch, XHR, EventSource, WebSocket, `sendBeacon`, navigation, forms, history/location masking, storage facades, Worker/SharedWorker constructors, service worker registration blocking, high-risk device/network API blockers, and synchronous iframe containment are present. Click navigation handles normal anchors, hash-only virtual navigation, and script-created elements that carry a URL-valued `href` property. The runtime also masks patched function source strings for its virtual location and network wrappers where target scripts commonly inspect `toString()`. - -### Client-side replacement matrix - -Service Worker: - -- `/zp/p/` document requests are resolved from in-memory route state and fetched through `__go_jshttp`; unknown navigations receive safe ZeroProxy errors and unknown subresources receive `Response.error()`. -- `/zp/api/fetch` accepts runtime `fetch`/XHR/EventSource/sendBeacon payloads, adds `X-ZP-*` tab metadata, enforces the request-body size limit, and routes through the WASM kernel. -- `/zp/api/script?u=...` fetches the absolute target script through the kernel and returns same-origin JavaScript with `Content-Security-Policy: ZP.fixedCSP()`. It runs the Rust WASM rewriter and fails closed to a throwing script on parse/rewrite failure. -- `/zp/api/worker-script?u=...` does the same for worker and imported worker scripts. Worker bootstrap URLs carry the target script URL and tab id in the hash; `worker-prelude.js` preserves internal `/zp/api/worker-script` imports without double-wrapping them. - -HTML transform: - -- `'` are already made inert, but direct script element text is not rewritten or blocked before insertion. - -Affected code: - -- `web/runtime-prelude.js`: script URL laundering handles `script.src`, but insertion hooks do not block or rewrite script text. -- `web/runtime-prelude.js`: dynamic HTML `transformHTML()` blocks string-created `` + - `` + - ``; -} -``` - -### 4. Bare module imports and import maps are currently broken - -Observed behavior: - -```js -import React from 'react'; -``` - -The rewriter turns this into a target-relative URL such as: - -```js -import React from "/__zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Freact"; -``` - -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. - -Affected code: - -- `web/js-rewriter.js`: `moduleSpecifier()` resolves every specifier with `new URL(specifier, moduleTargetURL)`. -- `internal/htmltx/transform.go`: `type="importmap"` is not handled as a first-class module-resolution input. - -Required Phase 3 behavior: - -- Distinguish specifier classes: - - relative-like: `./x.js`, `../x.js`, `/x.js`; - - absolute URL: `https://...`, `http://...`; - - special schemes: `data:`, `blob:`, `node:`, etc.; - - bare: `react`, `@scope/pkg`, `pkg/subpath`. -- Rewrite only relative-like and HTTP(S) absolute specifiers directly. -- Preserve or resolve bare specifiers according to a parsed import map. Do not treat them as URL paths. -- Transform import maps before module execution: - - parse JSON safely; - - rewrite mapped HTTP(S)/relative addresses to `${controlPrefix}/api/script?kind=module&u=...`; - - preserve invalid import-map behavior as close to the browser as practical; - - block import-map entries with executable or unsupported schemes. -- Add tests for bare specifier with import map, bare specifier without import map, scoped import-map entries, and absolute/relative module imports. - -### 5. `import.meta.url` remains proxy API URL-backed - -Observed behavior: - -```js -export const rel = new URL('./chunk.js', import.meta.url).href; -``` - -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. - -Required Phase 3 behavior: - -- Rewriter must replace `import.meta.url` with the original target module URL string, or an equivalent immutable helper value. -- `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. -- Add E2E coverage for a module that creates a Worker and dynamic import from `new URL(..., import.meta.url)`. - -### 6. Non-literal dynamic `import()` is not rewritten - -Observed behavior: - -```js -export async function load(name) { - return import('./chunks/' + name + '.js'); -} -``` - -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. - -Required Phase 3 behavior: - -- Rewrite expression dynamic imports to a helper path, for example: - -```js -import(__zp_module_url(expr, originalModuleURL)) -``` - -- The helper must: - - resolve relative-like and HTTP(S) absolute specifiers against the original target module URL; - - consult the transformed import-map registry for bare specifiers; - - return a same-origin `${controlPrefix}/api/script?kind=module&u=...` URL; - - fail closed for unsupported schemes. -- Service Worker classification must preserve module kind for module subresource fetches instead of falling back to classic script rewriting. - -### 7. Rewriter lexical scoping mishandles `var` hoisting - -Observed behavior: - -```js -function f(x) { - if (x) { var location = { href: 'local' }; } - return location.href; -} -``` - -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. - -Affected code: - -- `web/js-rewriter.js`: scope collection treats block body declarations too uniformly and does not model `var` hoisting to the nearest function/program scope. - -Required Phase 3 behavior: - -- Implement a real scope model: - - program scope; - - function scope; - - block scope; - - catch scope; - - class scope where relevant; - - module import/export bindings; - - `var` and function-declaration hoisting to function/program scope; - - `let`/`const`/class bindings to block scope; - - parameter and function-name scopes. -- Add rewriter unit tests for shadowing across blocks, functions, loops, catch clauses, destructuring, imports, class names, and nested functions. -- Fail closed only for unsupported syntax or ambiguous transformations, not for valid local-shadowing code. - -### 8. Inline classic and event-handler fallback is not strict fail-closed - -Current behavior: - -- External script rewrite failure returns a throwing script. -- Inline module fallback returns a throwing script. -- Inline classic script and event-handler fallback can execute original source wrapped in `__zp_runClassic` / `__zp_runEvent` when the OXC rewriter is unavailable. - -Affected code: - -- `internal/htmltx/transform.go`: `rewriteInlineScript()` and `rewriteEventHandler()` compatibility fallback. -- `cmd/wasm-kernel/main.go`: `rewriteScript()` returns false when the JS rewriter is unavailable or fails. - -Required Phase 3 behavior: - -- Default strict path: inline classic scripts and event handlers must block when OXC rewrite fails. -- If a compatibility mode is retained, it must be explicit, test-named, and documented as lower assurance. -- The default E2E path must prove parse/rewrite failures do not execute original inline source. - -### 9. Compatibility passthrough allowlist remains an acceptance-boundary exception - -Current behavior: - -- `/__zp/api/script` bypasses rewriting for selected third-party challenge/tag-manager hosts. -- This is not a parse-failure fallback, but it is still a strict-mode exception. - -Affected code: - -- `web/sw.js`: `shouldPassthroughScript()`. - -Required Phase 3 behavior: - -- Decide one strict default: - - remove passthrough from strict mode; or - - move passthrough behind an explicit compatibility policy flag with host/path allowlist tests. -- Strict/high-assurance acceptance must not depend on passthrough scripts executing unrewritten. - -### 10. Worker script API compatibility remains partial - -Current behavior: - -- Worker `fetch` and `importScripts` are routed. -- Worker XHR, WebSocket, EventSource, WebRTC/WebTransport, device APIs, and blob/data worker scripts are blocked or prototype-level. - -Required Phase 3 behavior: - -- Keep blocked APIs explicit and test-covered. -- Add compatibility only where it can preserve the transport boundary: - - worker XHR over `${controlPrefix}/api/fetch` if needed; - - worker EventSource over fetch stream if needed; - - worker WebSocket only through the existing `ZP_WS_OPEN`/`__zp_stream` path with per-tab capability. -- Do not silently expose native worker networking. - -### 11. ZeroProxy control artifacts are script-observable through DOM fingerprinting APIs - -Observed behavior: - -ZeroProxy must attach internal attributes and injected boot assets to maintain routing and rewriting state, for example `data-zp-target-url`, `data-zp-blocked-url`, `data-zp-integrity`, `data-zp-blocked-script`, `#__zp-boot`, and injected control-prefix assets such as `/zp/assets/zp-core.js` / `/zp/assets/runtime-prelude.js` after the Gate 1 cutover. Anti-bot and integrity scripts can enumerate these artifacts through: - -- `element.innerHTML` and `element.outerHTML`; -- `getAttribute`, `hasAttribute`, `getAttributeNames`, and `attributes` / `NamedNodeMap`; -- `document.scripts`, `getElementsByTagName('*')`, `getElementsByTagName('script')`, and related count APIs; -- `NodeIterator`, `TreeWalker`, and manual traversal; -- selector APIs such as `querySelectorAll('[data-zp-target-url]')`, `matches()`, and `closest()`. - -Required Phase 3 behavior: - -- Add a stealth membrane that hides ZeroProxy framework artifacts from target-page script-level inspection while preserving internal access through captured native references and private state such as `WeakMap` metadata. -- The membrane must cover at least: - - `Element.prototype.innerHTML` and `outerHTML` getters; - - `Element.prototype.getAttribute`, `hasAttribute`, `getAttributeNames`, `attributes`; - - `NamedNodeMap.length`, numeric indexing, `item()`, `getNamedItem()`, iteration, and property access; - - `Document.prototype.getElementsByTagName` and `Element.prototype.getElementsByTagName` for `*`, `script`, `meta`, `link`, and any tag that can expose injected assets; - - `Document.prototype.scripts` and any available script collection getters; - - `Document.prototype.createNodeIterator` and `createTreeWalker`; - - selector APIs that directly query `data-zp-*` attributes or injected ZeroProxy script IDs/sources. -- The membrane must not hide target-authored attributes that merely contain the substring `zp`; it hides the exact ZeroProxy-reserved namespace `data-zp-*` and exact internal boot assets. -- The membrane must preserve web-compatible collection behavior: stable order, numeric indexing, `length`, `item()`, iteration, and function `this` binding. -- Masked methods must be integrated with the existing native-function stringification layer so `Function.prototype.toString` does not expose wrapper bodies. -- Tests must assert both invisibility to page scripts and continued internal functionality. - -Reference implementation shape for the core membrane: - -```js -(() => { - const mask = (fn, name) => { if (typeof fn === 'function') maskNativeFunction(fn, name); }; - const isZPAttr = name => typeof name === 'string' && name.toLowerCase().startsWith('data-zp-'); - const isZPAsset = node => node && ( - node.id === '__zp-boot' || - (node.localName === 'script' && isZeroProxyAssetURL(Native.getAttribute.call(node, 'src'))) - ); - - const origInnerHTMLGet = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML').get; - const origOuterHTMLGet = Object.getOwnPropertyDescriptor(Element.prototype, 'outerHTML').get; - const origGetAttribute = Element.prototype.getAttribute; - const origHasAttribute = Element.prototype.hasAttribute; - const origGetAttributeNames = Element.prototype.getAttributeNames; - const origAttributesGet = Object.getOwnPropertyDescriptor(Element.prototype, 'attributes').get; - - Object.defineProperty(Element.prototype, 'innerHTML', { - get() { return sanitizeSerializedHTML(origInnerHTMLGet.call(this)); }, - configurable: true, - enumerable: true - }); - - Object.defineProperty(Element.prototype, 'outerHTML', { - get() { return sanitizeSerializedHTML(origOuterHTMLGet.call(this)); }, - configurable: true, - enumerable: true - }); - - Element.prototype.getAttribute = function(name) { - if (isZPAttr(name)) return null; - return origGetAttribute.apply(this, arguments); - }; - mask(Element.prototype.getAttribute, 'getAttribute'); - - Element.prototype.hasAttribute = function(name) { - if (isZPAttr(name)) return false; - return origHasAttribute.apply(this, arguments); - }; - mask(Element.prototype.hasAttribute, 'hasAttribute'); - - Element.prototype.getAttributeNames = function() { - return origGetAttributeNames.apply(this, arguments).filter(n => !isZPAttr(n)); - }; - mask(Element.prototype.getAttributeNames, 'getAttributeNames'); - - Object.defineProperty(Element.prototype, 'attributes', { - get() { return filteredNamedNodeMap(origAttributesGet.call(this), attr => !isZPAttr(attr.name)); }, - configurable: true, - enumerable: true - }); - - const filterNodeList = list => filteredLiveCollection(list, node => !isZPAsset(node)); - - const origDocumentGetElementsByTagName = Document.prototype.getElementsByTagName; - const origElementGetElementsByTagName = Element.prototype.getElementsByTagName; - Document.prototype.getElementsByTagName = function(tag) { - const raw = origDocumentGetElementsByTagName.apply(this, arguments); - return shouldFilterTag(tag) ? filterNodeList(raw) : raw; - }; - Element.prototype.getElementsByTagName = function(tag) { - const raw = origElementGetElementsByTagName.apply(this, arguments); - return shouldFilterTag(tag) ? filterNodeList(raw) : raw; - }; - mask(Document.prototype.getElementsByTagName, 'getElementsByTagName'); - mask(Element.prototype.getElementsByTagName, 'getElementsByTagName'); - - const origScriptsGet = Object.getOwnPropertyDescriptor(Document.prototype, 'scripts').get; - Object.defineProperty(Document.prototype, 'scripts', { - get() { return filterNodeList(origScriptsGet.call(this)); }, - configurable: true, - enumerable: true - }); - - const origCreateNodeIterator = Document.prototype.createNodeIterator; - Document.prototype.createNodeIterator = function(root, whatToShow, filter) { - const nativeIterator = origCreateNodeIterator.apply(this, arguments); - return filteredIterator(nativeIterator, node => !isZPAsset(node)); - }; - mask(Document.prototype.createNodeIterator, 'createNodeIterator'); -})(); -``` - -`sanitizeSerializedHTML()`, `filteredNamedNodeMap()`, `filteredLiveCollection()`, `filteredIterator()`, and selector filtering must be implemented without recursively calling patched public APIs. They must use captured native descriptors and avoid avoidable string allocation except at API boundaries that are defined to return strings. - -### 12. `WebSocketStream` is blocked instead of mapped onto the proxied WebSocket path - -Current behavior: - -- `web/runtime-prelude.js` blocks `WebSocketStream` with other unsupported networking APIs. -- Some modern browser/anti-bot code checks for `WebSocketStream` or uses the Streams API shape directly. - -Required Phase 3 behavior: - -- Provide a `WebSocketStream` polyfill when native `WebSocketStream` is absent or blocked by ZeroProxy policy. -- The polyfill must use the existing ZeroProxy `WebSocket` constructor/membrane so traffic still routes through `ZP_WS_OPEN` / `__zp_stream` and never through native networking. -- Constructor semantics: - - `new WebSocketStream(url, options = {})` resolves `url` against the virtual location/base URL; - - `options.protocols` is passed to `WebSocket` without inventing unsupported options; - - `opened` is a promise resolving to `{ readable, writable, protocol, extensions }` after WebSocket open; - - `closed` is a promise resolving to `{ closeCode, reason }` after close; - - `readable` enqueues WebSocket messages and closes on socket close; - - `writable.write(chunk)` sends chunks through `ws.send(chunk)`, `close()` closes the socket, and `abort()` closes the socket; - - errors reject `opened` when open has not completed and error/close streams after open as web-compatibly as practical. -- The constructor and methods must be masked through the existing native-stringification layer. - -Reference implementation shape: - -```js -if (!root.WebSocketStream) { - class WebSocketStream { - constructor(url, options = {}) { - const virtualBase = root.__zp_virtualLocation?.href || location.href; - const targetUrl = new URL(url, virtualBase).href; - let socketClosedResolve; - this.closed = new Promise(resolve => { socketClosedResolve = resolve; }); - this.opened = new Promise((resolve, reject) => { - try { - const ws = new root.WebSocket(targetUrl, options.protocols); - ws.binaryType = 'arraybuffer'; - let controllerReadable; - const readable = new ReadableStream({ - start(controller) { controllerReadable = controller; }, - cancel() { ws.close(); } - }); - const writable = new WritableStream({ - write(chunk) { ws.send(chunk); }, - close() { ws.close(); }, - abort() { ws.close(); } - }); - ws.onopen = () => resolve({ readable, writable, protocol: ws.protocol, extensions: ws.extensions || '' }); - ws.onmessage = event => { if (controllerReadable) controllerReadable.enqueue(event.data); }; - ws.onerror = err => reject(err); - ws.onclose = event => { - try { controllerReadable && controllerReadable.close(); } catch {} - socketClosedResolve({ closeCode: event.code, reason: event.reason }); - }; - } catch (err) { - reject(err); - } - }); - } - } - maskNativeFunction(WebSocketStream, 'WebSocketStream'); - root.WebSocketStream = WebSocketStream; -} -``` - -## Phase 3 integration guidelines - -- Treat the route-prefix cutover as a fail-closed scoping change: narrow Service Worker scope only after every shell, document, API, asset, error, and optional relay route lives under the same prefix. -- Treat `server` fragment parameters as control-plane state. They are parsed only by the shell, stored in Service Worker tab/entry context, inherited downward, and never accepted from target-authored document URLs or request parameters. -- Treat the `` / document-navigation rewrite bug as a kernel correctness fix, not a compatibility enhancement. It must land before any E2E fixture relies on target-page navigation. -- Treat dynamic HTML regex removal as a clean cutover for markup transformation. Do not keep a second regex fallback path for malformed HTML; browser parser behavior is the contract. -- Treat the stealth membrane as a consistency layer over ZeroProxy-owned artifacts. Internal code must use captured native APIs or private metadata; page code must see target-authored DOM, not ZeroProxy implementation details. -- Treat `WebSocketStream` as an API facade over the existing proxied `WebSocket` path. Do not add a parallel transport. -- Every new membrane hook must include a test proving both directions: page-observable hiding works, and ZeroProxy internal behavior still works. -- Implementation must avoid redundant string serialization and repeated full-collection materialization where a live-filtered proxy or indexed lazy scan is sufficient. - -## Implementation sequence - -### Gate 0: Add failing fixtures first - -Add tests before changing behavior: - -- Route namespace and relay inheritance tests: - - shell served from `controlPrefix` registers the Service Worker with `scope: controlPrefix`; - - `/p/...`, `/__zp/...`, and root-scoped controlled URLs are absent from newly generated URLs after the cutover; - - unknown same-origin navigation inside `controlPrefix` fails closed; - - unknown same-origin paths outside `controlPrefix` cannot serve target content or runtime APIs; - - `#k=&server=...` parsing validates, normalizes, de-duplicates, bounds, stores, and erases relay server fragments; - - child navigations, iframes, workers, runtime API calls, WebSocket, and `WebSocketStream` inherit the selected server list. -- Go HTML-transform tests: - - ``, ``, `
`, ``, and ``), Options{TabID: "tab", EntryID: "entry", TargetURL: target, Servers: []string{"wss://relay.example/ws"}}) +func TestTransformDelegatesWholeDocumentToRustHook(t *testing.T) { + target, err := url.Parse("https://example.com/app/") if err != nil { t.Fatal(err) } - s := string(out) - for _, want := range []string{"/zp/assets/zp-core.js", "/zp/assets/runtime-prelude.js", "/zp/p/", "#k=", "server=wss%3A%2F%2Frelay.example%2Fws", "__ZP_SET_BASE", "https://evil.test/", `data-zp-target-url="https://example.com/next"`, `data-zp-target-url="https://example.com/dir/submit"`, `data-zp-target-url="https://example.com/alt"`, `data-zp-target-url="https://example.com/child"`, `data-zp-blocked-rel="preconnect"`, `ZeroProxy blocked object`} { - if !strings.Contains(s, want) { - t.Fatalf("missing %q in %s", want, s) - } - } - for _, forbidden := range []string{"`), Options{TabID: "tab", EntryID: "entry", TargetURL: target}) + called := false + out, err := Transform(strings.NewReader(`raw`), Options{ + TabID: "tab-1", + EntryID: "entry-1", + TargetURL: target, + RuntimeToken: "rt-1", + Servers: []string{"wss://relay.example/ws"}, + DocumentRewriter: func(source, targetURL, controlPrefix, runtimePrelude, tabID, runtimeToken string, servers []string) (string, error) { + called = true + if source != `raw` { + t.Fatalf("unexpected source: %q", source) + } + if targetURL != "https://example.com/app/" { + t.Fatalf("unexpected target URL: %q", targetURL) + } + if controlPrefix != "/zp/" { + t.Fatalf("unexpected control prefix: %q", controlPrefix) + } + if !strings.Contains(runtimePrelude, "runtime-prelude.js") { + t.Fatalf("runtime prelude was not passed: %q", runtimePrelude) + } + if tabID != "tab-1" || runtimeToken != "rt-1" { + t.Fatalf("unexpected runtime context tab=%q rt=%q", tabID, runtimeToken) + } + if len(servers) != 1 || servers[0] != "wss://relay.example/ws" { + t.Fatalf("unexpected servers: %#v", servers) + } + return `rust`, nil + }, + }) if err != nil { t.Fatal(err) } - s := string(out) - for _, want := range []string{ - `rel="icon"`, - `rel="apple-touch-icon"`, - `href="data:application/x-zeroproxy-icon,1"`, - `data-zp-target-url="https://example.com/favicon.ico"`, - `data-zp-target-url="https://example.com/app/touch.png"`, - } { - if !strings.Contains(s, want) { - t.Fatalf("missing %q in %s", want, s) - } + if !called { + t.Fatal("document rewriter hook was not called") } - for _, forbidden := range []string{`href="/favicon.ico"`, `href="touch.png"`} { - if strings.Contains(s, forbidden) { - t.Fatalf("raw icon href %q remained in %s", forbidden, s) - } + if string(out) != `rust` { + t.Fatalf("unexpected delegated output: %s", out) } } -func TestTransformPreservesBlockedHeadLinkForHydration(t *testing.T) { - target, _ := url.Parse("https://example.com/check") - out, err := Transform(strings.NewReader(``), Options{TabID: "tab", EntryID: "entry", TargetURL: target}) + +func TestTransformRequiresRustDocumentRewriter(t *testing.T) { + target, err := url.Parse("https://example.com/app/") if err != nil { t.Fatal(err) } - s := string(out) - for _, want := range []string{``, ``} { - if !strings.Contains(s, want) { - t.Fatalf("missing %q in %s", want, s) - } - } - for _, forbidden := range []string{` rel="preconnect"`, ` href="https://am.i.mullvad.net"`} { - if strings.Contains(s, forbidden) { - t.Fatalf("active blocked link attribute %q remained in %s", forbidden, s) - } - } - marker := strings.Index(s, ``) - link := strings.Index(s[marker:], ``) - if marker < 0 || link < 0 || closingMarker < 0 || link > closingMarker { - t.Fatalf("blocked head link no longer occupies the Svelte hydration slot: %s", s) + _, err = Transform(strings.NewReader(`raw`), Options{TargetURL: target}) + if !errors.Is(err, ErrMalformedHTML) { + t.Fatalf("Transform error = %v, want ErrMalformedHTML", err) } } -func TestTransformPreservesFragmentsAndBlocksExecutableNavigationSchemes(t *testing.T) { - target, _ := url.Parse("https://example.com/") - out, err := Transform(strings.NewReader(`hashjsdata
`), Options{TabID: "t", EntryID: "e", TargetURL: target}) +func TestRuntimePreludeIsSingleRuntimeAsset(t *testing.T) { + target, err := url.Parse(`https://example.com/path?q="&x=1`) if err != nil { t.Fatal(err) } - s := string(out) - if !strings.Contains(s, `href="#x"`) { - t.Fatalf("expected fragment link to remain local: %s", s) - } - for _, forbidden := range []string{`href="javascript:`, `href="DATA:`, `action="vbscript:`, `src="data:`} { - if strings.Contains(s, forbidden) { - t.Fatalf("executable navigation scheme remained in active attribute %q: %s", forbidden, s) - } - } - if strings.Contains(s, `https://attacker.test/`) { - t.Fatalf("target-supplied ZeroProxy control attribute remained: %s", s) - } - if got := strings.Count(s, `data-zp-blocked-url=`); got != 4 { - t.Fatalf("blocked URL marker count = %d, want 4 in %s", got, s) - } -} - -func TestRuntimePreludeEmbedsBootAsInertJSON(t *testing.T) { - target, _ := url.Parse(`https://example.com/path?q="&x=1`) - tabID := `tab"` - out, err := Transform(strings.NewReader(``), Options{ - TabID: tabID, + prelude := runtimePrelude(Options{ + TabID: `tab"`, EntryID: "entry", TargetURL: target, DocumentCookie: `a="`, - RuntimeToken: `tok<&>`, + RuntimeToken: "rt", }) - if err != nil { - t.Fatal(err) - } - s := string(out) - if strings.Contains(s, `Object.defineProperty(window,"__ZP_BOOT"`) { - t.Fatalf("boot config was embedded in executable JavaScript: %s", s) - } - const open = ``) - if end < 0 { - t.Fatalf("unterminated boot JSON script in %s", s) - } - bootRaw := s[start : start+end] - for _, unsafe := range []string{"<", ">", "&"} { - if strings.Contains(bootRaw, unsafe) { - t.Fatalf("boot JSON contains raw %q in %s", unsafe, bootRaw) - } - } - var boot map[string]string - if err := json.Unmarshal([]byte(bootRaw), &boot); err != nil { - t.Fatalf("boot JSON did not decode: %v in %s", err, bootRaw) - } - if boot["tabId"] != tabID || boot["targetUrl"] != target.String() || boot["runtimeToken"] != `tok<&>` { - t.Fatalf("boot JSON mismatch: %#v", boot) - } -} - -func TestTransformPreservesRawScriptAndStyleText(t *testing.T) { - target, _ := url.Parse("https://example.com/app/") - out, err := Transform(strings.NewReader(``), Options{TabID: "t", EntryID: "e", TargetURL: target}) - if err != nil { - t.Fatal(err) - } - s := string(out) - for _, want := range []string{`content:"x<&>"`, `__ZP_EXEC_INLINE_SCRIPT(`} { - if !strings.Contains(s, want) { - t.Fatalf("raw script/style text was escaped or corrupted; missing %q in %s", want, s) - } - } - if strings.Contains(s, """) || strings.Contains(s, "<&>") { - t.Fatalf("raw script/style text was entity-escaped: %s", s) - } -} - -func TestTransformRewritesPhase2ScriptSourcesAndHandlers(t *testing.T) { - target, _ := url.Parse("https://example.com/app/page.html") - out, err := Transform(strings.NewReader(``), Options{TabID: "tab", EntryID: "entry", TargetURL: target}) - if err != nil { - t.Fatal(err) - } - s := string(out) - for _, want := range []string{`/zp/api/script?`, `u=https%3A%2F%2Fexample.com%2Fapp.js`, `kind=classic`, `__ZP_EXEC_INLINE_SCRIPT(`, `__ZP_EXEC_INLINE_MODULE(`, `__ZP_EXEC_EVENT(`} { - if !strings.Contains(s, want) { - t.Fatalf("missing %q in %s", want, s) - } - } - for _, forbidden := range []string{`src="/app.js"`, `onclick="return location.href"`, `onLoad="location.href='/boot'"`, `onerror="Function(`} { - if strings.Contains(s, forbidden) { - t.Fatalf("unrewritten script source or handler remained: %q in %s", forbidden, s) - } - } -} -func TestTransformStripsIntegrityButBacksUpForRuntimeMasking(t *testing.T) { - target, _ := url.Parse("https://example.com/app/") - out, err := Transform(strings.NewReader(``), Options{TabID: "tab", EntryID: "entry", TargetURL: target}) - if err != nil { - t.Fatal(err) - } - s := string(out) - for _, want := range []string{`data-zp-integrity="sha384-script"`, `data-zp-integrity="sha256-style"`, `/zp/api/script?`, `href="https://example.com/app.css"`, `data-zp-target-url="https://example.com/app.css"`} { - if !strings.Contains(s, want) { - t.Fatalf("missing %q in %s", want, s) - } - } - for _, forbidden := range []string{` integrity="sha384-script"`, ` integrity="sha256-style"`, `data-zp-integrity="attacker"`, `href="/app.css"`} { - if strings.Contains(s, forbidden) { - t.Fatalf("forbidden integrity marker %q remained in %s", forbidden, s) - } - } -} - -func TestTransformAbsolutizesPassiveSubresources(t *testing.T) { - target, _ := url.Parse("https://example.com/app/page.html") - out, err := Transform(strings.NewReader(``), Options{TabID: "tab", EntryID: "entry", TargetURL: target}) - if err != nil { - t.Fatal(err) + if !strings.Contains(prelude, "runtime-prelude.js") { + t.Fatalf("missing runtime prelude asset: %s", prelude) } - s := string(out) - for _, want := range []string{`src="https://example.com/logo.png"`, `poster="https://example.com/app/poster.jpg"`, `src="https://example.com/media.webm"`, `src="data:image/png;base64,AAAA"`} { - if !strings.Contains(s, want) { - t.Fatalf("missing %q in %s", want, s) - } + if strings.Contains(prelude, "zp-core.js") || strings.Contains(prelude, "http-rewriter.js") { + t.Fatalf("unexpected multi-asset injection: %s", prelude) } - for _, forbidden := range []string{`src="/logo.png"`, `poster="poster.jpg"`, `src="../media.webm"`} { - if strings.Contains(s, forbidden) { - t.Fatalf("unresolved passive subresource %q remained in %s", forbidden, s) - } + if strings.Contains(prelude, `"# + ), + ContentType::Html, + ); + Ok(()) +} + +fn rewrite_event_handler_attrs( + el: &mut lol_html::html_content::Element<'_, '_, H>, +) -> lol_html::HandlerResult { + let handlers = attr_names(el) + .into_iter() + .filter(|name| event_handler_attr_kind(name) == "block") + .collect::>(); + for name in handlers { + let value = el.get_attribute(&name).unwrap_or_default(); + el.remove_attribute(&name); + el.set_attribute(&format!("data-zp-blocked-{name}"), &value)?; + } + Ok(()) +} + +fn rewrite_inline_style_attr( + el: &mut lol_html::html_content::Element<'_, '_, H>, + target_url: &str, + control_prefix: &str, +) -> lol_html::HandlerResult { + let Some(style) = el.get_attribute("style") else { + return Ok(()); + }; + el.set_attribute( + "style", + &rewrite_inline_style(&style, target_url, control_prefix), + )?; + Ok(()) +} + +fn rewrite_srcdoc_attr( + el: &mut lol_html::html_content::Element<'_, '_, H>, + tag: &str, + runtime_prelude: &str, +) -> lol_html::HandlerResult { + if tag != "iframe" && tag != "frame" { + return Ok(()); + } + let Some(srcdoc) = el.get_attribute("srcdoc") else { + return Ok(()); + }; + el.set_attribute("srcdoc", &format!("{runtime_prelude}{srcdoc}"))?; + Ok(()) +} + +fn attr_names( + el: &lol_html::html_content::Element<'_, '_, H>, +) -> Vec { + el.attributes().iter().map(|attr| attr.name()).collect() +} + +fn rewrite_script_attrs( + el: &mut lol_html::html_content::Element<'_, '_, H>, + target_url: &str, + control_prefix: &str, + tab_id: &str, + runtime_token: &str, +) -> lol_html::HandlerResult { + drop_control_attrs(el); + backup_masked_attrs(el, true)?; + let script_kind = script_type_kind(&el.get_attribute("type").unwrap_or_default()); + if script_kind == "speculationrules" { + el.remove(); + return Ok(()); + } + if let Some(src) = el.get_attribute("src") { + if matches!(script_kind, "classic" | "module") { + let rewritten = js::module_urls::script_url( + &src, + script_kind, + target_url, + control_prefix, + tab_id, + runtime_token, + ); + el.set_attribute("src", &rewritten.url)?; + if rewritten.ok { + el.set_attribute("data-zp-target-url", &rewritten.target)?; + } else if !src.trim().is_empty() { + el.set_attribute("data-zp-blocked-url", src.trim())?; + } + } + } + if matches!(script_kind, "classic" | "module") { + el.set_attribute("nonce", "zp")?; + if el.get_attribute("src").unwrap_or_default().is_empty() { + el.set_attribute("data-zp-static-script", "1")?; + } + } + Ok(()) +} + +fn rewrite_style_attrs( + el: &mut lol_html::html_content::Element<'_, '_, H>, + target_url: &str, + control_prefix: &str, +) -> lol_html::HandlerResult { + drop_control_attrs(el); + backup_masked_attrs(el, false)?; + if let Some(style) = el.get_attribute("style") { + el.set_attribute( + "style", + &rewrite_inline_style(&style, target_url, control_prefix), + )?; + } + Ok(()) +} + +fn backup_masked_attrs( + el: &mut lol_html::html_content::Element<'_, '_, H>, + script: bool, +) -> lol_html::HandlerResult { + if let Some(value) = el.get_attribute("integrity") { + el.remove_attribute("integrity"); + el.set_attribute("data-zp-integrity", &value)?; + } + if script { + if let Some(value) = el.get_attribute("nonce") { + el.remove_attribute("nonce"); + if !value.trim().is_empty() && value != "zp" { + el.set_attribute("data-zp-target-nonce", &value)?; + } + } + } + Ok(()) +} + +fn script_body_kind( + el: &lol_html::html_content::Element<'_, '_, H>, +) -> RawTextKind { + if !el.get_attribute("src").unwrap_or_default().is_empty() { + return RawTextKind::Pass; + } + match script_type_kind(&el.get_attribute("type").unwrap_or_default()) { + "classic" => RawTextKind::Script("classic".to_string()), + "module" => RawTextKind::Script("module".to_string()), + "importmap" => RawTextKind::ImportMap, + _ => RawTextKind::Pass, + } +} + +fn script_raw_text_kind( + el: &lol_html::html_content::Element<'_, '_, H>, +) -> Option { + let kind = script_body_kind(el); + if matches!(kind, RawTextKind::Pass) { + None + } else { + Some(kind) + } +} + +fn rewrite_raw_text_chunk( + txt: &mut lol_html::html_content::TextChunk<'_>, + states: &Rc>>, + target_url: &str, + control_prefix: &str, + tab_id: &str, + runtime_token: &str, +) -> lol_html::HandlerResult { + let mut states = states.borrow_mut(); + let Some(state) = states.front_mut() else { + return Ok(()); + }; + if matches!(state.kind, RawTextKind::Pass) { + if txt.last_in_text_node() { + states.pop_front(); + } + return Ok(()); + } + state.text.push_str(txt.as_str()); + if !txt.last_in_text_node() { + txt.remove(); + return Ok(()); + } + let rewritten = match &state.kind { + RawTextKind::Script(kind) => rewrite_inline_script( + &state.text, + kind, + target_url, + control_prefix, + tab_id, + runtime_token, + ), + RawTextKind::Style => rewrite_inline_style(&state.text, target_url, control_prefix), + RawTextKind::ImportMap => import_map::rewrite( + &state.text, + target_url, + tab_id, + runtime_token, + control_prefix, + ), + RawTextKind::Pass => state.text.clone(), + }; + txt.replace(&rewritten, ContentType::Html); + states.pop_front(); + Ok(()) +} + +fn rewrite_inline_script( + source: &str, + kind: &str, + target_url: &str, + control_prefix: &str, + tab_id: &str, + runtime_token: &str, +) -> String { + if source.trim().is_empty() { + return String::new(); + } + let module = kind == "module"; + let ctx = RewriteContext::new(target_url, control_prefix, tab_id, runtime_token); + match js::swc_rewriter::rewrite_script(source, module, ctx) { + Ok(code) => escape_inline_script_sentinel(&code), + Err(_) => block_script_source(), + } +} + +fn rewrite_inline_style(source: &str, target_url: &str, control_prefix: &str) -> String { + if source.trim().is_empty() { + return String::new(); + } + css::rewrite(source, target_url, control_prefix).unwrap_or_default() +} + +fn block_script_source() -> String { + "throw new DOMException('Blocked by ZeroProxy rewrite policy','NotSupportedError');".to_string() +} + +fn escape_inline_script_sentinel(code: &str) -> String { + let mut out = String::new(); + let lower = code.to_ascii_lowercase(); + let mut start = 0usize; + while let Some(offset) = lower[start..].find("( + el: &mut lol_html::html_content::Element<'_, '_, H>, +) { + for attr in [ + "data-zp-target-url", + "data-zp-target-srcset", + "data-zp-blocked-url", + "data-zp-blocked-rel", + "data-zp-integrity", + "data-zp-target-nonce", + "data-zp-blocked-srcset", + ] { + el.remove_attribute(attr); + } +} + +fn rewrite_meta_or_blocked_element( + el: &mut lol_html::html_content::Element<'_, '_, H>, + tag: &str, +) -> Result> { + if tag == "meta" + && meta_policy_kind(&el.get_attribute("http-equiv").unwrap_or_default()) == "drop" + { + el.remove(); + return Ok(true); + } + match blocked_element_kind(tag) { + "object" | "embed" => { + el.replace(&blocked_placeholder(tag), ContentType::Html); + Ok(true) + } + _ => Ok(false), + } +} + +fn blocked_placeholder(kind: &str) -> String { + format!( + r#"
ZeroProxy blocked {} content
"#, + escape_html_attr(kind), + escape_html_text(kind) + ) +} + +fn escape_html_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +fn escape_html_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn rewrite_link_attrs( + el: &mut lol_html::html_content::Element<'_, '_, H>, + target_url: &str, + control_prefix: &str, +) -> lol_html::HandlerResult { + let rel = el.get_attribute("rel").unwrap_or_default(); + match link_rel_kind(&rel) { + "blocked" => rewrite_blocked_link(el, &rel), + "icon" => rewrite_icon_link(el, target_url, control_prefix), + "stylesheet" => rewrite_stylesheet_link(el, target_url, control_prefix), + _ => Ok(()), + } +} + +fn rewrite_blocked_link( + el: &mut lol_html::html_content::Element<'_, '_, H>, + rel: &str, +) -> lol_html::HandlerResult { + let href = el.get_attribute("href").unwrap_or_default(); + el.remove_attribute("rel"); + el.remove_attribute("href"); + el.set_attribute("data-zp-blocked-rel", rel)?; + if !href.trim().is_empty() { + el.set_attribute("data-zp-blocked-url", href.trim())?; + } + Ok(()) +} + +fn rewrite_icon_link( + el: &mut lol_html::html_content::Element<'_, '_, H>, + target_url: &str, + control_prefix: &str, +) -> lol_html::HandlerResult { + let Some(raw) = el.get_attribute("href") else { + return Ok(()); + }; + let target = resolve_target_url(&raw, target_url, control_prefix); + el.set_attribute("href", "data:application/x-zeroproxy-icon,1")?; + if target.ok { + el.set_attribute("data-zp-target-url", &target.target)?; + return Ok(()); + } + if !raw.trim().is_empty() { + el.set_attribute("data-zp-blocked-url", raw.trim())?; + } + Ok(()) +} + +fn rewrite_stylesheet_link( + el: &mut lol_html::html_content::Element<'_, '_, H>, + target_url: &str, + control_prefix: &str, +) -> lol_html::HandlerResult { + let Some(raw) = el.get_attribute("href") else { + return Ok(()); + }; + let out = fetch_url(&raw, target_url, control_prefix); + if out.ok { + el.set_attribute("href", &out.url)?; + el.set_attribute("data-zp-target-url", &out.target)?; + return Ok(()); + } + if !raw.trim().is_empty() { + el.set_attribute("href", &out.url)?; + el.set_attribute("data-zp-blocked-url", raw.trim())?; + } + Ok(()) +} + +fn rewrite_passive_attr( + el: &mut lol_html::html_content::Element<'_, '_, H>, + attr: &str, + target_url: &str, + control_prefix: &str, +) -> lol_html::HandlerResult { + let Some(raw) = el.get_attribute(attr) else { + return Ok(()); + }; + let out = fetch_url(&raw, target_url, control_prefix); + if out.ok { + el.set_attribute(attr, &out.url)?; + el.set_attribute("data-zp-target-url", &out.target)?; + return Ok(()); + } + el.set_attribute(attr, &out.url)?; + el.set_attribute("data-zp-blocked-url", &raw)?; + Ok(()) +} + +fn rewrite_navigation_attr( + el: &mut lol_html::html_content::Element<'_, '_, H>, + attr: &str, + target_url: &str, + control_prefix: &str, + servers: &[String], +) -> lol_html::HandlerResult { + let Some(raw) = el.get_attribute(attr) else { + return Ok(()); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return Ok(()); + } + let target = resolve_target_url(&raw, target_url, control_prefix); + if target.ok { + match share_url::new_with_servers(&target.target, servers) { + Ok(route) => { + el.set_attribute(attr, &route)?; + el.set_attribute("data-zp-target-url", &target.target)?; + } + Err(_) => { + el.set_attribute(attr, "#")?; + el.set_attribute("data-zp-blocked-url", trimmed)?; + } + } + return Ok(()); + } + el.set_attribute(attr, "#")?; + el.set_attribute("data-zp-blocked-url", trimmed)?; + Ok(()) +} + +fn rewrite_srcset_attr( + el: &mut lol_html::html_content::Element<'_, '_, H>, + target_url: &str, + control_prefix: &str, +) -> lol_html::HandlerResult { + let Some(raw) = el.get_attribute("srcset") else { + return Ok(()); + }; + let out = srcset(&raw, target_url, control_prefix); + if out.ok { + el.set_attribute("srcset", &out.url)?; + el.set_attribute("data-zp-target-srcset", &out.target)?; + return Ok(()); + } + el.set_attribute("srcset", &format!("{control_prefix}error/POLICY_BLOCKED"))?; + el.set_attribute("data-zp-blocked-srcset", &raw)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeSet, VecDeque}; + + use lol_html::{element, rewrite_str, text, RewriteStrSettings}; + use serde_json::Value; + + use super::{rewrite_document, DocumentOptions}; + + const INJECTION_INVENTORY: &str = + include_str!("../../../internal/htmltx/testdata/injection_inventory.json"); + + struct Inventory { + scripts: BTreeSet, + control_attrs: BTreeSet, + srcdocs: Vec, + } + + impl Inventory { + fn new() -> Self { + Self { + scripts: BTreeSet::new(), + control_attrs: BTreeSet::new(), + srcdocs: Vec::new(), + } + } + + fn extend(&mut self, other: Inventory) { + self.scripts.extend(other.scripts); + self.control_attrs.extend(other.control_attrs); + self.srcdocs.extend(other.srcdocs); + } + } + + fn collect_inventory(source: &str, scope: &str) -> Inventory { + let scripts = std::rc::Rc::new(std::cell::RefCell::new(BTreeSet::new())); + let attrs = std::rc::Rc::new(std::cell::RefCell::new(BTreeSet::new())); + let srcdocs = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let script_text = + std::rc::Rc::new(std::cell::RefCell::new(VecDeque::>::new())); + let scope_for_script = scope.to_string(); + let scope_for_text = scope.to_string(); + let scope_for_attr = scope.to_string(); + let scripts_for_element = std::rc::Rc::clone(&scripts); + let scripts_for_text = std::rc::Rc::clone(&scripts); + let attrs_for_element = std::rc::Rc::clone(&attrs); + let srcdocs_for_element = std::rc::Rc::clone(&srcdocs); + let script_text_for_element = std::rc::Rc::clone(&script_text); + let script_text_for_text = std::rc::Rc::clone(&script_text); + + rewrite_str( + source, + RewriteStrSettings { + element_content_handlers: vec![ + element!("script", move |el| { + if let Some(src) = el.get_attribute("src") { + if src == "/zp/assets/runtime-prelude.js" { + scripts_for_element + .borrow_mut() + .insert(format!("{scope_for_script}|src|{src}")); + } + } else { + script_text_for_element + .borrow_mut() + .push_back(Some(String::new())); + } + Ok(()) + }), + text!("script", move |txt| { + let mut states = script_text_for_text.borrow_mut(); + let Some(state) = states.front_mut() else { + return Ok(()); + }; + if let Some(buf) = state { + buf.push_str(txt.as_str()); + } + if txt.last_in_text_node() { + if let Some(Some(body)) = states.pop_front() { + let inline = if body.contains("__ZP_BOOT") { + Some("boot-config") + } else if body.contains("__ZP_SET_BASE") { + Some("base-sync") + } else { + None + }; + if let Some(inline) = inline { + scripts_for_text + .borrow_mut() + .insert(format!("{scope_for_text}|inline|{inline}")); + } + } + } + Ok(()) + }), + element!("*", move |el| { + for attr in el.attributes() { + let name = attr.name(); + if name.starts_with("data-zp-") { + attrs_for_element.borrow_mut().insert(format!( + "{}|{}|{}|{}", + scope_for_attr, + el.tag_name(), + name, + attr.value() + )); + } + } + if matches!(el.tag_name().as_str(), "iframe" | "frame") { + if let Some(srcdoc) = el.get_attribute("srcdoc") { + srcdocs_for_element + .borrow_mut() + .push(srcdoc.replace(""", "\"")); + } + } + Ok(()) + }), + ], + ..RewriteStrSettings::new() + }, + ) + .expect("inventory scan should parse rewritten HTML"); + + Inventory { + scripts: std::rc::Rc::try_unwrap(scripts) + .expect("scripts still shared") + .into_inner(), + control_attrs: std::rc::Rc::try_unwrap(attrs) + .expect("attrs still shared") + .into_inner(), + srcdocs: std::rc::Rc::try_unwrap(srcdocs) + .expect("srcdocs still shared") + .into_inner(), + } + } + + fn expected_inventory() -> Inventory { + let json: Value = + serde_json::from_str(INJECTION_INVENTORY).expect("inventory JSON should be valid"); + let mut inv = Inventory::new(); + for item in json["scripts"].as_array().expect("scripts array") { + let scope = item["scope"].as_str().expect("script scope"); + if let Some(src) = item["src"].as_str() { + inv.scripts.insert(format!("{scope}|src|{src}")); + } else { + inv.scripts.insert(format!( + "{}|inline|{}", + scope, + item["inline"].as_str().expect("inline script label") + )); + } + } + for item in json["controlAttrs"].as_array().expect("controlAttrs array") { + inv.control_attrs.insert(format!( + "{}|{}|{}|{}", + item["scope"].as_str().expect("attr scope"), + item["tag"].as_str().expect("attr tag"), + item["name"].as_str().expect("attr name"), + item["value"].as_str().expect("attr value") + )); + } + inv + } + + #[test] + fn rewrites_passive_subresources_with_lol_html() { + let out = rewrite_document( + r#""#, + DocumentOptions { + target_url: "https://example.com/app/page.html", + control_prefix: "/zp/", + servers: &[], + runtime_prelude: "", + tab_id: "", + runtime_token: "", + }, + ) + .expect("document rewrite should succeed"); + + for want in [ + r#"src="/zp/api/fetch?url=https%3A%2F%2Fexample.com%2Flogo.png""#, + r#"data-zp-target-url="https://example.com/logo.png""#, + r#"srcset="/zp/api/fetch?url=https%3A%2F%2Fexample.com%2Fsmall.png 1x, /zp/api/fetch?url=https%3A%2F%2Fexample.com%2Flarge.png 2x""#, + r#"data-zp-target-srcset="https://example.com/small.png 1x, https://example.com/large.png 2x""#, + r#"poster="/zp/api/fetch?url=https%3A%2F%2Fexample.com%2Fapp%2Fposter.jpg""#, + r#"src="/zp/api/fetch?url=https%3A%2F%2Fexample.com%2Fmedia.webm""#, + r#"href="/zp/api/fetch?url=https%3A%2F%2Fexample.com%2Ficons.svg#icon-a""#, + r#"data-zp-target-url="https://example.com/icons.svg#icon-a""#, + r#"src="/zp/error/POLICY_BLOCKED""#, + r#"data-zp-blocked-url="data:image/png;base64,AAAA""#, + ] { + assert!(out.contains(want), "missing {want} in {out}"); + } + + for forbidden in [ + r#"src="/logo.png""#, + r#"poster="poster.jpg""#, + r#"src="../media.webm""#, + ] { + assert!(!out.contains(forbidden), "raw attribute survived in {out}"); + } + } + + #[test] + fn rewrites_link_policy_with_lol_html() { + let out = rewrite_document( + r#""#, + DocumentOptions { + target_url: "https://example.com/app/page.html", + control_prefix: "/zp/", + servers: &[], + runtime_prelude: "", + tab_id: "", + runtime_token: "", + }, + ) + .expect("document rewrite should succeed"); + + for want in [ + r#"data-zp-blocked-rel="preconnect""#, + r#"data-zp-blocked-url="https://cdn.example/""#, + r#"href="data:application/x-zeroproxy-icon,1""#, + r#"data-zp-target-url="https://example.com/favicon.ico""#, + r#"data-zp-target-url="https://example.com/app/touch.png""#, + r#"href="/zp/api/fetch?url=https%3A%2F%2Fexample.com%2Fapp.css""#, + r#"data-zp-target-url="https://example.com/app.css""#, + r#"href="/zp/error/POLICY_BLOCKED""#, + r#"data-zp-blocked-url="data:text/css,x""#, + ] { + assert!(out.contains(want), "missing {want} in {out}"); + } + + for forbidden in [ + r#"

after

"#, + DocumentOptions { + target_url: "https://example.com/app/page.html", + control_prefix: "/zp/", + servers: &[], + runtime_prelude: "", + tab_id: "", + runtime_token: "", + }, + ) + .expect("document rewrite should succeed"); + + for want in [ + r#""#, + r#"data-zp-blocked="object""#, + "ZeroProxy blocked object content", + r#"data-zp-blocked="embed""#, + "ZeroProxy blocked embed content", + "

after

", + ] { + assert!(out.contains(want), "missing {want} in {out}"); + } + + for forbidden in [ + "http-equiv=\"refresh\"", + "Content-Security-Policy", + "policy.example", + "nhash
bad"##, + DocumentOptions { + target_url: "https://example.com/app/page.html", + control_prefix: "/zp/", + servers: &[], + runtime_prelude: "", + tab_id: "", + runtime_token: "", + }, + ) + .expect("document rewrite should succeed"); + + for want in [ + r#"href="/zp/p/"#, + r#"action="/zp/p/"#, + r#"formaction="/zp/p/"#, + r#"src="/zp/p/"#, + "#k=", + r#"data-zp-target-url="https://example.com/next""#, + r#"data-zp-target-url="https://example.com/app/submit""#, + r#"data-zp-target-url="https://example.com/alt""#, + r#"data-zp-target-url="https://example.com/child""#, + r##"href="#x""##, + r##"href="#" data-zp-blocked-url="javascript:alert(1)""##, + r##"src="#" data-zp-blocked-url="data:text/html,frame""##, + ] { + assert!(out.contains(want), "missing {want} in {out}"); + } + + for forbidden in [ + r#"https://attacker.test/"#, + r#"href="/next""#, + r#"action="submit""#, + r#"formaction="/alt""#, + r#"src="/child""#, + r#"href="javascript:"#, + r#"src="data:"#, + ] { + assert!( + !out.contains(forbidden), + "raw navigation policy survived {forbidden} in {out}" + ); + } + } + + #[test] + fn injects_runtime_prelude_once_with_lol_html() { + let prelude = r#""#; + for source in [ + "xok", + "ok", + "

fragment

", + ] { + let out = rewrite_document( + source, + DocumentOptions { + target_url: "https://example.com/app/page.html", + control_prefix: "/zp/", + servers: &[], + runtime_prelude: prelude, + tab_id: "", + runtime_token: "", + }, + ) + .expect("document rewrite should succeed"); + assert_eq!( + out.matches(prelude).count(), + 1, + "bad prelude count in {out}" + ); + } + } + + #[test] + fn injection_inventory_matches_lol_html_document_snapshot() { + let prelude = r#""#; + let out = rewrite_document( + r#"next
"#, + DocumentOptions { + target_url: "https://example.com/app/page.html", + control_prefix: "/zp/", + servers: &[], + runtime_prelude: prelude, + tab_id: "", + runtime_token: "", + }, + ) + .expect("document rewrite should succeed"); + + let mut observed = collect_inventory(&out, "document"); + for srcdoc in observed.srcdocs.clone() { + observed.extend(collect_inventory(&srcdoc, "document/srcdoc")); + } + + let expected = expected_inventory(); + assert_eq!( + observed.scripts, expected.scripts, + "injected script inventory changed in {out}" + ); + assert_eq!( + observed.control_attrs, expected.control_attrs, + "control attribute inventory changed in {out}" + ); + } + + #[test] + fn rewrites_script_style_importmap_and_srcdoc_with_lol_html() { + let prelude = r#""#; + let out = rewrite_document( + r#""#, + DocumentOptions { + target_url: "https://example.com/app/page.html", + control_prefix: "/zp/", + servers: &[], + runtime_prelude: prelude, + tab_id: "tab-1", + runtime_token: "rt-1", + }, + ) + .expect("document rewrite should succeed"); + + for want in [ + r#"src="/zp/api/script?kind=classic&u=https%3A%2F%2Fexample.com%2Fapp.js&tab=tab-1&rt=rt-1""#, + r#"data-zp-target-url="https://example.com/app.js""#, + r#"data-zp-integrity="sha384-i""#, + r#"data-zp-target-nonce="target-nonce""#, + r#"nonce="zp""#, + r#"data-zp-static-script="1""#, + r#"<\/script>"#, + r#"/zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fapp%2Fdep.js&tab=tab-1&rt=rt-1"#, + r#""a":"/zp/api/script?kind=module\u0026rt=rt-1\u0026tab=tab-1\u0026u=https%3A%2F%2Fexample.com%2Fapp%2Fa.js""#, + r#"url("/zp/api/fetch?url=https%3A%2F%2Fexample.com%2Fbg.png")"#, + r#"data-zp-blocked-onload="location.href='/boot'""#, + r#"data-zp-blocked-onclick="return location.href""#, + r#"srcdoc=""#, + ] { + assert!(out.contains(want), "missing {want} in {out}"); + } + + for forbidden in [ + r#" onload="#, + r#" onclick="#, + r#" integrity="sha384-i""#, + r#" nonce="target-nonce""#, + r#"src="/app.js""#, + r#"href="""#, + ] { + assert!( + !out.contains(forbidden), + "raw script/style policy survived {forbidden} in {out}" + ); + } + } +} diff --git a/rewriter-rs/src/html/mod.rs b/rewriter-rs/src/html/mod.rs new file mode 100644 index 0000000..e459af8 --- /dev/null +++ b/rewriter-rs/src/html/mod.rs @@ -0,0 +1,628 @@ +pub mod document; + +pub(crate) struct URLPolicy { + pub(crate) ok: bool, + pub(crate) url: String, + pub(crate) target: String, + pub(crate) error: String, +} + +pub(crate) fn fetch_url(raw: &str, target_url: &str, control_prefix: &str) -> URLPolicy { + let blocked = || URLPolicy { + ok: false, + url: format!("{}error/POLICY_BLOCKED", control_prefix), + target: String::new(), + error: "POLICY_BLOCKED".to_string(), + }; + let text = raw.trim(); + if text.is_empty() || text.starts_with('#') || is_executable_scheme(text) { + return blocked(); + } + let mut abs = match resolve_http_target(target_url, text) { + Some(value) => value, + None => return blocked(), + }; + let target = abs.to_string(); + let fragment = abs.fragment().map(str::to_string); + abs.set_fragment(None); + let mut out = format!( + "{}api/fetch?url={}", + control_prefix, + percent_encode(abs.to_string()) + ); + if let Some(value) = fragment { + out.push('#'); + out.push_str(&value); + } + URLPolicy { + ok: true, + url: out, + target, + error: String::new(), + } +} + +pub(crate) fn srcset(raw: &str, target_url: &str, control_prefix: &str) -> URLPolicy { + let candidates = parse_srcset(raw); + if candidates.is_empty() { + return URLPolicy { + ok: false, + url: raw.to_string(), + target: raw.to_string(), + error: "UNCHANGED".to_string(), + }; + } + let mut out = Vec::with_capacity(candidates.len()); + let mut visible = Vec::with_capacity(candidates.len()); + let mut changed = false; + for candidate in candidates { + if candidate.url.is_empty() { + continue; + } + let rewritten = fetch_url(candidate.url, target_url, control_prefix); + if !rewritten.ok { + out.push(candidate.raw.clone()); + visible.push(candidate.raw); + continue; + } + out.push(join_srcset_candidate(&rewritten.url, candidate.descriptor)); + if rewritten.target.is_empty() { + visible.push(candidate.raw); + } else { + visible.push(join_srcset_candidate( + &rewritten.target, + candidate.descriptor, + )); + } + changed = true; + } + if !changed { + return URLPolicy { + ok: false, + url: raw.to_string(), + target: raw.to_string(), + error: "UNCHANGED".to_string(), + }; + } + URLPolicy { + ok: true, + url: out.join(", "), + target: visible.join(", "), + error: String::new(), + } +} + +pub(crate) fn target_url(raw: &str, target_url: &str, control_prefix: &str) -> URLPolicy { + let blocked = || URLPolicy { + ok: false, + url: format!("{}error/POLICY_BLOCKED", control_prefix), + target: String::new(), + error: "POLICY_BLOCKED".to_string(), + }; + let text = raw.trim(); + if text.is_empty() || text.starts_with('#') || is_executable_scheme(text) { + return blocked(); + } + let Some(abs) = resolve_http_target(target_url, text) else { + return blocked(); + }; + let target = abs.to_string(); + URLPolicy { + ok: true, + url: target.clone(), + target, + error: String::new(), + } +} + +pub(crate) fn link_rel_kind(rel: &str) -> &'static str { + let mut kind = "pass"; + for token in rel + .trim() + .split(|c: char| c.is_ascii_whitespace() || c == ',') + .filter(|token| !token.is_empty()) + { + let token = token.to_ascii_lowercase(); + if is_blocked_link_rel_token(&token) { + return "blocked"; + } + if is_icon_link_rel_token(&token) { + kind = "icon"; + continue; + } + if token == "stylesheet" && kind == "pass" { + kind = "stylesheet"; + } + } + kind +} + +pub(crate) fn blocked_element_kind(tag: &str) -> &'static str { + match tag.trim().to_ascii_lowercase().as_str() { + "object" => "object", + "embed" => "embed", + _ => "pass", + } +} + +pub(crate) fn meta_policy_kind(http_equiv: &str) -> &'static str { + match http_equiv.trim().to_ascii_lowercase().as_str() { + "refresh" | "content-security-policy" | "content-security-policy-report-only" => "drop", + _ => "pass", + } +} + +pub(crate) fn attr_policy_kind(tag: &str, key: &str) -> &'static str { + let tag = tag.trim().to_ascii_lowercase(); + match attr_local_name(key).as_str() { + "style" => "style", + "href" => match tag.as_str() { + "a" | "area" => "navigation", + "link" | "image" | "use" => "passive", + _ => "pass", + }, + "action" if tag == "form" => "navigation", + "formaction" if tag == "input" || tag == "button" => "navigation", + "src" => match tag.as_str() { + "iframe" | "frame" => "navigation", + "img" | "source" | "audio" | "video" | "track" | "input" => "passive", + _ => "pass", + }, + "poster" if tag == "video" => "passive", + "srcset" if tag == "img" || tag == "source" => "srcset", + _ => "pass", + } +} + +pub(crate) fn script_type_kind(script_type: &str) -> &'static str { + match script_type.trim().to_ascii_lowercase().as_str() { + "" + | "text/javascript" + | "application/javascript" + | "application/ecmascript" + | "text/ecmascript" => "classic", + "module" => "module", + "importmap" => "importmap", + "speculationrules" => "speculationrules", + _ => "pass", + } +} + +pub(crate) fn event_handler_attr_kind(attr_name: &str) -> &'static str { + let attr_name = attr_name.trim().to_ascii_lowercase(); + if attr_name.starts_with("on") && attr_name.len() > 2 { + "block" + } else { + "pass" + } +} + +fn attr_local_name(key: &str) -> String { + let key = key.trim().to_ascii_lowercase(); + match key.split_once(':') { + Some((_, local)) => local.to_string(), + None => key, + } +} + +fn is_blocked_link_rel_token(token: &str) -> bool { + matches!( + token, + "modulepreload" + | "preload" + | "prefetch" + | "preconnect" + | "dns-prefetch" + | "prerender" + | "manifest" + ) +} + +fn is_icon_link_rel_token(token: &str) -> bool { + matches!( + token, + "icon" + | "mask-icon" + | "apple-touch-icon" + | "apple-touch-icon-precomposed" + | "apple-touch-startup-image" + | "fluid-icon" + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SrcsetCandidate<'a> { + raw: String, + url: &'a str, + descriptor: &'a str, +} + +fn parse_srcset(raw: &str) -> Vec> { + let mut out = Vec::new(); + let mut rest = raw.trim(); + while !rest.is_empty() { + rest = trim_leading_html_space(rest); + if rest.is_empty() { + break; + } + let (candidate, next, more) = next_srcset_candidate(rest); + out.push(candidate); + if !more { + break; + } + rest = next; + } + out +} + +fn next_srcset_candidate(input: &str) -> (SrcsetCandidate<'_>, &str, bool) { + let url_end = srcset_url_end(input); + let mut candidate_end = url_end; + for (offset, ch) in input[url_end..].char_indices() { + if ch == ',' { + break; + } + candidate_end = url_end + offset + ch.len_utf8(); + } + if candidate_end < url_end { + candidate_end = url_end; + } + if input[url_end..].chars().all(|ch| ch != ',') { + candidate_end = input.len(); + } + let candidate = SrcsetCandidate { + raw: input[..candidate_end].trim().to_string(), + url: &input[..url_end], + descriptor: input[url_end..candidate_end].trim(), + }; + if candidate_end >= input.len() { + return (candidate, "", false); + } + (candidate, &input[candidate_end + 1..], true) +} + +fn srcset_url_end(input: &str) -> usize { + let lower = input.to_ascii_lowercase(); + if lower.starts_with("data:") { + for (idx, ch) in input.char_indices() { + if is_html_space(ch) { + return idx; + } + } + return input.len(); + } + for (idx, ch) in input.char_indices() { + if is_html_space(ch) || ch == ',' { + return idx; + } + } + input.len() +} + +fn trim_leading_html_space(input: &str) -> &str { + let mut start = 0; + for (idx, ch) in input.char_indices() { + if !is_html_space(ch) { + start = idx; + return &input[start..]; + } + start = idx + ch.len_utf8(); + } + &input[start..] +} + +fn is_html_space(ch: char) -> bool { + matches!(ch, ' ' | '\n' | '\t' | '\r' | '\u{000C}') +} + +fn join_srcset_candidate(url_part: &str, descriptor: &str) -> String { + let descriptor = descriptor.trim(); + if descriptor.is_empty() { + url_part.to_string() + } else { + format!("{url_part} {descriptor}") + } +} + +fn resolve_http_target(base: &str, raw: &str) -> Option { + let abs = absolute_url(base, raw)?; + if is_http_url(abs.as_str()) { + Some(abs) + } else { + None + } +} + +fn is_executable_scheme(spec: &str) -> bool { + if !has_scheme(spec) { + return false; + } + let scheme = spec.split_once(':').map(|(value, _)| value).unwrap_or(""); + scheme.eq_ignore_ascii_case("javascript") + || scheme.eq_ignore_ascii_case("data") + || scheme.eq_ignore_ascii_case("vbscript") +} + +fn absolute_url(base: &str, raw: &str) -> Option { + url::Url::parse(raw) + .or_else(|_| url::Url::parse(base).and_then(|base_url| base_url.join(raw))) + .ok() +} + +fn is_http_url(value: &str) -> bool { + value.starts_with("http://") || value.starts_with("https://") +} + +fn has_scheme(spec: &str) -> bool { + let mut chars = spec.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() => {} + _ => return false, + } + for c in chars { + if c == ':' { + return true; + } + if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') { + return false; + } + } + false +} + +fn percent_encode(input: String) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => { + out.push('%'); + out.push(hex(b >> 4)); + out.push(hex(b & 15)); + } + } + } + out +} + +fn hex(v: u8) -> char { + match v { + 0..=9 => (b'0' + v) as char, + _ => (b'A' + (v - 10)) as char, + } +} + +#[cfg(test)] +mod tests { + use super::{ + attr_policy_kind, blocked_element_kind, event_handler_attr_kind, fetch_url, link_rel_kind, + meta_policy_kind, parse_srcset, script_type_kind, srcset, target_url, SrcsetCandidate, + }; + + #[test] + fn rewrites_fetch_urls_without_leaking_fragments_to_network_target() { + let out = fetch_url( + "/icons.svg#icon-a", + "https://target.example/app/page.html", + "/zp/", + ); + assert!(out.ok, "rewrite failed: {}", out.error); + assert_eq!(out.target, "https://target.example/icons.svg#icon-a"); + assert_eq!( + out.url, + "/zp/api/fetch?url=https%3A%2F%2Ftarget.example%2Ficons.svg#icon-a" + ); + } + + #[test] + fn resolves_relative_fetch_urls() { + let out = fetch_url( + "../media.webm", + "https://target.example/app/page.html", + "/zp/", + ); + assert!(out.ok, "rewrite failed: {}", out.error); + assert_eq!(out.target, "https://target.example/media.webm"); + assert_eq!( + out.url, + "/zp/api/fetch?url=https%3A%2F%2Ftarget.example%2Fmedia.webm" + ); + } + + #[test] + fn rewrites_static_srcset_policy() { + let out = srcset( + "/small.png 1x, ../large.png 2x, data:image/png,AAAA 3x", + "https://target.example/app/page.html", + "/zp/", + ); + assert!(out.ok, "rewrite failed: {}", out.error); + assert_eq!( + out.url, + "/zp/api/fetch?url=https%3A%2F%2Ftarget.example%2Fsmall.png 1x, /zp/api/fetch?url=https%3A%2F%2Ftarget.example%2Flarge.png 2x, data:image/png,AAAA 3x" + ); + assert_eq!( + out.target, + "https://target.example/small.png 1x, https://target.example/large.png 2x, data:image/png,AAAA 3x" + ); + } + + #[test] + fn keeps_srcset_unchanged_without_rewritable_candidates() { + let out = srcset( + "data:image/png,AAAA 1x, javascript:alert(1) 2x", + "https://target.example/app/page.html", + "/zp/", + ); + assert!(!out.ok); + assert_eq!(out.url, "data:image/png,AAAA 1x, javascript:alert(1) 2x"); + } + + #[test] + fn parses_srcset_like_static_html_policy() { + let got = parse_srcset(" /a.png 1x, data:image/png,AAAA 2x, ../b.png "); + assert_eq!( + got, + vec![ + SrcsetCandidate { + raw: "/a.png 1x".to_string(), + url: "/a.png", + descriptor: "1x" + }, + SrcsetCandidate { + raw: "data:image/png,AAAA 2x".to_string(), + url: "data:image/png,AAAA", + descriptor: "2x" + }, + SrcsetCandidate { + raw: "../b.png".to_string(), + url: "../b.png", + descriptor: "" + }, + ] + ); + } + + #[test] + fn blocks_non_http_fetch_urls() { + for raw in [ + "", + "#local", + "javascript:alert(1)", + "data:image/png,0", + "mailto:a@b", + ] { + let out = fetch_url(raw, "https://target.example/app/page.html", "/zp/"); + assert!(!out.ok, "{raw} unexpectedly rewrote to {}", out.url); + assert_eq!(out.url, "/zp/error/POLICY_BLOCKED"); + } + } + + #[test] + fn resolves_visible_target_urls_for_static_html_policy() { + let out = target_url("touch.png", "https://target.example/app/page.html", "/zp/"); + assert!(out.ok, "rewrite failed: {}", out.error); + assert_eq!(out.target, "https://target.example/app/touch.png"); + assert_eq!(out.url, out.target); + } + + #[test] + fn blocks_visible_target_urls_without_http_targets() { + for raw in [ + "", + "#local", + "javascript:alert(1)", + "data:image/png,0", + "mailto:a@b", + ] { + let out = target_url(raw, "https://target.example/app/page.html", "/zp/"); + assert!(!out.ok, "{raw} unexpectedly resolved to {}", out.target); + assert_eq!(out.url, "/zp/error/POLICY_BLOCKED"); + } + } + + #[test] + fn classifies_static_link_rel_policy() { + for rel in [ + "preload", + "modulepreload stylesheet", + "icon, preconnect", + "dns-prefetch", + "manifest", + ] { + assert_eq!(link_rel_kind(rel), "blocked", "{rel}"); + } + for rel in [ + "icon", + "mask-icon", + "apple-touch-icon", + "apple-touch-icon-precomposed", + "apple-touch-startup-image", + "fluid-icon", + ] { + assert_eq!(link_rel_kind(rel), "icon", "{rel}"); + } + assert_eq!(link_rel_kind("stylesheet"), "stylesheet"); + assert_eq!(link_rel_kind("alternate stylesheet"), "stylesheet"); + assert_eq!(link_rel_kind("canonical"), "pass"); + assert_eq!(link_rel_kind(""), "pass"); + } + + #[test] + fn classifies_static_blocked_element_policy() { + assert_eq!(blocked_element_kind("object"), "object"); + assert_eq!(blocked_element_kind(" EMBED "), "embed"); + assert_eq!(blocked_element_kind("iframe"), "pass"); + } + + #[test] + fn classifies_static_meta_policy() { + assert_eq!(meta_policy_kind("refresh"), "drop"); + assert_eq!(meta_policy_kind(" Content-Security-Policy "), "drop"); + assert_eq!( + meta_policy_kind("content-security-policy-report-only"), + "drop" + ); + assert_eq!(meta_policy_kind("viewport"), "pass"); + assert_eq!(meta_policy_kind(""), "pass"); + } + + #[test] + fn classifies_static_attr_policy() { + for (tag, key) in [ + ("a", "href"), + ("area", "href"), + ("form", "action"), + ("input", "formaction"), + ("button", "formaction"), + ("iframe", "src"), + ("frame", "src"), + ] { + assert_eq!(attr_policy_kind(tag, key), "navigation", "{tag}.{key}"); + } + for (tag, key) in [ + ("link", "href"), + ("image", "xlink:href"), + ("use", "href"), + ("img", "src"), + ("source", "src"), + ("audio", "src"), + ("video", "poster"), + ("track", "src"), + ] { + assert_eq!(attr_policy_kind(tag, key), "passive", "{tag}.{key}"); + } + assert_eq!(attr_policy_kind("img", "srcset"), "srcset"); + assert_eq!(attr_policy_kind("source", "srcset"), "srcset"); + assert_eq!(attr_policy_kind("div", "style"), "style"); + assert_eq!(attr_policy_kind("script", "src"), "pass"); + assert_eq!(attr_policy_kind("link", "rel"), "pass"); + } + + #[test] + fn classifies_static_script_type_policy() { + for value in [ + "", + " text/javascript ", + "application/javascript", + "application/ecmascript", + "text/ecmascript", + ] { + assert_eq!(script_type_kind(value), "classic", "{value}"); + } + assert_eq!(script_type_kind("module"), "module"); + assert_eq!(script_type_kind(" importmap "), "importmap"); + assert_eq!(script_type_kind("speculationrules"), "speculationrules"); + assert_eq!(script_type_kind("application/json"), "pass"); + } + + #[test] + fn classifies_static_event_handler_attr_policy() { + assert_eq!(event_handler_attr_kind("onclick"), "block"); + assert_eq!(event_handler_attr_kind(" onLoad "), "block"); + assert_eq!(event_handler_attr_kind("on"), "pass"); + assert_eq!(event_handler_attr_kind("data-onclick"), "pass"); + } +} diff --git a/rewriter-rs/src/import_map/address.rs b/rewriter-rs/src/import_map/address.rs new file mode 100644 index 0000000..2b3ed4b --- /dev/null +++ b/rewriter-rs/src/import_map/address.rs @@ -0,0 +1,35 @@ +use url::{form_urlencoded, Url}; + +pub(crate) fn rewrite_address( + raw: &str, + base_url: &str, + tab_id: &str, + runtime_token: &str, + control_prefix: &str, +) -> String { + let Some(abs) = absolute_url(raw, base_url) else { + return policy_blocked(control_prefix); + }; + if abs.scheme() != "http" && abs.scheme() != "https" { + return policy_blocked(control_prefix); + } + + let mut query = form_urlencoded::Serializer::new(String::new()); + query.append_pair("kind", "module"); + query.append_pair("rt", runtime_token); + query.append_pair("tab", tab_id); + query.append_pair("u", abs.as_str()); + format!("{}api/script?{}", control_prefix, query.finish()) +} + +fn absolute_url(raw: &str, base_url: &str) -> Option { + let trimmed = raw.trim(); + match Url::parse(trimmed) { + Ok(url) => Some(url), + Err(_) => Url::parse(base_url).ok()?.join(trimmed).ok(), + } +} + +fn policy_blocked(control_prefix: &str) -> String { + format!("{}error/POLICY_BLOCKED", control_prefix) +} diff --git a/rewriter-rs/src/import_map/document.rs b/rewriter-rs/src/import_map/document.rs new file mode 100644 index 0000000..11d2bd5 --- /dev/null +++ b/rewriter-rs/src/import_map/document.rs @@ -0,0 +1,97 @@ +use serde_json::{Map, Value}; + +use super::address::rewrite_address; + +pub(crate) fn rewrite_imports( + map: &mut Map, + base_url: &str, + tab_id: &str, + runtime_token: &str, + control_prefix: &str, +) { + if let Some(imports) = map.get_mut("imports").and_then(Value::as_object_mut) { + rewrite_addresses(imports, base_url, tab_id, runtime_token, control_prefix); + } + if let Some(scopes) = map.get("scopes").and_then(Value::as_object) { + map.insert( + "scopes".to_string(), + Value::Object(rewrite_scopes( + scopes, + base_url, + tab_id, + runtime_token, + control_prefix, + )), + ); + } +} + +fn rewrite_addresses( + addresses: &mut Map, + base_url: &str, + tab_id: &str, + runtime_token: &str, + control_prefix: &str, +) { + for value in addresses.values_mut() { + if let Some(raw) = value.as_str() { + *value = Value::String(rewrite_address( + raw, + base_url, + tab_id, + runtime_token, + control_prefix, + )); + } + } +} + +fn rewrite_scopes( + scopes: &Map, + base_url: &str, + tab_id: &str, + runtime_token: &str, + control_prefix: &str, +) -> Map { + let mut next = Map::new(); + for (scope, raw_entries) in scopes { + let scope_key = rewrite_address(scope, base_url, tab_id, runtime_token, control_prefix); + let mut out = Map::new(); + if let Some(entries) = raw_entries.as_object() { + rewrite_scope_entries( + &mut out, + entries, + base_url, + tab_id, + runtime_token, + control_prefix, + ); + } + next.insert(scope_key, Value::Object(out)); + } + next +} + +fn rewrite_scope_entries( + out: &mut Map, + entries: &Map, + base_url: &str, + tab_id: &str, + runtime_token: &str, + control_prefix: &str, +) { + for (key, value) in entries { + if let Some(raw) = value.as_str() { + out.insert( + key.clone(), + Value::String(rewrite_address( + raw, + base_url, + tab_id, + runtime_token, + control_prefix, + )), + ); + } + } +} diff --git a/rewriter-rs/src/import_map/mod.rs b/rewriter-rs/src/import_map/mod.rs new file mode 100644 index 0000000..52eec9f --- /dev/null +++ b/rewriter-rs/src/import_map/mod.rs @@ -0,0 +1,147 @@ +mod address; +mod document; + +use document::rewrite_imports; +use serde_json::Value; + +pub(crate) fn rewrite( + source: &str, + base_url: &str, + tab_id: &str, + runtime_token: &str, + control_prefix: &str, +) -> String { + let Ok(mut doc) = serde_json::from_str::(source) else { + return "{}".to_string(); + }; + if doc.is_null() { + return "null".to_string(); + } + let Some(map) = doc.as_object_mut() else { + return "{}".to_string(); + }; + + rewrite_imports(map, base_url, tab_id, runtime_token, control_prefix); + + match serde_json::to_string(&doc) { + Ok(json) => escape_html_json_chars(json), + Err(_) => "{}".to_string(), + } +} + +fn escape_html_json_chars(json: String) -> String { + json.replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e") +} + +#[cfg(test)] +mod tests { + use super::rewrite; + use serde_json::Value; + + const BASE: &str = "https://target.example/app/main.js"; + const TAB: &str = "tab-1"; + const RT: &str = "rt-1"; + const PREFIX: &str = "/zp/"; + + fn value(source: &str) -> Value { + serde_json::from_str(source).expect(source) + } + + fn rewritten_url(raw: &str) -> String { + format!( + "/zp/api/script?kind=module&rt=rt-1&tab=tab-1&u={}", + url::form_urlencoded::byte_serialize(raw.as_bytes()).collect::() + ) + } + + #[test] + fn malformed_and_non_object_inputs_match_static_policy() { + assert_eq!(rewrite("not json", BASE, TAB, RT, PREFIX), "{}"); + assert_eq!(rewrite("", BASE, TAB, RT, PREFIX), "{}"); + assert_eq!(rewrite("[]", BASE, TAB, RT, PREFIX), "{}"); + assert_eq!(rewrite("42", BASE, TAB, RT, PREFIX), "{}"); + assert_eq!(rewrite("null", BASE, TAB, RT, PREFIX), "null"); + } + + #[test] + fn rewrites_import_addresses_and_keeps_non_strings() { + let out = rewrite( + r#"{"imports":{"a":"/a.js","b":"./rel.js","c":"https://cdn.test/x.js","n":123}}"#, + BASE, + TAB, + RT, + PREFIX, + ); + let got = value(&out); + assert_eq!( + got["imports"]["a"], + rewritten_url("https://target.example/a.js") + ); + assert_eq!( + got["imports"]["b"], + rewritten_url("https://target.example/app/rel.js") + ); + assert_eq!(got["imports"]["c"], rewritten_url("https://cdn.test/x.js")); + assert_eq!(got["imports"]["n"], 123); + } + + #[test] + fn blocks_non_http_import_targets() { + let out = rewrite( + r##"{"imports":{"bad":"javascript:alert(1)","data":"data:text/js,x","frag":"#x"}}"##, + BASE, + TAB, + RT, + PREFIX, + ); + let got = value(&out); + assert_eq!(got["imports"]["bad"], "/zp/error/POLICY_BLOCKED"); + assert_eq!(got["imports"]["data"], "/zp/error/POLICY_BLOCKED"); + assert_eq!( + got["imports"]["frag"], + rewritten_url("https://target.example/app/main.js#x") + ); + } + + #[test] + fn rewrites_scope_keys_and_string_entries() { + let out = rewrite( + r#"{"scopes":{"/s/":{"a":"/a.js","n":1},"bad:scope":{"x":"/x.js"},"/empty":5}}"#, + BASE, + TAB, + RT, + PREFIX, + ); + let got = value(&out); + let scopes = got["scopes"].as_object().expect("scopes"); + let good_scope = rewritten_url("https://target.example/s/"); + let blocked_scope = "/zp/error/POLICY_BLOCKED".to_string(); + let empty_scope = rewritten_url("https://target.example/empty"); + assert_eq!( + scopes[&good_scope]["a"], + rewritten_url("https://target.example/a.js") + ); + assert!(!scopes[&good_scope].as_object().unwrap().contains_key("n")); + assert_eq!( + scopes[&blocked_scope]["x"], + rewritten_url("https://target.example/x.js") + ); + assert_eq!(scopes[&empty_scope], value("{}")); + } + + #[test] + fn escapes_html_sensitive_json_characters() { + let out = rewrite( + r#"{"imports":{"amp":"https://cdn.test/a&b.js","":"/safe.js"}}"#, + BASE, + TAB, + RT, + PREFIX, + ); + assert!(out.contains("\\u0026")); + assert!(out.contains("\\u003c")); + assert!(out.contains("\\u003e")); + } +} diff --git a/rewriter-rs/src/js/mod.rs b/rewriter-rs/src/js/mod.rs new file mode 100644 index 0000000..bf2cb50 --- /dev/null +++ b/rewriter-rs/src/js/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod module_urls; +pub(crate) mod swc_rewriter; diff --git a/rewriter-rs/src/js/module_urls.rs b/rewriter-rs/src/js/module_urls.rs new file mode 100644 index 0000000..38af41d --- /dev/null +++ b/rewriter-rs/src/js/module_urls.rs @@ -0,0 +1,325 @@ +pub(crate) struct ScriptURL { + pub(crate) ok: bool, + pub(crate) url: String, + pub(crate) target: String, + pub(crate) error: String, +} + +pub(crate) fn module_specifier( + raw: &str, + target_url: &str, + control_prefix: &str, + tab_id: &str, + runtime_token: &str, +) -> String { + if target_url.is_empty() { + return raw.to_string(); + } + if is_bare_specifier(raw) { + return raw.to_string(); + } + if has_scheme(raw) && !raw.starts_with("http://") && !raw.starts_with("https://") { + return format!("{}error/POLICY_BLOCKED", control_prefix); + } + let abs = join_url(target_url, raw); + if !abs.starts_with("http://") && !abs.starts_with("https://") { + return format!("{}error/POLICY_BLOCKED", control_prefix); + } + let mut out = format!( + "{}api/script?kind=module&u={}", + control_prefix, + percent_encode(abs) + ); + append_context(&mut out, "tab", tab_id); + append_context(&mut out, "rt", runtime_token); + out +} + +pub(crate) fn script_url( + raw: &str, + kind: &str, + target_url: &str, + control_prefix: &str, + tab_id: &str, + runtime_token: &str, +) -> ScriptURL { + let blocked = || ScriptURL { + ok: false, + url: format!("{}error/POLICY_BLOCKED", control_prefix), + target: String::new(), + error: "POLICY_BLOCKED".to_string(), + }; + let text = raw.trim(); + if text.is_empty() || text.starts_with('#') || is_executable_scheme(text) { + return blocked(); + } + let abs = match absolute_url(target_url, text) { + Some(value) if is_http_url(value.as_str()) => value, + _ => return blocked(), + }; + let normalized_kind = if kind == "module" { + "module" + } else { + "classic" + }; + let mut out = format!( + "{}api/script?kind={}&u={}", + control_prefix, + normalized_kind, + percent_encode(abs.clone()) + ); + if normalized_kind != "module" { + append_context(&mut out, "tab", tab_id); + append_context(&mut out, "rt", runtime_token); + } + ScriptURL { + ok: true, + url: out, + target: abs, + error: String::new(), + } +} + +fn append_context(out: &mut String, key: &str, value: &str) { + if value.is_empty() { + return; + } + out.push('&'); + out.push_str(key); + out.push('='); + out.push_str(&percent_encode(value.to_string())); +} + +fn is_executable_scheme(spec: &str) -> bool { + if !has_scheme(spec) { + return false; + } + let scheme = spec.split_once(':').map(|(value, _)| value).unwrap_or(""); + scheme.eq_ignore_ascii_case("javascript") + || scheme.eq_ignore_ascii_case("data") + || scheme.eq_ignore_ascii_case("vbscript") +} + +fn absolute_url(base: &str, raw: &str) -> Option { + let parsed = url::Url::parse(raw) + .or_else(|_| url::Url::parse(base).and_then(|base_url| base_url.join(raw))) + .ok()?; + Some(parsed.to_string()) +} + +fn is_http_url(value: &str) -> bool { + value.starts_with("http://") || value.starts_with("https://") +} + +fn is_bare_specifier(spec: &str) -> bool { + !spec.starts_with('/') + && !spec.starts_with("./") + && !spec.starts_with("../") + && !has_scheme(spec) +} + +fn has_scheme(spec: &str) -> bool { + let mut chars = spec.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() => {} + _ => return false, + } + for c in chars { + if c == ':' { + return true; + } + if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') { + return false; + } + } + false +} + +fn join_url(base: &str, raw: &str) -> String { + if raw.starts_with("http://") || raw.starts_with("https://") { + return raw.to_string(); + } + if raw.starts_with('/') { + if let Some(idx) = base.find("://") { + let rest = &base[idx + 3..]; + if let Some(slash) = rest.find('/') { + return format!("{}{}", &base[..idx + 3 + slash], raw); + } + } + return raw.to_string(); + } + let prefix = match base.rfind('/') { + Some(i) => &base[..=i], + None => base, + }; + let mut parts: Vec<&str> = prefix.split('/').collect(); + if parts.last() == Some(&"") { + parts.pop(); + } + for part in raw.split('/') { + match part { + "." => {} + ".." => { + if parts.len() > 3 { + parts.pop(); + } + } + _ => parts.push(part), + } + } + parts.join("/") +} + +fn percent_encode(input: String) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => { + out.push('%'); + out.push(hex(b >> 4)); + out.push(hex(b & 15)); + } + } + } + out +} + +fn hex(v: u8) -> char { + match v { + 0..=9 => (b'0' + v) as char, + _ => (b'A' + (v - 10)) as char, + } +} + +#[cfg(test)] +mod tests { + use super::{module_specifier, script_url}; + + #[test] + fn preserves_bare_specifiers() { + assert_eq!( + module_specifier( + "react", + "https://target.example/app/main.js", + "/zp/", + "", + "" + ), + "react" + ); + } + + #[test] + fn rewrites_relative_module_specifiers() { + assert_eq!( + module_specifier( + "./dep.js", + "https://target.example/app/main.js", + "/zp/", + "", + "" + ), + "/zp/api/script?kind=module&u=https%3A%2F%2Ftarget.example%2Fapp%2Fdep.js" + ); + assert_eq!( + module_specifier( + "../lib/a b.js", + "https://target.example/app/main.js", + "/zp/", + "", + "" + ), + "/zp/api/script?kind=module&u=https%3A%2F%2Ftarget.example%2Flib%2Fa%20b.js" + ); + } + + #[test] + fn appends_runtime_context_when_present() { + assert_eq!( + module_specifier( + "./dep.js", + "https://target.example/app/main.js", + "/zp/", + "tab 1", + "rt+1" + ), + "/zp/api/script?kind=module&u=https%3A%2F%2Ftarget.example%2Fapp%2Fdep.js&tab=tab%201&rt=rt%2B1" + ); + } + + #[test] + fn blocks_non_http_schemes() { + assert_eq!( + module_specifier( + "data:text/javascript,0", + "https://target.example/app/main.js", + "/zp/", + "", + "" + ), + "/zp/error/POLICY_BLOCKED" + ); + } + + #[test] + fn leaves_empty_target_context_unchanged() { + assert_eq!(module_specifier("./dep.js", "", "/zp/", "", ""), "./dep.js"); + } + + #[test] + fn rewrites_external_script_urls() { + let classic = script_url( + "./app.js", + "classic", + "https://target.example/dir/page.html", + "/zp/", + "tab 1", + "rt+1", + ); + assert!(classic.ok); + assert_eq!(classic.target, "https://target.example/dir/app.js"); + assert_eq!( + classic.url, + "/zp/api/script?kind=classic&u=https%3A%2F%2Ftarget.example%2Fdir%2Fapp.js&tab=tab%201&rt=rt%2B1" + ); + + let module = script_url( + "/main.js", + "module", + "https://target.example/dir/page.html", + "/zp/", + "tab", + "rt", + ); + assert!(module.ok); + assert_eq!( + module.url, + "/zp/api/script?kind=module&u=https%3A%2F%2Ftarget.example%2Fmain.js" + ); + } + + #[test] + fn blocks_external_script_unsafe_urls() { + for raw in [ + "", + "#frag", + "javascript:alert(1)", + "data:text/javascript,0", + "file:///x.js", + ] { + let out = script_url( + raw, + "classic", + "https://target.example/app.js", + "/zp/", + "tab", + "rt", + ); + assert!(!out.ok, "{raw}"); + assert_eq!(out.url, "/zp/error/POLICY_BLOCKED"); + } + } +} diff --git a/rewriter-rs/src/js/swc_rewriter.rs b/rewriter-rs/src/js/swc_rewriter.rs new file mode 100644 index 0000000..eba86cc --- /dev/null +++ b/rewriter-rs/src/js/swc_rewriter.rs @@ -0,0 +1,889 @@ +use std::collections::HashSet; + +use swc_common::{ + comments::SingleThreadedComments, sync::Lrc, FileName, Globals, Mark, SourceMap, SyntaxContext, + DUMMY_SP, GLOBALS as SWC_GLOBALS, +}; +use swc_ecma_ast::{ + op, ArrayLit, AssignOp, AssignTarget, BinaryOp, Callee, EsVersion, Expr, ExprOrSpread, Ident, + IdentName, ImportDecl, Lit, MemberExpr, MemberProp, MetaPropKind, ModuleDecl, OptCall, + OptChainBase, OptChainExpr, Pat, Program, Prop, PropName, Str, UpdateOp, +}; +use swc_ecma_codegen::Config; +use swc_ecma_codegen::{text_writer::JsWriter, Emitter}; +use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax}; +use swc_ecma_transforms_base::resolver; +use swc_ecma_visit::{VisitMut, VisitMutWith}; + +use crate::RewriteContext; + +use super::module_urls; + +const GLOBAL_NAMES: &[&str] = &[ + "window", + "self", + "globalThis", + "location", + "origin", + "document", + "history", + "top", + "parent", + "opener", + "frames", + "WebSocket", + "eval", + "Function", + "AsyncFunction", + "GeneratorFunction", + "AsyncGeneratorFunction", +]; + +const WINDOW_ALIASES: &[&str] = &[ + "window", + "self", + "globalThis", + "top", + "parent", + "opener", + "frames", +]; + +const MEMBER_HELPER_PROPS: &[&str] = &[ + "location", + "defaultView", + "contentWindow", + "contentDocument", + "top", + "parent", + "opener", + "frames", + "constructor", + "postMessage", +]; + +const CALL_HELPER_PROPS: &[&str] = &[ + "assign", + "replace", + "open", + "get", + "has", + "ownKeys", + "keys", + "getOwnPropertyDescriptor", + "getOwnPropertyDescriptors", + "getOwnPropertyNames", + "getOwnPropertySymbols", + "defineProperty", +]; + +pub(crate) fn rewrite_script( + source: &str, + module: bool, + ctx: RewriteContext<'_>, +) -> Result { + SWC_GLOBALS.set(&Globals::new(), || { + rewrite_script_in_globals(source, module, ctx) + }) +} + +fn rewrite_script_in_globals( + source: &str, + module: bool, + ctx: RewriteContext<'_>, +) -> Result { + let cm: Lrc = Default::default(); + let comments = SingleThreadedComments::default(); + let fm = cm.new_source_file( + FileName::Custom("zeroproxy-input.js".into()).into(), + source.to_string(), + ); + let lexer = Lexer::new( + Syntax::Es(EsSyntax { + jsx: true, + import_attributes: true, + ..Default::default() + }), + EsVersion::latest(), + StringInput::from(&*fm), + Some(&comments), + ); + let mut parser = Parser::new_from(lexer); + let mut program = if module { + Program::Module( + parser + .parse_module() + .map_err(|_| "PARSE_FAILED".to_string())?, + ) + } else { + Program::Script( + parser + .parse_script() + .map_err(|_| "PARSE_FAILED".to_string())?, + ) + }; + if !parser.take_errors().is_empty() { + return Err("PARSE_FAILED".to_string()); + } + + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + program.visit_mut_with(&mut resolver(unresolved_mark, top_level_mark, false)); + program.visit_mut_with(&mut SwcRewriter { + ctx, + module, + unresolved_mark, + window_aliases: HashSet::new(), + document_aliases: HashSet::new(), + }); + print_program(cm, &program, &comments) +} + +fn print_program( + cm: Lrc, + program: &Program, + comments: &SingleThreadedComments, +) -> Result { + let mut out = Vec::new(); + { + let wr = JsWriter::new(cm.clone(), "\n", &mut out, None); + let mut emitter = Emitter { + cfg: Config::default().with_minify(true), + cm, + comments: Some(comments), + wr, + }; + emitter + .emit_program(program) + .map_err(|_| "REWRITE_FAILED".to_string())?; + } + String::from_utf8(out).map_err(|_| "REWRITE_FAILED".to_string()) +} + +struct SwcRewriter<'a> { + ctx: RewriteContext<'a>, + module: bool, + unresolved_mark: Mark, + window_aliases: HashSet<(String, u32)>, + document_aliases: HashSet<(String, u32)>, +} + +impl VisitMut for SwcRewriter<'_> { + fn visit_mut_import_decl(&mut self, decl: &mut ImportDecl) { + *decl.src = rewritten_str_lit(&decl.src, self.ctx); + } + + fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) { + match decl { + ModuleDecl::ExportNamed(export) => { + if let Some(src) = export.src.as_deref() { + export.src = Some(Box::new(rewritten_str_lit(src, self.ctx))); + } + decl.visit_mut_children_with(self); + } + ModuleDecl::ExportAll(export) => { + *export.src = rewritten_str_lit(&export.src, self.ctx); + } + _ => decl.visit_mut_children_with(self), + } + } + + fn visit_mut_var_declarator(&mut self, decl: &mut swc_ecma_ast::VarDeclarator) { + if let Some(init) = decl.init.as_mut() { + if let Pat::Ident(binding) = &decl.name { + self.track_alias_init(&binding.id, init); + } + init.visit_mut_with(self); + } + } + + fn visit_mut_call_expr(&mut self, call: &mut swc_ecma_ast::CallExpr) { + if matches!(call.callee, Callee::Import(_)) { + self.rewrite_dynamic_import(call); + return; + } + if let Some((base, prop)) = self.call_target_parts(&call.callee, false) { + for arg in &mut call.args { + arg.expr.visit_mut_with(self); + } + let args = array_expr(call.args.iter().cloned().map(expr_from_spread).collect()); + *call = call_expr("__zp_call", vec![base, prop, args]); + return; + } + call.visit_mut_children_with(self); + } + + fn visit_mut_prop(&mut self, prop: &mut Prop) { + match prop { + Prop::Shorthand(id) if self.is_global_ident(id) => { + let key = PropName::Ident(IdentName::new(id.sym.clone(), id.span)); + *prop = Prop::KeyValue(swc_ecma_ast::KeyValueProp { + key, + value: Box::new(self.global_get_expr(id)), + }); + } + _ => prop.visit_mut_children_with(self), + } + } + + fn visit_mut_expr(&mut self, expr: &mut Expr) { + match expr { + Expr::Member(member) => { + if self.is_import_meta_url(member) { + *expr = str_expr(self.ctx.target_url); + return; + } + if let Some((base, prop)) = self.member_parts(member) { + *expr = call_helper("__zp_get", vec![base, prop]); + return; + } + expr.visit_mut_children_with(self); + } + Expr::Assign(assign) => { + if assign.op == op!("=") { + if let AssignTarget::Simple(swc_ecma_ast::SimpleAssignTarget::Ident(binding)) = + &assign.left + { + self.track_alias_init(&binding.id, assign.right.as_mut()); + } + } + if let Some((base, prop)) = self.assign_target_parts(&assign.left) { + assign.right.visit_mut_with(self); + *expr = self.assignment_helper(assign.op, base, prop, *assign.right.clone()); + return; + } + assign.right.visit_mut_with(self); + } + Expr::Update(update) => { + if let Some((base, prop)) = self.update_target_parts(&update.arg) { + *expr = call_helper( + "__zp_update", + vec![ + base, + prop, + str_expr(update_operator_text(update.op)), + bool_expr(update.prefix), + ], + ); + return; + } + expr.visit_mut_children_with(self); + } + Expr::Bin(bin) => { + if bin.op == BinaryOp::In { + bin.left.visit_mut_with(self); + bin.right.visit_mut_with(self); + *expr = call_helper("__zp_has", vec![*bin.right.clone(), *bin.left.clone()]); + return; + } + expr.visit_mut_children_with(self); + } + Expr::New(new_expr) => { + if let Some(callee) = self.construct_target(&new_expr.callee) { + if let Some(args) = new_expr.args.as_mut() { + for arg in args { + arg.expr.visit_mut_with(self); + } + } + let args = array_expr( + new_expr + .args + .as_ref() + .map(|args| args.iter().cloned().map(expr_from_spread).collect()) + .unwrap_or_default(), + ); + *expr = call_helper("__zp_construct", vec![callee, args]); + return; + } + expr.visit_mut_children_with(self); + } + Expr::OptChain(chain) => { + if let Some(rewritten) = self.rewrite_optional_chain(chain) { + *expr = rewritten; + return; + } + expr.visit_mut_children_with(self); + } + Expr::Ident(id) if self.is_global_ident(id) => { + *expr = self.global_get_expr(id); + } + _ => expr.visit_mut_children_with(self), + } + } +} + +impl SwcRewriter<'_> { + fn is_unresolved(&self, ctxt: SyntaxContext) -> bool { + ctxt.has_mark(self.unresolved_mark) + } + + fn alias_key(id: &Ident) -> (String, u32) { + (id.sym.to_string(), id.ctxt.as_u32()) + } + + fn is_global_ident(&self, id: &Ident) -> bool { + GLOBAL_NAMES.contains(&id.sym.as_ref()) && self.is_unresolved(id.ctxt) + } + + fn global_get_expr(&self, id: &Ident) -> Expr { + call_helper( + "__zp_get", + vec![global_this_expr(), str_expr(id.sym.as_ref())], + ) + } + + fn track_alias_init(&mut self, id: &Ident, init: &mut Expr) { + let key = Self::alias_key(id); + if self.expression_is_window_alias_source(init) { + self.window_aliases.insert(key.clone()); + self.document_aliases.remove(&key); + *init = self.rewrite_window_alias_source(init.clone()); + return; + } + if self.expression_is_document_alias_source(init) { + self.document_aliases.insert(key.clone()); + self.window_aliases.remove(&key); + return; + } + self.window_aliases.remove(&key); + self.document_aliases.remove(&key); + } + + fn expression_is_window_alias_source(&self, expr: &Expr) -> bool { + match expr { + Expr::This(_) => false, + Expr::Ident(id) => { + (WINDOW_ALIASES.contains(&id.sym.as_ref()) && self.is_unresolved(id.ctxt)) + || self.window_aliases.contains(&Self::alias_key(id)) + } + Expr::Member(member) => self.is_window_like_expr(&member.obj), + Expr::Bin(bin) + if matches!( + bin.op, + BinaryOp::LogicalOr | BinaryOp::LogicalAnd | BinaryOp::NullishCoalescing + ) => + { + self.expression_is_window_alias_source(&bin.left) + || self.expression_is_window_alias_source(&bin.right) + } + Expr::Cond(cond) => { + self.expression_is_window_alias_source(&cond.cons) + || self.expression_is_window_alias_source(&cond.alt) + } + Expr::Paren(paren) => self.expression_is_window_alias_source(&paren.expr), + _ => false, + } + } + + fn expression_is_document_alias_source(&self, expr: &Expr) -> bool { + match expr { + Expr::Ident(id) => { + id.sym == *"document" && self.is_unresolved(id.ctxt) + || self.document_aliases.contains(&Self::alias_key(id)) + } + Expr::Member(member) => { + self.member_prop_name(&member.prop) == Some("document") + && self.is_window_like_expr(&member.obj) + || matches!(member.prop, MemberProp::Computed(_)) + && self.is_window_like_expr(&member.obj) + } + Expr::Bin(bin) + if matches!( + bin.op, + BinaryOp::LogicalOr | BinaryOp::LogicalAnd | BinaryOp::NullishCoalescing + ) => + { + self.expression_is_document_alias_source(&bin.left) + || self.expression_is_document_alias_source(&bin.right) + } + Expr::Cond(cond) => { + self.expression_is_document_alias_source(&cond.cons) + || self.expression_is_document_alias_source(&cond.alt) + } + Expr::Paren(paren) => self.expression_is_document_alias_source(&paren.expr), + _ => false, + } + } + + fn rewrite_window_alias_source(&mut self, expr: Expr) -> Expr { + match expr { + Expr::This(_) => call_helper("__zp_get", vec![global_this_expr(), str_expr("window")]), + Expr::Bin(mut bin) + if matches!( + bin.op, + BinaryOp::LogicalOr | BinaryOp::LogicalAnd | BinaryOp::NullishCoalescing + ) => + { + *bin.left = self.rewrite_window_alias_source(*bin.left); + *bin.right = self.rewrite_window_alias_source(*bin.right); + Expr::Bin(bin) + } + Expr::Cond(mut cond) => { + cond.test.visit_mut_with(self); + *cond.cons = self.rewrite_window_alias_source(*cond.cons); + *cond.alt = self.rewrite_window_alias_source(*cond.alt); + Expr::Cond(cond) + } + Expr::Paren(mut paren) => { + *paren.expr = self.rewrite_window_alias_source(*paren.expr); + Expr::Paren(paren) + } + mut other => { + other.visit_mut_with(self); + other + } + } + } + + fn member_prop_name<'a>(&self, prop: &'a MemberProp) -> Option<&'a str> { + match prop { + MemberProp::Ident(id) => Some(id.sym.as_ref()), + _ => None, + } + } + + fn member_prop_expr(&mut self, prop: &MemberProp) -> Expr { + match prop { + MemberProp::Ident(id) => str_expr(id.sym.as_ref()), + MemberProp::Computed(comp) => { + let mut expr = *comp.expr.clone(); + expr.visit_mut_with(self); + expr + } + MemberProp::PrivateName(private) => str_expr(private.name.as_ref()), + } + } + + fn transformed_expr(&mut self, expr: &Expr) -> Expr { + let mut out = expr.clone(); + out.visit_mut_with(self); + out + } + + fn is_window_like_expr(&self, expr: &Expr) -> bool { + match expr { + Expr::Ident(id) => { + (matches!( + id.sym.as_ref(), + "window" + | "self" + | "globalThis" + | "top" + | "parent" + | "opener" + | "frames" + | "document" + ) && self.is_unresolved(id.ctxt)) + || self.window_aliases.contains(&Self::alias_key(id)) + || self.document_aliases.contains(&Self::alias_key(id)) + } + Expr::Member(member) => { + matches!( + self.member_prop_name(&member.prop), + Some( + "defaultView" + | "contentWindow" + | "window" + | "self" + | "globalThis" + | "top" + | "parent" + | "opener" + | "frames" + ) + ) && self.is_window_like_expr(&member.obj) + || matches!(member.prop, MemberProp::Computed(_)) + && self.is_window_like_expr(&member.obj) + } + _ => false, + } + } + + fn is_virtual_location_expr(&self, expr: &Expr) -> bool { + match expr { + Expr::Ident(id) => id.sym == *"location" && self.is_unresolved(id.ctxt), + Expr::Member(member) => { + self.member_prop_name(&member.prop) == Some("location") + && self.is_window_like_expr(&member.obj) + || matches!(member.prop, MemberProp::Computed(_)) + && self.is_window_like_expr(&member.obj) + } + _ => false, + } + } + + fn member_needs_helper(&self, member: &MemberExpr) -> bool { + match &member.prop { + MemberProp::Ident(id) => { + MEMBER_HELPER_PROPS.contains(&id.sym.as_ref()) + || matches!(id.sym.as_ref(), "href" | "hash") + && self.is_virtual_location_expr(&member.obj) + } + MemberProp::Computed(_) => { + self.is_window_like_expr(&member.obj) || self.is_virtual_location_expr(&member.obj) + } + MemberProp::PrivateName(_) => false, + } + } + + fn member_parts(&mut self, member: &MemberExpr) -> Option<(Expr, Expr)> { + if !self.member_needs_helper(member) { + return None; + } + let base = self.transformed_expr(&member.obj); + let prop = self.member_prop_expr(&member.prop); + Some((base, prop)) + } + + fn assign_target_parts(&mut self, target: &AssignTarget) -> Option<(Expr, Expr)> { + let AssignTarget::Simple(simple) = target else { + return None; + }; + match simple { + swc_ecma_ast::SimpleAssignTarget::Ident(binding) + if matches!(binding.id.sym.as_ref(), "location" | "window") + && self.is_unresolved(binding.id.ctxt) => + { + Some((global_this_expr(), str_expr(binding.id.sym.as_ref()))) + } + swc_ecma_ast::SimpleAssignTarget::Member(member) => self.member_parts(member), + swc_ecma_ast::SimpleAssignTarget::Paren(paren) => { + self.assign_target_parts(&AssignTarget::try_from(paren.expr.clone()).ok()?) + } + _ => None, + } + } + + fn update_target_parts(&mut self, target: &Expr) -> Option<(Expr, Expr)> { + match target { + Expr::Ident(id) + if matches!(id.sym.as_ref(), "location" | "window") + && self.is_unresolved(id.ctxt) => + { + Some((global_this_expr(), str_expr(id.sym.as_ref()))) + } + Expr::Member(member) => self.member_parts(member), + Expr::Paren(paren) => self.update_target_parts(&paren.expr), + _ => None, + } + } + + fn assignment_helper(&mut self, op: AssignOp, base: Expr, prop: Expr, rhs: Expr) -> Expr { + if op == op!("=") { + return call_helper("__zp_set", vec![base, prop, rhs]); + } + let value = if matches!( + op, + AssignOp::AndAssign | AssignOp::OrAssign | AssignOp::NullishAssign + ) { + Expr::Arrow(swc_ecma_ast::ArrowExpr { + span: DUMMY_SP, + ctxt: Default::default(), + params: vec![], + body: Box::new(swc_ecma_ast::BlockStmtOrExpr::Expr(Box::new(rhs))), + is_async: false, + is_generator: false, + type_params: None, + return_type: None, + }) + } else { + rhs + }; + call_helper( + "__zp_assign", + vec![base, prop, str_expr(assign_operator_text(op)), value], + ) + } + + fn call_target_parts(&mut self, callee: &Callee, optional: bool) -> Option<(Expr, Expr)> { + let Callee::Expr(expr) = callee else { + return None; + }; + match &**expr { + Expr::Member(member) => { + let prop_name = self.member_prop_name(&member.prop); + if prop_name + .map(|prop| CALL_HELPER_PROPS.contains(&prop)) + .unwrap_or(false) + || self.member_needs_helper(member) + || optional + { + let base = self.transformed_expr(&member.obj); + let prop = self.member_prop_expr(&member.prop); + Some((base, prop)) + } else { + None + } + } + _ => None, + } + } + + fn construct_target(&mut self, callee: &Expr) -> Option { + match callee { + Expr::Ident(id) if self.is_global_ident(id) => Some(self.global_get_expr(id)), + Expr::Member(member) + if self.is_window_like_expr(&member.obj) || self.member_needs_helper(member) => + { + Some(self.transformed_expr(callee)) + } + Expr::OptChain(chain) => self.rewrite_optional_chain(chain), + _ => None, + } + } + + fn rewrite_dynamic_import(&mut self, call: &mut swc_ecma_ast::CallExpr) { + let Some(first) = call.args.first_mut() else { + return; + }; + match &mut *first.expr { + Expr::Lit(Lit::Str(spec)) => { + *spec = rewritten_str_lit(spec, self.ctx); + } + expr => { + expr.visit_mut_with(self); + let source = expr.clone(); + *expr = call_helper( + "__zp_module_url", + vec![source, str_expr(self.ctx.target_url)], + ); + } + } + } + + fn is_import_meta_url(&self, member: &MemberExpr) -> bool { + self.module + && self.member_prop_name(&member.prop) == Some("url") + && matches!(&*member.obj, Expr::MetaProp(meta) if meta.kind == MetaPropKind::ImportMeta) + } + + fn rewrite_optional_chain(&mut self, chain: &OptChainExpr) -> Option { + match &*chain.base { + OptChainBase::Member(member) => { + let base = self.transformed_expr(&member.obj); + let prop = self.member_prop_expr(&member.prop); + Some(call_helper("__zp_optionalGet", vec![base, prop])) + } + OptChainBase::Call(call) => self.rewrite_optional_call(call), + } + } + + fn rewrite_optional_call(&mut self, call: &OptCall) -> Option { + let (base, prop) = self.optional_call_target_parts(&call.callee)?; + let args = array_expr( + call.args + .iter() + .cloned() + .map(|arg| self.transformed_arg_expr(arg)) + .collect(), + ); + Some(call_helper("__zp_optionalCall", vec![base, prop, args])) + } + + fn optional_call_target_parts(&mut self, callee: &Expr) -> Option<(Expr, Expr)> { + match callee { + Expr::Member(member) => { + let wrapped = Callee::Expr(Box::new(Expr::Member(member.clone()))); + self.call_target_parts(&wrapped, true) + } + Expr::OptChain(chain) => match &*chain.base { + OptChainBase::Member(member) => { + let base = self.transformed_expr(&member.obj); + let prop = self.member_prop_expr(&member.prop); + Some((base, prop)) + } + _ => None, + }, + Expr::Paren(paren) => self.optional_call_target_parts(&paren.expr), + _ => None, + } + } + + fn transformed_arg_expr(&mut self, arg: ExprOrSpread) -> Expr { + let ExprOrSpread { spread, expr } = arg; + let mut expr = *expr; + expr.visit_mut_with(self); + expr_from_spread(ExprOrSpread { + spread, + expr: Box::new(expr), + }) + } +} + +fn rewritten_str_lit(src: &Str, ctx: RewriteContext<'_>) -> Str { + Str { + span: src.span, + value: module_urls::module_specifier( + src.value.to_string_lossy().as_ref(), + ctx.target_url, + ctx.control_prefix, + ctx.tab_id, + ctx.runtime_token, + ) + .into(), + raw: None, + } +} + +fn helper_ident(name: &str) -> Ident { + Ident::new(name.into(), DUMMY_SP, SyntaxContext::empty()) +} + +fn global_this_expr() -> Expr { + Expr::Ident(helper_ident("globalThis")) +} + +fn str_expr(value: &str) -> Expr { + Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: value.into(), + raw: None, + })) +} + +fn bool_expr(value: bool) -> Expr { + Expr::Lit(Lit::Bool(swc_ecma_ast::Bool { + span: DUMMY_SP, + value, + })) +} + +fn array_expr(values: Vec) -> Expr { + Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: values + .into_iter() + .map(|expr| { + Some(ExprOrSpread { + spread: None, + expr: Box::new(expr), + }) + }) + .collect(), + }) +} + +fn expr_from_spread(arg: ExprOrSpread) -> Expr { + if arg.spread.is_some() { + Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: vec![Some(arg)], + }) + } else { + *arg.expr + } +} + +fn call_expr(name: &str, args: Vec) -> swc_ecma_ast::CallExpr { + swc_ecma_ast::CallExpr { + span: DUMMY_SP, + ctxt: Default::default(), + callee: Callee::Expr(Box::new(Expr::Ident(helper_ident(name)))), + args: args + .into_iter() + .map(|expr| ExprOrSpread { + spread: None, + expr: Box::new(expr), + }) + .collect(), + type_args: None, + } +} + +fn call_helper(name: &str, args: Vec) -> Expr { + Expr::Call(call_expr(name, args)) +} + +fn assign_operator_text(op: AssignOp) -> &'static str { + match op { + AssignOp::Assign => "=", + AssignOp::AddAssign => "+=", + AssignOp::SubAssign => "-=", + AssignOp::MulAssign => "*=", + AssignOp::DivAssign => "/=", + AssignOp::ModAssign => "%=", + AssignOp::ExpAssign => "**=", + AssignOp::LShiftAssign => "<<=", + AssignOp::RShiftAssign => ">>=", + AssignOp::ZeroFillRShiftAssign => ">>>=", + AssignOp::BitOrAssign => "|=", + AssignOp::BitXorAssign => "^=", + AssignOp::BitAndAssign => "&=", + AssignOp::OrAssign => "||=", + AssignOp::AndAssign => "&&=", + AssignOp::NullishAssign => "??=", + } +} + +fn update_operator_text(op: UpdateOp) -> &'static str { + match op { + UpdateOp::PlusPlus => "++", + UpdateOp::MinusMinus => "--", + } +} + +#[cfg(test)] +mod tests { + use super::rewrite_script; + use crate::RewriteContext; + + fn ctx() -> RewriteContext<'static> { + RewriteContext::new("https://example.com/assets/main.js", "/zp/", "tab", "rt") + } + + #[test] + fn parses_and_rewrites_module_specifiers_with_swc() { + let out = rewrite_script("import './dep.js'; export * from './x.js';", true, ctx()) + .expect("swc rewrite should succeed"); + assert!(out.contains( + "/zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Fdep.js&tab=tab&rt=rt" + )); + assert!(out.contains( + "/zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Fx.js&tab=tab&rt=rt" + )); + } + + #[test] + fn rewrites_free_globals_with_swc_ast() { + let out = rewrite_script("window.location.href; document.title;", false, ctx()) + .expect("swc rewrite should succeed"); + assert!(out.contains("__zp_get(globalThis,\"window\")")); + assert!(out.contains("__zp_get(globalThis,\"document\")")); + } + + #[test] + fn preserves_local_bindings_with_swc_resolver() { + let out = rewrite_script( + "const location = { href: 'local' }; window.location.href; location.href;", + false, + ctx(), + ) + .expect("swc rewrite should succeed"); + assert!(out.contains("location.href;")); + assert!(out.contains("__zp_get(globalThis,\"window\")")); + assert!(!out.contains("__zp_get(globalThis, \"location\").href")); + } + + #[test] + fn preserves_function_body_block_comments_for_to_string_templates() { + let out = rewrite_script( + r#"const html = parseTemplate(function () { +/*!@preserve +
뉴스
+*/ +return true; +});"#, + false, + ctx(), + ) + .expect("swc rewrite should succeed"); + assert!(out.contains("/*!@preserve")); + assert!(out.contains("
뉴스
")); + } + + #[test] + fn reports_parse_failures() { + let err = rewrite_script("if (", false, ctx()).expect_err("parse should fail"); + assert_eq!(err, "PARSE_FAILED"); + } +} diff --git a/rewriter-rs/src/lib.rs b/rewriter-rs/src/lib.rs index f0e5979..ef991ac 100644 --- a/rewriter-rs/src/lib.rs +++ b/rewriter-rs/src/lib.rs @@ -1,12 +1,11 @@ -use std::collections::HashSet; - -use oxc_allocator::Allocator; -use oxc_ast::ast::*; -use oxc_parser::Parser; -use oxc_span::{GetSpan, SourceType, Span}; -use oxc_syntax::operator::{AssignmentOperator, UpdateOperator}; use wasm_bindgen::prelude::*; +mod css; +pub mod html; +mod import_map; +mod js; +mod share_url; + #[wasm_bindgen] pub struct RewriteOutput { ok: bool, @@ -14,27 +13,78 @@ pub struct RewriteOutput { error: String, } +#[wasm_bindgen] +pub struct URLRewriteOutput { + ok: bool, + url: String, + target: String, + error: String, +} + +#[wasm_bindgen] +impl URLRewriteOutput { + #[wasm_bindgen(getter)] + pub fn ok(&self) -> bool { + self.ok + } + #[wasm_bindgen(getter)] + pub fn url(&self) -> String { + self.url.clone() + } + #[wasm_bindgen(getter)] + pub fn target(&self) -> String { + self.target.clone() + } + #[wasm_bindgen(getter)] + pub fn error(&self) -> String { + self.error.clone() + } +} + #[wasm_bindgen] impl RewriteOutput { #[wasm_bindgen(getter)] - pub fn ok(&self) -> bool { self.ok } + pub fn ok(&self) -> bool { + self.ok + } #[wasm_bindgen(getter)] - pub fn code(&self) -> String { self.code.clone() } + pub fn code(&self) -> String { + self.code.clone() + } #[wasm_bindgen(getter)] - pub fn error(&self) -> String { self.error.clone() } + pub fn error(&self) -> String { + self.error.clone() + } +} + +#[wasm_bindgen] +pub fn rewrite_script( + source: &str, + kind: &str, + target_url: &str, + control_prefix: &str, +) -> RewriteOutput { + rewrite_script_with_context(source, kind, target_url, control_prefix, "", "") } #[wasm_bindgen] -pub fn rewrite_script(source: &str, kind: &str, target_url: &str, control_prefix: &str) -> RewriteOutput { +pub fn rewrite_script_with_context( + source: &str, + kind: &str, + target_url: &str, + control_prefix: &str, + tab_id: &str, + runtime_token: &str, +) -> RewriteOutput { + let ctx = RewriteContext::new(target_url, control_prefix, tab_id, runtime_token); match normalize_kind(kind) { - "module" => rewrite_program_source(source, true, target_url, control_prefix), + "module" => rewrite_program_source(source, true, ctx), "event-handler" => rewrite_wrapped_source( source, "function __zp_event__(event){\n", "\n}", false, - target_url, - control_prefix, + ctx.without_runtime_context(), true, ), "function" => rewrite_wrapped_source( @@ -42,1103 +92,295 @@ pub fn rewrite_script(source: &str, kind: &str, target_url: &str, control_prefix "function __zp_dynamic__(){\n", "\n}", false, - target_url, - control_prefix, + ctx.without_runtime_context(), false, ), - _ => rewrite_program_source(source, false, target_url, control_prefix), + _ => rewrite_program_source(source, false, ctx.without_runtime_context()), } } -fn normalize_kind(kind: &str) -> &'static str { - match kind { - "module" => "module", - "event-handler" | "event" => "event-handler", - "function" => "function", - _ => "classic", +#[wasm_bindgen] +pub fn rewrite_css(source: &str, base_url: &str, control_prefix: &str) -> RewriteOutput { + match css::rewrite(source, base_url, control_prefix) { + Ok(code) => RewriteOutput { + ok: true, + code, + error: String::new(), + }, + Err(error) => RewriteOutput { + ok: false, + code: String::new(), + error, + }, } } -fn rewrite_program_source(source: &str, module: bool, target_url: &str, control_prefix: &str) -> RewriteOutput { - let allocator = Allocator::default(); - let source_type = if module { SourceType::mjs() } else { SourceType::cjs() }; - let ret = Parser::new(&allocator, source, source_type).parse(); - if !ret.errors.is_empty() { - return RewriteOutput { ok: false, code: String::new(), error: "PARSE_FAILED".to_string() }; - } - let mut rewriter = Rewriter::new(source, module, target_url, control_prefix); - rewriter.walk_program(&ret.program); - RewriteOutput { ok: true, code: rewriter.finish(), error: String::new() } +#[wasm_bindgen] +pub fn rewrite_import_map( + source: &str, + base_url: &str, + tab_id: &str, + runtime_token: &str, + control_prefix: &str, +) -> String { + import_map::rewrite(source, base_url, tab_id, runtime_token, control_prefix) } -fn rewrite_wrapped_source( +#[wasm_bindgen] +pub fn rewrite_html_document( source: &str, - prefix: &str, - suffix: &str, - module: bool, target_url: &str, control_prefix: &str, - event_handler: bool, + servers_json: &str, + runtime_prelude: &str, + tab_id: &str, + runtime_token: &str, ) -> RewriteOutput { - let mut wrapped = String::with_capacity(prefix.len() + source.len() + suffix.len()); - wrapped.push_str(prefix); - wrapped.push_str(source); - wrapped.push_str(suffix); - let out = rewrite_program_source(&wrapped, module, target_url, control_prefix); - if !out.ok { - return out; - } - if out.code.len() < prefix.len() + suffix.len() { - return RewriteOutput { ok: false, code: String::new(), error: "REWRITE_FAILED".to_string() }; - } - let inner_end = out.code.len() - suffix.len(); - let inner = &out.code[prefix.len()..inner_end]; - let code = if event_handler { - let mut event = String::with_capacity(inner.len() + 76); - event.push_str("return __zp_runEvent(this,event,function(__zp_scope){with(__zp_scope){\n"); - event.push_str(inner); - event.push_str("\n}})"); - event - } else { - inner.to_string() - }; - RewriteOutput { ok: true, code, error: String::new() } -} - -const GLOBALS: &[&str] = &[ - "window", "self", "globalThis", "location", "document", "history", "top", "parent", "opener", "frames", - "WebSocket", "eval", "Function", "AsyncFunction", "GeneratorFunction", "AsyncGeneratorFunction", -]; -const MEMBER_HELPER_PROPS: &[&str] = &[ - "location", "defaultView", "contentWindow", "contentDocument", "top", "parent", "opener", "frames", "constructor", "postMessage", -]; -const CALL_HELPER_PROPS: &[&str] = &[ - "assign", "replace", "open", "get", "getOwnPropertyDescriptor", "defineProperty", -]; - -#[derive(Clone)] -struct Replacement { - start: usize, - end: usize, - text: String, - priority: i32, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum ScopeMode { - FunctionRoot, - Block, -} - -struct Rewriter<'a> { - source: &'a str, - module: bool, - target_url: &'a str, - control_prefix: &'a str, - replacements: Vec, - scopes: Vec>, -} - -impl<'a> Rewriter<'a> { - fn new(source: &'a str, module: bool, target_url: &'a str, control_prefix: &'a str) -> Self { - Self { - source, - module, + let servers = serde_json::from_str::>(servers_json).unwrap_or_default(); + match html::document::rewrite_document( + source, + html::document::DocumentOptions { target_url, - control_prefix: if control_prefix.is_empty() { "/zp/" } else { control_prefix }, - replacements: Vec::new(), - scopes: Vec::new(), - } - } - - fn finish(mut self) -> String { - self.replacements.sort_by(|a, b| { - a.start.cmp(&b.start) - .then(b.priority.cmp(&a.priority)) - .then((b.end - b.start).cmp(&(a.end - a.start))) - }); - let mut chosen: Vec = Vec::new(); - let mut covered_end = 0usize; - for r in self.replacements { - if !chosen.is_empty() && r.start < covered_end { continue; } - covered_end = r.end; - chosen.push(r); - } - let mut out = String::with_capacity(self.source.len() + chosen.iter().map(|r| r.text.len()).sum::()); - let mut pos = 0usize; - for r in chosen { - out.push_str(&self.source[pos..r.start]); - out.push_str(&r.text); - pos = r.end; - } - out.push_str(&self.source[pos..]); - out - } - - fn span_text(&self, span: Span) -> &str { - self.source.get(span.start as usize..span.end as usize).unwrap_or("") - } - - fn push_scope(&mut self, scope: HashSet) { self.scopes.push(scope); } - fn pop_scope(&mut self) { self.scopes.pop(); } - fn declare(&mut self, name: &str) { - if let Some(scope) = self.scopes.last_mut() { scope.insert(name.to_string()); } - } - fn declared(&self, name: &str) -> bool { self.scopes.iter().rev().any(|scope| scope.contains(name)) } - fn add_replacement(&mut self, span: Span, text: String, priority: i32) { - let start = span.start as usize; - let end = span.end as usize; - if start < end { self.replacements.push(Replacement { start, end, text, priority }); } - } - - fn walk_program(&mut self, program: &Program<'a>) { - self.push_scope(self.collect_body_bindings(&program.body, ScopeMode::FunctionRoot)); - for stmt in &program.body { self.walk_statement(stmt); } - self.pop_scope(); - } - - fn walk_statement(&mut self, stmt: &Statement<'a>) { - match stmt { - Statement::BlockStatement(block) => { - self.push_scope(self.collect_body_bindings(&block.body, ScopeMode::Block)); - for stmt in &block.body { self.walk_statement(stmt); } - self.pop_scope(); - } - Statement::ExpressionStatement(expr) => self.walk_expression(&expr.expression), - Statement::IfStatement(stmt) => { - self.walk_expression(&stmt.test); - self.walk_statement(&stmt.consequent); - if let Some(alt) = &stmt.alternate { self.walk_statement(alt); } - } - Statement::WhileStatement(stmt) => { self.walk_expression(&stmt.test); self.walk_statement(&stmt.body); } - Statement::DoWhileStatement(stmt) => { self.walk_statement(&stmt.body); self.walk_expression(&stmt.test); } - Statement::ForStatement(stmt) => { - let scoped = matches!(stmt.init.as_ref(), Some(ForStatementInit::VariableDeclaration(decl)) if decl.kind != VariableDeclarationKind::Var); - if scoped { self.push_scope(HashSet::new()); } - if let Some(init) = &stmt.init { - match init { - ForStatementInit::VariableDeclaration(decl) => self.walk_variable_declaration(decl), - _ => self.walk_expression(init.to_expression()), - } - } - if let Some(test) = &stmt.test { self.walk_expression(test); } - if let Some(update) = &stmt.update { self.walk_expression(update); } - self.walk_statement(&stmt.body); - if scoped { self.pop_scope(); } - } - Statement::ForInStatement(stmt) => { - let scoped = matches!(&stmt.left, ForStatementLeft::VariableDeclaration(decl) if decl.kind != VariableDeclarationKind::Var); - if scoped { self.push_scope(HashSet::new()); } - match &stmt.left { - ForStatementLeft::VariableDeclaration(decl) => self.walk_variable_declaration(decl), - _ => self.walk_assignment_target(stmt.left.to_assignment_target()), - } - self.walk_expression(&stmt.right); - self.walk_statement(&stmt.body); - if scoped { self.pop_scope(); } - } - Statement::ForOfStatement(stmt) => { - let scoped = matches!(&stmt.left, ForStatementLeft::VariableDeclaration(decl) if decl.kind != VariableDeclarationKind::Var); - if scoped { self.push_scope(HashSet::new()); } - match &stmt.left { - ForStatementLeft::VariableDeclaration(decl) => self.walk_variable_declaration(decl), - _ => self.walk_assignment_target(stmt.left.to_assignment_target()), - } - self.walk_expression(&stmt.right); - self.walk_statement(&stmt.body); - if scoped { self.pop_scope(); } - } - Statement::ReturnStatement(stmt) => { if let Some(arg) = &stmt.argument { self.walk_expression(arg); } } - Statement::ThrowStatement(stmt) => self.walk_expression(&stmt.argument), - Statement::SwitchStatement(stmt) => { - self.walk_expression(&stmt.discriminant); - for case in &stmt.cases { - if let Some(test) = &case.test { self.walk_expression(test); } - for stmt in &case.consequent { self.walk_statement(stmt); } - } - } - Statement::TryStatement(stmt) => { - self.walk_block_statement(&stmt.block); - if let Some(handler) = &stmt.handler { - let mut scope = HashSet::new(); - if let Some(param) = &handler.param { self.collect_binding_pattern(¶m.pattern, &mut scope); } - self.push_scope(scope); - self.walk_block_statement(&handler.body); - self.pop_scope(); - } - if let Some(finalizer) = &stmt.finalizer { self.walk_block_statement(finalizer); } - } - Statement::VariableDeclaration(decl) => self.walk_variable_declaration(decl), - Statement::FunctionDeclaration(func) => self.walk_function(func), - Statement::ClassDeclaration(class) => self.walk_class(class, true), - Statement::ImportDeclaration(decl) => self.add_replacement(decl.source.span, format!("{:?}", self.module_specifier(decl.source.value.as_str())), 95), - Statement::ExportNamedDeclaration(decl) => { - if let Some(source) = &decl.source { self.add_replacement(source.span, format!("{:?}", self.module_specifier(source.value.as_str())), 95); } - if let Some(inner) = &decl.declaration { self.walk_declaration(inner); } - } - Statement::ExportAllDeclaration(decl) => self.add_replacement(decl.source.span, format!("{:?}", self.module_specifier(decl.source.value.as_str())), 95), - Statement::ExportDefaultDeclaration(decl) => self.walk_export_default(decl), - _ => {} - } - } - fn walk_declaration(&mut self, decl: &Declaration<'a>) { - match decl { - Declaration::VariableDeclaration(decl) => self.walk_variable_declaration(decl), - Declaration::FunctionDeclaration(func) => self.walk_function(func), - Declaration::ClassDeclaration(class) => self.walk_class(class, true), - _ => {} - } - } - fn walk_export_default(&mut self, decl: &ExportDefaultDeclaration<'a>) { - match &decl.declaration { - ExportDefaultDeclarationKind::FunctionDeclaration(func) => self.walk_function(func), - ExportDefaultDeclarationKind::ClassDeclaration(class) => self.walk_class(class, true), - other => { - if let Some(expr) = other.as_expression() { self.walk_expression(expr); } - } - } - } - fn walk_function(&mut self, func: &Function<'a>) { - if let Some(id) = &func.id { self.declare(id.name.as_str()); } - let mut scope = HashSet::new(); - if let Some(id) = &func.id { scope.insert(id.name.to_string()); } - self.collect_formal_parameters(&func.params, &mut scope); - if let Some(body) = &func.body { - let body_scope = self.collect_body_bindings(&body.statements, ScopeMode::FunctionRoot); - scope.extend(body_scope); - self.push_scope(scope); - for stmt in &body.statements { self.walk_statement(stmt); } - self.pop_scope(); - } - } - fn walk_class(&mut self, class: &Class<'a>, declare_id: bool) { - if declare_id { - if let Some(id) = &class.id { self.declare(id.name.as_str()); } - } - if let Some(super_class) = &class.super_class { self.walk_expression(super_class); } - let mut pushed_name_scope = false; - if let Some(id) = &class.id { - let mut scope = HashSet::new(); - scope.insert(id.name.to_string()); - self.push_scope(scope); - pushed_name_scope = true; - } - for elem in &class.body.body { - match elem { - ClassElement::StaticBlock(block) => { - self.push_scope(self.collect_body_bindings(&block.body, ScopeMode::Block)); - for stmt in &block.body { self.walk_statement(stmt); } - self.pop_scope(); - } - ClassElement::MethodDefinition(method) => { - if method.computed { self.walk_property_key(&method.key); } - self.walk_function(&method.value); - } - ClassElement::PropertyDefinition(prop) => { - if prop.computed { self.walk_property_key(&prop.key); } - if let Some(value) = &prop.value { self.walk_expression(value); } - } - ClassElement::AccessorProperty(prop) => { - if prop.computed { self.walk_property_key(&prop.key); } - if let Some(value) = &prop.value { self.walk_expression(value); } - } - _ => {} - } - } - if pushed_name_scope { self.pop_scope(); } - } - fn walk_block_statement(&mut self, block: &BlockStatement<'a>) { - self.push_scope(self.collect_body_bindings(&block.body, ScopeMode::Block)); - for stmt in &block.body { self.walk_statement(stmt); } - self.pop_scope(); - } - - fn walk_variable_declaration(&mut self, decl: &VariableDeclaration<'a>) { - for declarator in &decl.declarations { - self.declare_binding_pattern(&declarator.id); - self.walk_binding_pattern(&declarator.id); - if let Some(init) = &declarator.init { self.walk_expression(init); } - } - } - - fn declare_binding_pattern(&mut self, pattern: &BindingPattern<'a>) { - let mut names = HashSet::new(); - self.collect_binding_pattern(pattern, &mut names); - for name in names { self.declare(&name); } - } - - fn walk_binding_pattern(&mut self, pattern: &BindingPattern<'a>) { - match &pattern.kind { - BindingPatternKind::BindingIdentifier(_) => {} - BindingPatternKind::AssignmentPattern(pat) => { - self.walk_binding_pattern(&pat.left); - self.walk_expression(&pat.right); - } - BindingPatternKind::ArrayPattern(arr) => { - for elem in &arr.elements { if let Some(p) = elem { self.walk_binding_pattern(p); } } - if let Some(rest) = &arr.rest { self.walk_binding_pattern(&rest.argument); } - } - BindingPatternKind::ObjectPattern(obj) => { - for prop in &obj.properties { - if prop.computed { self.walk_property_key(&prop.key); } - self.walk_binding_pattern(&prop.value); - } - if let Some(rest) = &obj.rest { self.walk_binding_pattern(&rest.argument); } - } - } - } - - fn walk_assignment_target(&mut self, target: &AssignmentTarget<'a>) { - match target { - AssignmentTarget::AssignmentTargetIdentifier(_) => {} - AssignmentTarget::ComputedMemberExpression(expr) => { - self.walk_expression(&expr.object); - self.walk_expression(&expr.expression); - } - AssignmentTarget::StaticMemberExpression(expr) => self.walk_expression(&expr.object), - AssignmentTarget::PrivateFieldExpression(expr) => self.walk_expression(&expr.object), - AssignmentTarget::ArrayAssignmentTarget(arr) => { - for elem in &arr.elements { if let Some(item) = elem { self.walk_assignment_target_maybe_default(item); } } - if let Some(rest) = &arr.rest { self.walk_assignment_target(&rest.target); } - } - AssignmentTarget::ObjectAssignmentTarget(obj) => { - for prop in &obj.properties { - match prop { - AssignmentTargetProperty::AssignmentTargetPropertyIdentifier(id) => { - if let Some(init) = &id.init { self.walk_expression(init); } - } - AssignmentTargetProperty::AssignmentTargetPropertyProperty(prop) => { - if prop.computed { self.walk_property_key(&prop.name); } - self.walk_assignment_target_maybe_default(&prop.binding); - } - } - } - if let Some(rest) = &obj.rest { self.walk_assignment_target(&rest.target); } - } - _ => {} - } - } - - fn walk_assignment_target_maybe_default(&mut self, target: &AssignmentTargetMaybeDefault<'a>) { - match target { - AssignmentTargetMaybeDefault::AssignmentTargetIdentifier(_) => {} - AssignmentTargetMaybeDefault::ComputedMemberExpression(expr) => { self.walk_expression(&expr.object); self.walk_expression(&expr.expression); } - AssignmentTargetMaybeDefault::StaticMemberExpression(expr) => self.walk_expression(&expr.object), - AssignmentTargetMaybeDefault::PrivateFieldExpression(expr) => self.walk_expression(&expr.object), - AssignmentTargetMaybeDefault::AssignmentTargetWithDefault(target) => { - self.walk_assignment_target(&target.binding); - self.walk_expression(&target.init); - } - _ => {} - } - } - - fn walk_property_key(&mut self, key: &PropertyKey<'a>) { - match key { - PropertyKey::StaticIdentifier(_) | PropertyKey::PrivateIdentifier(_) => {} - _ => self.walk_expression(key.to_expression()), - } - } - - fn walk_expression(&mut self, expr: &Expression<'a>) { - match expr { - Expression::Identifier(id) => { - if self.is_global_name(id.name.as_str()) && !self.declared(id.name.as_str()) { - self.add_replacement(id.span, format!("__zp_get(globalThis,{:?})", id.name.as_str()), 10); - } - } - Expression::StaticMemberExpression(expr) => { - if self.is_import_meta_url_static(expr) { - self.add_replacement(expr.span, format!("{:?}", self.target_url), 90); - return; - } - if self.member_needs_helper_static(expr) { - self.add_replacement(expr.span, format!("__zp_get({},{:?})", self.render_expression(&expr.object), expr.property.name.as_str()), 80); - return; - } - self.walk_expression(&expr.object); - } - Expression::ComputedMemberExpression(expr) => { - if self.member_needs_helper_computed(expr) { - self.add_replacement(expr.span, format!("__zp_get({},{})", self.render_expression(&expr.object), self.render_expression(&expr.expression)), 80); - return; - } - self.walk_expression(&expr.object); - self.walk_expression(&expr.expression); - } - Expression::PrivateFieldExpression(expr) => self.walk_expression(&expr.object), - Expression::AssignmentExpression(expr) => self.walk_assignment_expression(expr), - Expression::UpdateExpression(expr) => self.walk_update_expression(expr), - Expression::ImportExpression(expr) => self.walk_import_expression(expr), - Expression::CallExpression(expr) => self.walk_call_expression(expr), - Expression::NewExpression(expr) => self.walk_new_expression(expr), - Expression::BinaryExpression(expr) => { self.walk_expression(&expr.left); self.walk_expression(&expr.right); } - Expression::LogicalExpression(expr) => { self.walk_expression(&expr.left); self.walk_expression(&expr.right); } - Expression::ConditionalExpression(expr) => { self.walk_expression(&expr.test); self.walk_expression(&expr.consequent); self.walk_expression(&expr.alternate); } - Expression::UnaryExpression(expr) => self.walk_expression(&expr.argument), - Expression::AwaitExpression(expr) => self.walk_expression(&expr.argument), - Expression::YieldExpression(expr) => if let Some(arg) = &expr.argument { self.walk_expression(arg); }, - Expression::SequenceExpression(expr) => for e in &expr.expressions { self.walk_expression(e); }, - Expression::ParenthesizedExpression(expr) => self.walk_expression(&expr.expression), - Expression::ChainExpression(expr) => match &expr.expression { - ChainElement::CallExpression(call) => self.walk_call_expression(call), - ChainElement::TSNonNullExpression(inner) => self.walk_expression(&inner.expression), - ChainElement::ComputedMemberExpression(inner) => { self.walk_expression(&inner.object); self.walk_expression(&inner.expression); } - ChainElement::StaticMemberExpression(inner) => self.walk_expression(&inner.object), - ChainElement::PrivateFieldExpression(inner) => self.walk_expression(&inner.object), - }, - Expression::ObjectExpression(expr) => { - for prop in &expr.properties { - match prop { - ObjectPropertyKind::ObjectProperty(prop) => { - if prop.computed { self.walk_property_key(&prop.key); } - if prop.shorthand { - if let Expression::Identifier(id) = &prop.value { - if self.is_global_name(id.name.as_str()) && !self.declared(id.name.as_str()) { - self.add_replacement(prop.span, format!("{}: {}", self.span_text(prop.key.span()), self.render_expression(&prop.value)), 90); - continue; - } - } - } - self.walk_expression(&prop.value); - } - ObjectPropertyKind::SpreadProperty(prop) => self.walk_expression(&prop.argument), - } - } - } - Expression::ArrayExpression(expr) => { - for elem in &expr.elements { - match elem { - ArrayExpressionElement::SpreadElement(spread) => self.walk_expression(&spread.argument), - ArrayExpressionElement::Elision(_) => {} - _ => self.walk_expression(elem.to_expression()), - } - } - } - Expression::FunctionExpression(func) => self.walk_function(func), - Expression::ClassExpression(class) => self.walk_class(class, false), - Expression::ArrowFunctionExpression(func) => { - let mut scope = HashSet::new(); - self.collect_formal_parameters(&func.params, &mut scope); - scope.extend(self.collect_body_bindings(&func.body.statements, ScopeMode::FunctionRoot)); - self.push_scope(scope); - for stmt in &func.body.statements { self.walk_statement(stmt); } - self.pop_scope(); - } - Expression::TemplateLiteral(tpl) => { - for expr in &tpl.expressions { self.walk_expression(expr); } - } - Expression::TaggedTemplateExpression(tagged) => { - self.walk_expression(&tagged.tag); - for expr in &tagged.quasi.expressions { self.walk_expression(expr); } - } - Expression::TSAsExpression(expr) => self.walk_expression(&expr.expression), - Expression::TSSatisfiesExpression(expr) => self.walk_expression(&expr.expression), - Expression::TSNonNullExpression(expr) => self.walk_expression(&expr.expression), - Expression::TSTypeAssertion(expr) => self.walk_expression(&expr.expression), - Expression::TSInstantiationExpression(expr) => self.walk_expression(&expr.expression), - _ => {} - } - } - - fn walk_assignment_expression(&mut self, expr: &AssignmentExpression<'a>) { - if let Some((base, prop)) = self.assignment_target(&expr.left) { - if expr.operator == AssignmentOperator::Assign { - self.add_replacement(expr.span, format!("(__zp_set({},{},{}))", base, prop, self.render_expression(&expr.right)), 100); - return; - } - let op = assignment_operator_text(expr.operator); - let rhs = if matches!(expr.operator, AssignmentOperator::LogicalAnd | AssignmentOperator::LogicalOr | AssignmentOperator::LogicalNullish) { - format!("()=>({})", self.render_expression(&expr.right)) - } else { - self.render_expression(&expr.right) - }; - self.add_replacement(expr.span, format!("(__zp_assign({},{},{:?},{}))", base, prop, op, rhs), 100); - return; - } - self.walk_assignment_target(&expr.left); - self.walk_expression(&expr.right); - } - - fn walk_update_expression(&mut self, expr: &UpdateExpression<'a>) { - if let Some((base, prop)) = self.simple_assignment_target(&expr.argument) { - self.add_replacement( - expr.span, - format!("(__zp_update({},{},{:?},{}))", base, prop, update_operator_text(expr.operator), expr.prefix), - 100, - ); - return; - } - self.walk_simple_assignment_target(&expr.argument); - } - - fn walk_simple_assignment_target(&mut self, target: &SimpleAssignmentTarget<'a>) { - match target { - SimpleAssignmentTarget::AssignmentTargetIdentifier(_) => {} - SimpleAssignmentTarget::ComputedMemberExpression(expr) => { self.walk_expression(&expr.object); self.walk_expression(&expr.expression); } - SimpleAssignmentTarget::StaticMemberExpression(expr) => self.walk_expression(&expr.object), - SimpleAssignmentTarget::PrivateFieldExpression(expr) => self.walk_expression(&expr.object), - _ => {} - } - } - - fn walk_import_expression(&mut self, expr: &ImportExpression<'a>) { - if let Expression::StringLiteral(spec) = &expr.source { - self.add_replacement(spec.span, format!("{:?}", self.module_specifier(spec.value.as_str())), 95); - return; - } - self.add_replacement(expr.source.span(), format!("__zp_module_url({},{:?})", self.render_expression(&expr.source), self.target_url), 95); - } - - fn walk_call_expression(&mut self, expr: &CallExpression<'a>) { - if let Some((base, prop)) = self.call_target(&expr.callee) { - let args = self.render_arguments(&expr.arguments); - self.add_replacement(expr.span, format!("(__zp_call({},{},[{}]))", base, prop, args), 90); - return; - } - self.walk_expression(&expr.callee); - for arg in &expr.arguments { self.walk_argument(arg); } + control_prefix, + servers: &servers, + runtime_prelude, + tab_id, + runtime_token, + }, + ) { + Ok(code) => RewriteOutput { + ok: true, + code, + error: String::new(), + }, + Err(error) => RewriteOutput { + ok: false, + code: String::new(), + error, + }, } +} - fn walk_new_expression(&mut self, expr: &NewExpression<'a>) { - if let Some(target) = self.construct_target(&expr.callee) { - let args = self.render_arguments(&expr.arguments); - self.add_replacement(expr.span, format!("(__zp_construct({},[{}]))", target, args), 90); - return; - } - self.walk_expression(&expr.callee); - for arg in &expr.arguments { self.walk_argument(arg); } +#[wasm_bindgen] +pub fn make_share_url(target: &str, servers_json: &str) -> RewriteOutput { + let servers = serde_json::from_str::>(servers_json).unwrap_or_default(); + match share_url::new_with_servers(target, &servers) { + Ok(code) => RewriteOutput { + ok: true, + code, + error: String::new(), + }, + Err(error) => RewriteOutput { + ok: false, + code: String::new(), + error, + }, } +} - fn walk_argument(&mut self, arg: &Argument<'a>) { - match arg { - Argument::SpreadElement(spread) => self.walk_expression(&spread.argument), - _ => self.walk_expression(arg.to_expression()), - } +#[wasm_bindgen] +pub fn rewrite_script_url( + raw: &str, + kind: &str, + target_url: &str, + control_prefix: &str, + tab_id: &str, + runtime_token: &str, +) -> URLRewriteOutput { + let out = js::module_urls::script_url( + raw, + normalize_kind(kind), + target_url, + RewriteContext::new(target_url, control_prefix, tab_id, runtime_token).control_prefix, + tab_id, + runtime_token, + ); + URLRewriteOutput { + ok: out.ok, + url: out.url, + target: out.target, + error: out.error, } +} - fn render_arguments(&self, args: &[Argument<'a>]) -> String { - args.iter().map(|arg| match arg { - Argument::SpreadElement(spread) => format!("...{}", self.render_expression(&spread.argument)), - _ => self.render_expression(arg.to_expression()), - }).collect::>().join(",") +#[wasm_bindgen] +pub fn rewrite_fetch_url(raw: &str, target_url: &str, control_prefix: &str) -> URLRewriteOutput { + let out = html::fetch_url( + raw, + target_url, + RewriteContext::new(target_url, control_prefix, "", "").control_prefix, + ); + URLRewriteOutput { + ok: out.ok, + url: out.url, + target: out.target, + error: out.error, } +} - fn render_expression(&self, expr: &Expression<'a>) -> String { - match expr { - Expression::Identifier(id) => { - if self.is_global_name(id.name.as_str()) && !self.declared(id.name.as_str()) { - format!("__zp_get(globalThis,{:?})", id.name.as_str()) - } else { - self.span_text(id.span).to_string() - } - } - Expression::StaticMemberExpression(expr) => self.render_static_member(expr), - Expression::ComputedMemberExpression(expr) => self.render_computed_member(expr), - Expression::PrivateFieldExpression(expr) => { - self.render_span_with(expr.span, vec![(expr.object.span(), self.render_expression(&expr.object))]) - } - Expression::CallExpression(expr) => self.render_call_expression(expr), - Expression::NewExpression(expr) => self.render_new_expression(expr), - Expression::ImportExpression(expr) => self.render_import_expression(expr), - Expression::BinaryExpression(expr) => self.render_span_with(expr.span, vec![ - (expr.left.span(), self.render_expression(&expr.left)), - (expr.right.span(), self.render_expression(&expr.right)), - ]), - Expression::LogicalExpression(expr) => self.render_span_with(expr.span, vec![ - (expr.left.span(), self.render_expression(&expr.left)), - (expr.right.span(), self.render_expression(&expr.right)), - ]), - Expression::ConditionalExpression(expr) => self.render_span_with(expr.span, vec![ - (expr.test.span(), self.render_expression(&expr.test)), - (expr.consequent.span(), self.render_expression(&expr.consequent)), - (expr.alternate.span(), self.render_expression(&expr.alternate)), - ]), - Expression::UnaryExpression(expr) => self.render_span_with(expr.span, vec![ - (expr.argument.span(), self.render_expression(&expr.argument)), - ]), - Expression::UpdateExpression(expr) => self.render_update_expression(expr), - Expression::AwaitExpression(expr) => self.render_span_with(expr.span, vec![ - (expr.argument.span(), self.render_expression(&expr.argument)), - ]), - Expression::YieldExpression(expr) => { - if let Some(arg) = &expr.argument { - self.render_span_with(expr.span, vec![(arg.span(), self.render_expression(arg))]) - } else { - self.span_text(expr.span).to_string() - } - } - Expression::SequenceExpression(expr) => self.render_span_with( - expr.span, - expr.expressions.iter().map(|e| (e.span(), self.render_expression(e))).collect(), - ), - Expression::ParenthesizedExpression(expr) => self.render_span_with(expr.span, vec![ - (expr.expression.span(), self.render_expression(&expr.expression)), - ]), - Expression::ChainExpression(expr) => self.render_chain_element(expr.span, &expr.expression), - Expression::TemplateLiteral(expr) => self.render_span_with( - expr.span, - expr.expressions.iter().map(|e| (e.span(), self.render_expression(e))).collect(), - ), - Expression::TaggedTemplateExpression(expr) => { - let mut parts = Vec::with_capacity(expr.quasi.expressions.len() + 1); - parts.push((expr.tag.span(), self.render_expression(&expr.tag))); - parts.extend(expr.quasi.expressions.iter().map(|e| (e.span(), self.render_expression(e)))); - self.render_span_with(expr.span, parts) - } - Expression::ObjectExpression(expr) => self.render_object_expression(expr), - Expression::ArrayExpression(expr) => self.render_array_expression(expr), - Expression::AssignmentExpression(expr) => self.render_assignment_expression(expr), - Expression::TSAsExpression(expr) => self.render_span_with(expr.span, vec![(expr.expression.span(), self.render_expression(&expr.expression))]), - Expression::TSSatisfiesExpression(expr) => self.render_span_with(expr.span, vec![(expr.expression.span(), self.render_expression(&expr.expression))]), - Expression::TSNonNullExpression(expr) => self.render_span_with(expr.span, vec![(expr.expression.span(), self.render_expression(&expr.expression))]), - Expression::TSTypeAssertion(expr) => self.render_span_with(expr.span, vec![(expr.expression.span(), self.render_expression(&expr.expression))]), - Expression::TSInstantiationExpression(expr) => self.render_span_with(expr.span, vec![(expr.expression.span(), self.render_expression(&expr.expression))]), - _ => self.span_text(expr.span()).to_string(), - } +#[wasm_bindgen] +pub fn rewrite_srcset(raw: &str, target_url: &str, control_prefix: &str) -> URLRewriteOutput { + let out = html::srcset( + raw, + target_url, + RewriteContext::new(target_url, control_prefix, "", "").control_prefix, + ); + URLRewriteOutput { + ok: out.ok, + url: out.url, + target: out.target, + error: out.error, } +} - fn render_span_with(&self, span: Span, mut parts: Vec<(Span, String)>) -> String { - parts.sort_by_key(|(part_span, _)| part_span.start); - let start = span.start as usize; - let end = span.end as usize; - let mut out = String::with_capacity(end.saturating_sub(start) + parts.iter().map(|(_, text)| text.len()).sum::()); - let mut pos = start; - for (part_span, text) in parts { - let part_start = part_span.start as usize; - let part_end = part_span.end as usize; - if part_start < pos || part_end > end { continue; } - out.push_str(&self.source[pos..part_start]); - out.push_str(&text); - pos = part_end; - } - out.push_str(&self.source[pos..end]); - out +#[wasm_bindgen] +pub fn resolve_target_url(raw: &str, target_url: &str, control_prefix: &str) -> URLRewriteOutput { + let out = html::target_url( + raw, + target_url, + RewriteContext::new(target_url, control_prefix, "", "").control_prefix, + ); + URLRewriteOutput { + ok: out.ok, + url: out.url, + target: out.target, + error: out.error, } +} - fn render_static_member(&self, expr: &StaticMemberExpression<'a>) -> String { - if self.is_import_meta_url_static(expr) { return format!("{:?}", self.target_url); } - if self.member_needs_helper_static(expr) { - return format!("__zp_get({},{:?})", self.render_expression(&expr.object), expr.property.name.as_str()); - } - self.render_span_with(expr.span, vec![(expr.object.span(), self.render_expression(&expr.object))]) - } +#[wasm_bindgen] +pub fn classify_link_rel(rel: &str) -> String { + html::link_rel_kind(rel).to_string() +} - fn render_computed_member(&self, expr: &ComputedMemberExpression<'a>) -> String { - if self.member_needs_helper_computed(expr) { - return format!("__zp_get({},{})", self.render_expression(&expr.object), self.render_expression(&expr.expression)); - } - self.render_span_with(expr.span, vec![ - (expr.object.span(), self.render_expression(&expr.object)), - (expr.expression.span(), self.render_expression(&expr.expression)), - ]) - } +#[wasm_bindgen] +pub fn classify_blocked_element(tag: &str) -> String { + html::blocked_element_kind(tag).to_string() +} - fn render_call_expression(&self, expr: &CallExpression<'a>) -> String { - if let Some((base, prop)) = self.call_target(&expr.callee) { - return format!("(__zp_call({},{},[{}]))", base, prop, self.render_arguments(&expr.arguments)); - } - let mut parts = Vec::with_capacity(expr.arguments.len() + 1); - parts.push((expr.callee.span(), self.render_expression(&expr.callee))); - parts.extend(expr.arguments.iter().map(|arg| (arg.span(), self.render_argument(arg)))); - self.render_span_with(expr.span, parts) - } +#[wasm_bindgen] +pub fn classify_meta_policy(http_equiv: &str) -> String { + html::meta_policy_kind(http_equiv).to_string() +} - fn render_new_expression(&self, expr: &NewExpression<'a>) -> String { - if let Some(target) = self.construct_target(&expr.callee) { - return format!("(__zp_construct({},[{}]))", target, self.render_arguments(&expr.arguments)); - } - let mut parts = Vec::with_capacity(expr.arguments.len() + 1); - parts.push((expr.callee.span(), self.render_expression(&expr.callee))); - parts.extend(expr.arguments.iter().map(|arg| (arg.span(), self.render_argument(arg)))); - self.render_span_with(expr.span, parts) - } +#[wasm_bindgen] +pub fn classify_attr_policy(tag: &str, key: &str) -> String { + html::attr_policy_kind(tag, key).to_string() +} - fn render_import_expression(&self, expr: &ImportExpression<'a>) -> String { - let source = if let Expression::StringLiteral(spec) = &expr.source { - format!("{:?}", self.module_specifier(spec.value.as_str())) - } else { - format!("__zp_module_url({},{:?})", self.render_expression(&expr.source), self.target_url) - }; - self.render_span_with(expr.span, vec![(expr.source.span(), source)]) - } +#[wasm_bindgen] +pub fn classify_script_type(script_type: &str) -> String { + html::script_type_kind(script_type).to_string() +} - fn render_update_expression(&self, expr: &UpdateExpression<'a>) -> String { - if let Some((base, prop)) = self.simple_assignment_target(&expr.argument) { - return format!("(__zp_update({},{},{:?},{}))", base, prop, update_operator_text(expr.operator), expr.prefix); - } - self.render_span_with(expr.span, vec![(expr.argument.span(), self.render_simple_assignment_target(&expr.argument))]) - } +#[wasm_bindgen] +pub fn classify_event_handler_attr(attr_name: &str) -> String { + html::event_handler_attr_kind(attr_name).to_string() +} - fn render_object_expression(&self, expr: &ObjectExpression<'a>) -> String { - let mut parts = Vec::new(); - for prop in &expr.properties { - match prop { - ObjectPropertyKind::SpreadProperty(spread) => parts.push(format!("...{}", self.render_expression(&spread.argument))), - ObjectPropertyKind::ObjectProperty(prop) => { - if prop.method || prop.kind != PropertyKind::Init { - parts.push(self.span_text(prop.span).to_string()); - } else if prop.shorthand { - parts.push(format!("{}: {}", self.span_text(prop.key.span()), self.render_expression(&prop.value))); - } else if prop.computed { - parts.push(format!("[{}]: {}", self.render_property_key(&prop.key), self.render_expression(&prop.value))); - } else { - parts.push(format!("{}: {}", self.span_text(prop.key.span()), self.render_expression(&prop.value))); - } - } - } - } - format!("{{{}}}", parts.join(",")) +fn normalize_kind(kind: &str) -> &'static str { + match kind { + "module" => "module", + "event-handler" | "event" => "event-handler", + "function" => "function", + _ => "classic", } +} - fn render_array_expression(&self, expr: &ArrayExpression<'a>) -> String { - let mut parts = Vec::new(); - for elem in &expr.elements { - match elem { - ArrayExpressionElement::Elision(_) => parts.push(String::new()), - ArrayExpressionElement::SpreadElement(spread) => parts.push(format!("...{}", self.render_expression(&spread.argument))), - _ => parts.push(self.render_expression(elem.to_expression())), - } - } - format!("[{}]", parts.join(",")) - } +#[derive(Clone, Copy)] +pub(crate) struct RewriteContext<'a> { + pub(crate) target_url: &'a str, + pub(crate) control_prefix: &'a str, + pub(crate) tab_id: &'a str, + pub(crate) runtime_token: &'a str, +} - fn render_assignment_expression(&self, expr: &AssignmentExpression<'a>) -> String { - if let Some((base, prop)) = self.assignment_target(&expr.left) { - if expr.operator == AssignmentOperator::Assign { - format!("(__zp_set({},{},{}))", base, prop, self.render_expression(&expr.right)) +impl<'a> RewriteContext<'a> { + pub(crate) fn new( + target_url: &'a str, + control_prefix: &'a str, + tab_id: &'a str, + runtime_token: &'a str, + ) -> Self { + Self { + target_url, + control_prefix: if control_prefix.is_empty() { + "/zp/" } else { - let rhs = if matches!(expr.operator, AssignmentOperator::LogicalAnd | AssignmentOperator::LogicalOr | AssignmentOperator::LogicalNullish) { - format!("()=>({})", self.render_expression(&expr.right)) - } else { - self.render_expression(&expr.right) - }; - format!("(__zp_assign({},{},{:?},{}))", base, prop, assignment_operator_text(expr.operator), rhs) - } - } else { - self.render_span_with(expr.span, vec![ - (expr.left.span(), self.render_assignment_target(&expr.left)), - (expr.right.span(), self.render_expression(&expr.right)), - ]) - } - } - - fn render_chain_element(&self, _span: Span, elem: &ChainElement<'a>) -> String { - match elem { - ChainElement::CallExpression(call) => self.render_call_expression(call), - ChainElement::TSNonNullExpression(inner) => self.render_expression(&inner.expression), - ChainElement::ComputedMemberExpression(inner) => self.render_computed_member(inner), - ChainElement::StaticMemberExpression(inner) => self.render_static_member(inner), - ChainElement::PrivateFieldExpression(inner) => { - self.render_span_with(inner.span, vec![(inner.object.span(), self.render_expression(&inner.object))]) - } - } - } - - fn render_property_key(&self, key: &PropertyKey<'a>) -> String { - match key { - PropertyKey::StaticIdentifier(id) => id.name.to_string(), - PropertyKey::PrivateIdentifier(id) => self.span_text(id.span).to_string(), - _ => self.render_expression(key.to_expression()), - } - } - - fn render_argument(&self, arg: &Argument<'a>) -> String { - match arg { - Argument::SpreadElement(spread) => format!("...{}", self.render_expression(&spread.argument)), - _ => self.render_expression(arg.to_expression()), - } - } - - fn render_assignment_target(&self, target: &AssignmentTarget<'a>) -> String { - match target { - AssignmentTarget::AssignmentTargetIdentifier(id) => self.span_text(id.span).to_string(), - AssignmentTarget::StaticMemberExpression(expr) => self.render_static_member(expr), - AssignmentTarget::ComputedMemberExpression(expr) => self.render_computed_member(expr), - AssignmentTarget::PrivateFieldExpression(expr) => self.render_span_with(expr.span, vec![(expr.object.span(), self.render_expression(&expr.object))]), - _ => self.span_text(target.span()).to_string(), - } - } - - fn render_simple_assignment_target(&self, target: &SimpleAssignmentTarget<'a>) -> String { - match target { - SimpleAssignmentTarget::AssignmentTargetIdentifier(id) => self.span_text(id.span).to_string(), - SimpleAssignmentTarget::StaticMemberExpression(expr) => self.render_static_member(expr), - SimpleAssignmentTarget::ComputedMemberExpression(expr) => self.render_computed_member(expr), - SimpleAssignmentTarget::PrivateFieldExpression(expr) => self.render_span_with(expr.span, vec![(expr.object.span(), self.render_expression(&expr.object))]), - _ => self.span_text(target.span()).to_string(), - } - } - - fn collect_body_bindings(&self, body: &[Statement<'a>], mode: ScopeMode) -> HashSet { - let mut names = HashSet::new(); - for stmt in body { self.collect_statement_bindings(stmt, mode, &mut names); } - names - } - - fn collect_statement_bindings(&self, stmt: &Statement<'a>, mode: ScopeMode, names: &mut HashSet) { - match stmt { - Statement::ImportDeclaration(decl) => { - if let Some(specs) = &decl.specifiers { - for spec in specs { - match spec { - ImportDeclarationSpecifier::ImportSpecifier(spec) => { names.insert(spec.local.name.to_string()); } - ImportDeclarationSpecifier::ImportDefaultSpecifier(spec) => { names.insert(spec.local.name.to_string()); } - ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) => { names.insert(spec.local.name.to_string()); } - } - } - } - } - Statement::FunctionDeclaration(func) => { if let Some(id) = &func.id { names.insert(id.name.to_string()); } } - Statement::ClassDeclaration(class) => { if let Some(id) = &class.id { names.insert(id.name.to_string()); } } - Statement::VariableDeclaration(decl) => { - if mode == ScopeMode::Block { - if decl.kind != VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } - } else if decl.kind == VariableDeclarationKind::Var { - for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } - } - } - Statement::BlockStatement(block) if mode != ScopeMode::Block => for stmt in &block.body { self.collect_statement_bindings(stmt, mode, names); }, - Statement::IfStatement(stmt) if mode != ScopeMode::Block => { self.collect_statement_bindings(&stmt.consequent, mode, names); if let Some(alt) = &stmt.alternate { self.collect_statement_bindings(alt, mode, names); } } - Statement::ForStatement(stmt) if mode != ScopeMode::Block => { - if let Some(ForStatementInit::VariableDeclaration(decl)) = &stmt.init { if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } } - self.collect_statement_bindings(&stmt.body, mode, names); - } - Statement::ForInStatement(stmt) if mode != ScopeMode::Block => { if let ForStatementLeft::VariableDeclaration(decl) = &stmt.left { if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } } self.collect_statement_bindings(&stmt.body, mode, names); } - Statement::ForOfStatement(stmt) if mode != ScopeMode::Block => { if let ForStatementLeft::VariableDeclaration(decl) = &stmt.left { if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } } self.collect_statement_bindings(&stmt.body, mode, names); } - Statement::WhileStatement(stmt) if mode != ScopeMode::Block => self.collect_statement_bindings(&stmt.body, mode, names), - Statement::DoWhileStatement(stmt) if mode != ScopeMode::Block => self.collect_statement_bindings(&stmt.body, mode, names), - Statement::LabeledStatement(stmt) if mode != ScopeMode::Block => self.collect_statement_bindings(&stmt.body, mode, names), - Statement::SwitchStatement(stmt) if mode != ScopeMode::Block => for case in &stmt.cases { for child in &case.consequent { self.collect_statement_bindings(child, mode, names); } }, - Statement::TryStatement(stmt) if mode != ScopeMode::Block => { - self.collect_block_bindings(&stmt.block, names, mode); - if let Some(handler) = &stmt.handler { self.collect_block_bindings(&handler.body, names, mode); } - if let Some(finalizer) = &stmt.finalizer { self.collect_block_bindings(finalizer, names, mode); } - } - _ => {} - } - } - fn collect_block_bindings(&self, block: &BlockStatement<'a>, names: &mut HashSet, mode: ScopeMode) { - for stmt in &block.body { self.collect_statement_bindings(stmt, mode, names); } - } - - fn collect_binding_pattern(&self, pattern: &BindingPattern<'a>, names: &mut HashSet) { - match &pattern.kind { - BindingPatternKind::BindingIdentifier(id) => { names.insert(id.name.to_string()); } - BindingPatternKind::AssignmentPattern(pat) => self.collect_binding_pattern(&pat.left, names), - BindingPatternKind::ArrayPattern(arr) => { - for elem in &arr.elements { if let Some(p) = elem { self.collect_binding_pattern(p, names); } } - if let Some(rest) = &arr.rest { self.collect_binding_pattern(&rest.argument, names); } - } - BindingPatternKind::ObjectPattern(obj) => { - for prop in &obj.properties { self.collect_binding_pattern(&prop.value, names); } - if let Some(rest) = &obj.rest { self.collect_binding_pattern(&rest.argument, names); } - } - } - } - - fn collect_formal_parameters(&self, params: &FormalParameters<'a>, names: &mut HashSet) { - for param in ¶ms.items { self.collect_binding_pattern(¶m.pattern, names); } - if let Some(rest) = ¶ms.rest { self.collect_binding_pattern(&rest.argument, names); } - } - - fn is_global_name(&self, name: &str) -> bool { GLOBALS.iter().any(|global| *global == name) && !self.declared(name) } - - fn member_needs_helper_static(&self, expr: &StaticMemberExpression<'a>) -> bool { - !matches!(&expr.object, Expression::Super(_)) && MEMBER_HELPER_PROPS.iter().any(|prop| *prop == expr.property.name.as_str()) - } - - fn member_needs_helper_computed(&self, expr: &ComputedMemberExpression<'a>) -> bool { - self.is_window_like_expression(&expr.object) - } - - fn is_window_like_expression(&self, expr: &Expression<'a>) -> bool { - match expr { - Expression::Identifier(id) => matches!(id.name.as_str(), "window" | "self" | "globalThis" | "top" | "parent" | "opener" | "frames" | "document") && !self.declared(id.name.as_str()), - Expression::StaticMemberExpression(member) => matches!(member.property.name.as_str(), "defaultView" | "contentWindow" | "window" | "self" | "globalThis" | "top" | "parent" | "opener" | "frames") && self.is_window_like_expression(&member.object), - Expression::ComputedMemberExpression(member) => self.is_window_like_expression(&member.object), - _ => false, - } - } - - fn is_virtual_location_expression(&self, expr: &Expression<'a>) -> bool { - match expr { - Expression::Identifier(id) => id.name == "location" && !self.declared(id.name.as_str()), - Expression::StaticMemberExpression(member) => member.property.name == "location" && self.is_window_like_expression(&member.object), - Expression::ComputedMemberExpression(member) => self.is_window_like_expression(&member.object), - _ => false, - } - } - - fn assignment_target(&self, target: &AssignmentTarget<'a>) -> Option<(String, String)> { - match target { - AssignmentTarget::AssignmentTargetIdentifier(id) if self.is_global_name(id.name.as_str()) && matches!(id.name.as_str(), "location" | "window") => Some(("globalThis".to_string(), format!("{:?}", id.name.as_str()))), - AssignmentTarget::StaticMemberExpression(expr) if expr.property.name == "location" && self.is_window_like_expression(&expr.object) => Some((self.render_expression(&expr.object), format!("{:?}", expr.property.name.as_str()))), - AssignmentTarget::StaticMemberExpression(expr) if matches!(expr.property.name.as_str(), "href" | "hash") && self.is_virtual_location_expression(&expr.object) => Some((self.render_expression(&expr.object), format!("{:?}", expr.property.name.as_str()))), - AssignmentTarget::ComputedMemberExpression(expr) if self.is_window_like_expression(&expr.object) || self.is_virtual_location_expression(&expr.object) => Some((self.render_expression(&expr.object), self.render_expression(&expr.expression))), - AssignmentTarget::StaticMemberExpression(expr) if self.member_needs_helper_static(expr) => Some((self.render_expression(&expr.object), format!("{:?}", expr.property.name.as_str()))), - AssignmentTarget::ComputedMemberExpression(expr) if self.member_needs_helper_computed(expr) => Some((self.render_expression(&expr.object), self.render_expression(&expr.expression))), - _ => None, - } - } - - - fn simple_assignment_target(&self, target: &SimpleAssignmentTarget<'a>) -> Option<(String, String)> { - match target { - SimpleAssignmentTarget::AssignmentTargetIdentifier(id) if self.is_global_name(id.name.as_str()) && matches!(id.name.as_str(), "location" | "window") => Some(("globalThis".to_string(), format!("{:?}", id.name.as_str()))), - SimpleAssignmentTarget::StaticMemberExpression(expr) if expr.property.name == "location" && self.is_window_like_expression(&expr.object) => Some((self.render_expression(&expr.object), format!("{:?}", expr.property.name.as_str()))), - SimpleAssignmentTarget::StaticMemberExpression(expr) if matches!(expr.property.name.as_str(), "href" | "hash") && self.is_virtual_location_expression(&expr.object) => Some((self.render_expression(&expr.object), format!("{:?}", expr.property.name.as_str()))), - SimpleAssignmentTarget::ComputedMemberExpression(expr) if self.is_window_like_expression(&expr.object) || self.is_virtual_location_expression(&expr.object) => Some((self.render_expression(&expr.object), self.render_expression(&expr.expression))), - SimpleAssignmentTarget::StaticMemberExpression(expr) if self.member_needs_helper_static(expr) => Some((self.render_expression(&expr.object), format!("{:?}", expr.property.name.as_str()))), - SimpleAssignmentTarget::ComputedMemberExpression(expr) if self.member_needs_helper_computed(expr) => Some((self.render_expression(&expr.object), self.render_expression(&expr.expression))), - _ => None, - } - } - fn call_target(&self, callee: &Expression<'a>) -> Option<(String, String)> { - match callee { - Expression::StaticMemberExpression(expr) => { - if matches!(&expr.object, Expression::Super(_)) { - return None; - } - let prop = expr.property.name.as_str(); - if CALL_HELPER_PROPS.iter().any(|name| *name == prop) || self.member_needs_helper_static(expr) { - Some((self.render_expression(&expr.object), format!("{:?}", prop))) - } else { - None - } - } - Expression::ComputedMemberExpression(expr) => { - if self.member_needs_helper_computed(expr) { - Some((self.render_expression(&expr.object), self.render_expression(&expr.expression))) - } else { - None - } - } - _ => None, - } - } - - fn construct_target(&self, callee: &Expression<'a>) -> Option { - match callee { - Expression::Identifier(id) if self.is_global_name(id.name.as_str()) => Some(self.render_expression(callee)), - Expression::StaticMemberExpression(expr) if self.is_window_like_expression(&expr.object) || self.member_needs_helper_static(expr) => Some(self.render_expression(callee)), - Expression::ComputedMemberExpression(expr) if self.is_window_like_expression(&expr.object) || self.member_needs_helper_computed(expr) => Some(self.render_expression(callee)), - Expression::ChainExpression(expr) => match &expr.expression { - ChainElement::StaticMemberExpression(inner) if self.is_window_like_expression(&inner.object) || self.member_needs_helper_static(inner) => Some(self.render_expression(callee)), - ChainElement::ComputedMemberExpression(inner) if self.is_window_like_expression(&inner.object) || self.member_needs_helper_computed(inner) => Some(self.render_expression(callee)), - _ => None, + control_prefix }, - _ => None, + tab_id, + runtime_token, } } - fn is_import_meta_url_static(&self, expr: &StaticMemberExpression<'a>) -> bool { - self.module && expr.property.name == "url" && matches!(&expr.object, Expression::MetaProperty(meta) if meta.meta.name == "import" && meta.property.name == "meta") - } - - fn module_specifier(&self, raw: &str) -> String { - if self.target_url.is_empty() { return raw.to_string(); } - if is_bare_specifier(raw) { return raw.to_string(); } - if has_scheme(raw) && !raw.starts_with("http://") && !raw.starts_with("https://") { - return format!("{}error/POLICY_BLOCKED", self.control_prefix); - } - let abs = join_url(self.target_url, raw); - if !abs.starts_with("http://") && !abs.starts_with("https://") { - return format!("{}error/POLICY_BLOCKED", self.control_prefix); + pub(crate) fn without_runtime_context(self) -> Self { + Self { + tab_id: "", + runtime_token: "", + ..self } - format!("{}api/script?kind=module&u={}", self.control_prefix, percent_encode(abs)) } } -fn assignment_operator_text(op: AssignmentOperator) -> &'static str { - match op { - AssignmentOperator::Assign => "=", - AssignmentOperator::Addition => "+=", - AssignmentOperator::Subtraction => "-=", - AssignmentOperator::Multiplication => "*=", - AssignmentOperator::Division => "/=", - AssignmentOperator::Remainder => "%=", - AssignmentOperator::Exponential => "**=", - AssignmentOperator::ShiftLeft => "<<=", - AssignmentOperator::ShiftRight => ">>=", - AssignmentOperator::ShiftRightZeroFill => ">>>=", - AssignmentOperator::BitwiseOR => "|=", - AssignmentOperator::BitwiseXOR => "^=", - AssignmentOperator::BitwiseAnd => "&=", - AssignmentOperator::LogicalOr => "||=", - AssignmentOperator::LogicalAnd => "&&=", - AssignmentOperator::LogicalNullish => "??=", +fn rewrite_program_source(source: &str, module: bool, ctx: RewriteContext<'_>) -> RewriteOutput { + match js::swc_rewriter::rewrite_script(source, module, ctx) { + Ok(code) => RewriteOutput { + ok: true, + code, + error: String::new(), + }, + Err(error) => RewriteOutput { + ok: false, + code: String::new(), + error, + }, } } - -fn update_operator_text(op: UpdateOperator) -> &'static str { - match op { - UpdateOperator::Increment => "++", - UpdateOperator::Decrement => "--", - } -} -fn is_bare_specifier(spec: &str) -> bool { - !spec.starts_with('/') && !spec.starts_with("./") && !spec.starts_with("../") && !has_scheme(spec) -} - -fn has_scheme(spec: &str) -> bool { - let mut chars = spec.chars(); - match chars.next() { - Some(c) if c.is_ascii_alphabetic() => {} - _ => return false, - } - for c in chars { - if c == ':' { return true; } - if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') { return false; } - } - false -} - -fn join_url(base: &str, raw: &str) -> String { - if raw.starts_with("http://") || raw.starts_with("https://") { return raw.to_string(); } - if raw.starts_with('/') { - if let Some(idx) = base.find("://") { - let rest = &base[idx + 3..]; - if let Some(slash) = rest.find('/') { return format!("{}{}", &base[..idx + 3 + slash], raw); } - } - return raw.to_string(); - } - let prefix = match base.rfind('/') { Some(i) => &base[..=i], None => base }; - let mut parts: Vec<&str> = prefix.split('/').collect(); - if parts.last() == Some(&"") { - parts.pop(); - } - for part in raw.split('/') { - match part { - "." => {} - ".." => { if parts.len() > 3 { parts.pop(); } } - _ => parts.push(part), - } +fn rewrite_wrapped_source( + source: &str, + prefix: &str, + suffix: &str, + module: bool, + ctx: RewriteContext<'_>, + event_handler: bool, +) -> RewriteOutput { + let mut wrapped = String::with_capacity(prefix.len() + source.len() + suffix.len()); + wrapped.push_str(prefix); + wrapped.push_str(source); + wrapped.push_str(suffix); + let out = rewrite_program_source(&wrapped, module, ctx); + if !out.ok { + return out; } - parts.join("/") -} - -fn percent_encode(input: String) -> String { - let mut out = String::with_capacity(input.len()); - for b in input.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), - _ => { - out.push('%'); - out.push(hex(b >> 4)); - out.push(hex(b & 15)); - } - } + let Some(inner) = generated_function_body(&out.code) else { + return RewriteOutput { + ok: false, + code: String::new(), + error: "REWRITE_FAILED".to_string(), + }; + }; + let code = if event_handler { + let mut event = String::with_capacity(inner.len() + 76); + event.push_str("return __zp_runEvent(this,event,function(__zp_scope){with(__zp_scope){\n"); + event.push_str(inner); + event.push_str("\n}})"); + event + } else { + inner.to_string() + }; + RewriteOutput { + ok: true, + code, + error: String::new(), } - out } -fn hex(v: u8) -> char { - match v { - 0..=9 => (b'0' + v) as char, - _ => (b'A' + (v - 10)) as char, - } +fn generated_function_body(code: &str) -> Option<&str> { + let start = code.find('{')? + 1; + let end = code.rfind('}')?; + (start <= end).then_some(&code[start..end]) } #[cfg(test)] @@ -1159,7 +401,9 @@ mod tests { "https://example.com/app.js", ); assert!(code.contains("return location.href;")); - assert!(code.contains("__zp_assign(__zp_get(__zp_get(globalThis,\"window\"),\"location\"),\"hash\"")); + assert!(code.contains( + "__zp_assign(__zp_get(__zp_get(globalThis,\"window\"),\"location\"),\"hash\"" + )); assert!(code.contains("__zp_get(__zp_get(globalThis,\"document\"),\"defaultView\")")); assert!(!code.contains("return __zp_get(globalThis,\"location\")")); } @@ -1171,11 +415,33 @@ mod tests { "module", "https://example.com/assets/main.js", ); - assert!(code.contains("import \"/zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Fdep.js\";")); - assert!(code.contains("__zp_module_url('./chunks/' + name + '.js',\"https://example.com/assets/main.js\")")); + assert!(code + .contains("/zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Fdep.js")); + assert!(code.contains( + "__zp_module_url(\"./chunks/\"+name+\".js\",\"https://example.com/assets/main.js\")" + )); assert!(code.contains("\"https://example.com/assets/main.js\"")); } + #[test] + fn rewrites_module_urls_with_runtime_context_when_supplied() { + let out = rewrite_script_with_context( + "import './dep.js'; export async function load() { return import('./chunk.js'); }", + "module", + "https://example.com/assets/main.js", + "/zp/", + "tab-1", + "rt-1", + ); + assert!(out.ok, "rewrite failed: {}", out.error); + assert!(out.code.contains( + "/zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Fdep.js&tab=tab-1&rt=rt-1" + )); + assert!(out.code.contains( + "import(\"/zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Fchunk.js&tab=tab-1&rt=rt-1\")" + )); + } + #[test] fn rewrites_calls_and_constructors() { let code = rewrite_ok( @@ -1183,9 +449,10 @@ mod tests { "classic", "https://example.com/app.js", ); - assert!(code.contains("__zp_set(__zp_get(globalThis,\"window\"),\"location\",'/next')")); - assert!(code.contains("__zp_construct(__zp_get(globalThis,\"WebSocket\"),['/ws',['chat']])")); - assert!(code.contains("__zp_call(Object,\"getOwnPropertyDescriptor\",[__zp_get(globalThis,\"window\"),'location'])")); + assert!(code.contains("__zp_set(__zp_get(globalThis,\"window\"),\"location\",\"/next\")")); + assert!(code + .contains("__zp_construct(__zp_get(globalThis,\"WebSocket\"),[\"/ws\",[\"chat\"]])")); + assert!(code.contains("__zp_call(Object,\"getOwnPropertyDescriptor\",[__zp_get(globalThis,\"window\"),\"location\"])")); } #[test] diff --git a/rewriter-rs/src/share_url.rs b/rewriter-rs/src/share_url.rs new file mode 100644 index 0000000..157d364 --- /dev/null +++ b/rewriter-rs/src/share_url.rs @@ -0,0 +1,268 @@ +use aes::Aes256; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::collections::HashSet; +use std::net::IpAddr; +use url::Url; + +type Aes256CbcEnc = cbc::Encryptor; +type HmacSha256 = Hmac; + +const CONTROL_PREFIX: &str = "/zp/"; +const SHARE_INFO_ENC: &[u8] = b"zp-url-cbc-enc"; +const SHARE_INFO_MAC: &[u8] = b"zp-url-cbc-mac"; +const SHARE_MAC_PREFIX: &[u8] = b"ZP-CBC-URL-V1"; +const MAX_RELAY_SERVERS: usize = 8; +const MAX_RELAY_SERVER_BYTES: usize = 2048; +const SEED_LEN: usize = 64; +const IV_LEN: usize = 16; + +pub(crate) fn new_with_servers(target: &str, servers: &[String]) -> Result { + let mut random = [0u8; SEED_LEN + IV_LEN]; + getrandom::getrandom(&mut random).map_err(|err| err.to_string())?; + new_with_seed_iv_and_servers(target, servers, &random[..SEED_LEN], &random[SEED_LEN..]) +} + +pub(crate) fn new_with_seed_iv_and_servers( + target: &str, + servers: &[String], + seed: &[u8], + iv: &[u8], +) -> Result { + if seed.len() != SEED_LEN || iv.len() != IV_LEN { + return Err("shareurl: invalid random material".to_string()); + } + let target = validate_target(target)?; + let encrypted = seal_token(seed, iv, &target)?; + let fragment = share_fragment(&URL_SAFE_NO_PAD.encode(seed), servers)?; + Ok(format!("{CONTROL_PREFIX}p/{encrypted}{fragment}")) +} + +fn validate_target(target: &str) -> Result { + let parsed = Url::parse(target).map_err(|_| "shareurl: unsupported target URL".to_string())?; + if parsed.host_str().is_none() || !matches!(parsed.scheme(), "http" | "https") { + return Err("shareurl: unsupported target URL".to_string()); + } + Ok(go_style_target_string(target)) +} + +fn go_style_target_string(target: &str) -> String { + target.to_string() +} + +fn seal_token(seed: &[u8], iv: &[u8], target: &str) -> Result { + let enc_key = derive(seed, SHARE_INFO_ENC); + let mac_key = derive(seed, SHARE_INFO_MAC); + let ciphertext = Aes256CbcEnc::new_from_slices(&enc_key, iv) + .map_err(|_| "shareurl: encryption failed".to_string())? + .encrypt_padded_vec_mut::(target.as_bytes()); + + let mut mac = HmacSha256::new_from_slice(&mac_key) + .map_err(|_| "shareurl: encryption failed".to_string())?; + mac.update(SHARE_MAC_PREFIX); + mac.update(iv); + mac.update(&ciphertext); + let tag = mac.finalize().into_bytes(); + + let mut blob = Vec::with_capacity(iv.len() + ciphertext.len() + tag.len()); + blob.extend_from_slice(iv); + blob.extend_from_slice(&ciphertext); + blob.extend_from_slice(&tag); + Ok(URL_SAFE_NO_PAD.encode(blob)) +} + +fn derive(seed: &[u8], info: &[u8]) -> [u8; 32] { + let hk = hkdf::Hkdf::::new(None, seed); + let mut key = [0u8; 32]; + hk.expand(info, &mut key) + .expect("HKDF-SHA256 32-byte output is valid"); + key +} + +fn share_fragment(key: &str, servers: &[String]) -> Result { + let normalized = normalize_relay_servers(servers)?; + let mut out = format!("#k={}", form_encode(key)); + for server in normalized { + out.push_str("&server="); + out.push_str(&form_encode(&server)); + } + Ok(out) +} + +fn normalize_relay_servers(values: &[String]) -> Result, String> { + if values.is_empty() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut total = 0usize; + for raw in values { + let value = raw.trim(); + if value.is_empty() { + continue; + } + if out.len() >= MAX_RELAY_SERVERS { + return Err("shareurl: too many relay servers".to_string()); + } + let url = validate_relay_url(value)?; + let normalized = canonicalize_relay_url(&url)?; + total += normalized.len(); + if total > MAX_RELAY_SERVER_BYTES { + return Err("shareurl: relay server list too large".to_string()); + } + if seen.insert(normalized.clone()) { + out.push(normalized); + } + } + Ok(out) +} + +fn validate_relay_url(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| "shareurl: malformed relay server".to_string())?; + if url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() { + return Err("shareurl: malformed relay server".to_string()); + } + if url.fragment().is_some() { + return Err("shareurl: malformed relay server".to_string()); + } + match url.scheme() { + "wss" => Ok(url), + "ws" if is_loopback_host(url.host_str().unwrap_or_default()) => Ok(url), + "ws" => Err("shareurl: insecure relay server".to_string()), + _ => Err("shareurl: unsupported relay server".to_string()), + } +} + +fn canonicalize_relay_url(url: &Url) -> Result { + let host = url + .host_str() + .ok_or_else(|| "shareurl: malformed relay server".to_string())? + .to_ascii_lowercase(); + let port = match (url.scheme(), url.port()) { + ("wss", Some(443)) | ("ws", Some(80)) | (_, None) => None, + (_, value) => value, + }; + let host_port = canonical_host_port(&host, port); + let path = if url.path().is_empty() { + "/" + } else { + url.path() + }; + let mut out = format!("{}://{}{}", url.scheme(), host_port, path); + if let Some(query) = url.query() { + out.push('?'); + out.push_str(query); + } + Ok(out) +} + +fn canonical_host_port(host: &str, port: Option) -> String { + match port { + Some(port) if host.contains(':') => format!("[{host}]:{port}"), + Some(port) => format!("{host}:{port}"), + None if host.contains(':') => format!("[{host}]"), + None => host.to_string(), + } +} + +fn is_loopback_host(host: &str) -> bool { + let host = host + .trim_matches(|ch| matches!(ch, '[' | ']')) + .trim_end_matches('.') + .to_ascii_lowercase(); + if host == "localhost" || host.ends_with(".localhost") { + return true; + } + host.parse::() + .map(|addr| addr.is_loopback()) + .unwrap_or(false) +} + +fn form_encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixed_seed() -> [u8; SEED_LEN] { + [b'x'; SEED_LEN] + } + + fn fixed_iv() -> [u8; IV_LEN] { + [b'x'; IV_LEN] + } + + #[test] + fn golden_paths_match_go_shareurl() { + let cases = [ + ( + "https://example.com/path?q=1#frag", + vec![], + "/zp/p/eHh4eHh4eHh4eHh4eHh4eIRIkG1kf2-7MFSHXtEOyKsGTPBGny25c3KxeManFS88nq7MV4yF8_MwR6ghGmIXmT_motZWmAqxtGPEBz4FjkXCM1O5VlrfyudrlmRcc8IL#k=eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eA", + ), + ( + "https://example.com/path", + vec![ + "wss://relay.example:443/ws".to_string(), + "wss://relay.example/ws".to_string(), + "ws://proxy.localhost:8080/zp/ws-pipe".to_string(), + ], + "/zp/p/eHh4eHh4eHh4eHh4eHh4eIRIkG1kf2-7MFSHXtEOyKvBwni9ryndDvRCNNPp9x6foyLSYfD7xtgdO0GwsRK82SpJmr2XaXriQYqZ_0WtGIE#k=eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eA&server=wss%3A%2F%2Frelay.example%2Fws&server=ws%3A%2F%2Fproxy.localhost%3A8080%2Fzp%2Fws-pipe", + ), + ( + "http://example.com/", + vec![], + "/zp/p/eHh4eHh4eHh4eHh4eHh4eGD2wwf3pssbrhy-l3jPIAgaCd6Z87IeXesaMtPJQEtSkdyZL3aPjYZUVOznQI9cZXXd4njoLKkoVRGEkQj9ZFA#k=eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eA", + ), + ( + "http://example.com", + vec![], + "/zp/p/eHh4eHh4eHh4eHh4eHh4eGD2wwf3pssbrhy-l3jPIAjAUisyTCe0qFeTsfORYzevSK5mx5BVsZqMf75u4Aw7feQqCLBSrYzVZfY4YjMVaGQ#k=eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eA", + ), + ( + "https://Example.COM/Path", + vec![], + "/zp/p/eHh4eHh4eHh4eHh4eHh4eOHWWxMahmbnZOqSVnxNx60uARvcXfqSKHrLqYfzIZgccQp1jClyl08hr1z-ULtAg4OewgvRse10iid1vln1r9I#k=eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eA", + ), + ]; + for (target, servers, want) in cases { + let got = + new_with_seed_iv_and_servers(target, &servers, &fixed_seed(), &fixed_iv()).unwrap(); + assert_eq!(got, want, "{target}"); + } + } + + #[test] + fn rejects_unsupported_targets_and_relays_like_go() { + for target in [ + "", + "://bad", + "ws://example.com/socket", + "wss://example.com/socket", + "javascript:alert(1)", + "data:text/html,hi", + "/relative", + "https://", + ] { + assert_eq!( + new_with_seed_iv_and_servers(target, &[], &fixed_seed(), &fixed_iv()).unwrap_err(), + "shareurl: unsupported target URL" + ); + } + assert_eq!( + new_with_seed_iv_and_servers( + "https://h/", + &["ws://example.com/x".to_string()], + &fixed_seed(), + &fixed_iv(), + ) + .unwrap_err(), + "shareurl: insecure relay server" + ); + } +} diff --git a/scripts/build.mjs b/scripts/build.mjs index 9c50baa..b8b38bf 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,5 +1,4 @@ #!/usr/bin/env node -import esbuild from 'esbuild'; import { spawnSync } from 'node:child_process'; import { access, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; @@ -11,14 +10,27 @@ const outRoot = path.resolve(repoRoot, args.out || 'dist'); const webSrc = path.join(repoRoot, 'web'); const webOut = path.join(outRoot, 'web'); const kernelOut = path.join(outRoot, 'kernel.wasm'); -const serverOut = path.join(outRoot, process.platform === 'win32' ? 'zeroproxy-server.exe' : 'zeroproxy-server'); +const serverOut = path.join( + outRoot, + process.platform === 'win32' ? 'zeroproxy-server.exe' : 'zeroproxy-server', +); const cargoHome = process.env.CARGO_HOME || path.join(process.env.HOME || '', '.cargo'); -const cargoBinPath = path.join(cargoHome, 'bin', process.platform === 'win32' ? 'cargo.exe' : 'cargo'); -const wasmBindgenBinPath = path.join(cargoHome, 'bin', process.platform === 'win32' ? 'wasm-bindgen.exe' : 'wasm-bindgen'); +const cargoBinPath = path.join( + cargoHome, + 'bin', + process.platform === 'win32' ? 'cargo.exe' : 'cargo', +); +const wasmBindgenBinPath = path.join( + cargoHome, + 'bin', + process.platform === 'win32' ? 'wasm-bindgen.exe' : 'wasm-bindgen', +); const minify = args.minify === true; if (args.help) { - process.stdout.write(`Usage: node scripts/build.mjs [options]\n\nOptions:\n --out Output directory (default: dist)\n --web-only Build only browser assets\n --kernel-only Build only the Go WASM kernel\n --server-only Build only the relay server\n --skip-web Do not build browser assets\n --skip-kernel Do not build the Go WASM kernel\n --skip-server Do not build the relay server\n --minify Minify bundled JavaScript\n --no-clean Keep existing output files not overwritten by this run\n`); + process.stdout.write( + `Usage: node scripts/build.mjs [options]\n\nOptions:\n --out Output directory (default: dist)\n --web-only Build only browser assets\n --kernel-only Build only the Go WASM kernel\n --server-only Build only the relay server\n --skip-web Do not build browser assets\n --skip-kernel Do not build the Go WASM kernel\n --skip-server Do not build the relay server\n --minify Minify bundled JavaScript\n --no-clean Keep existing output files not overwritten by this run\n`, + ); process.exit(0); } @@ -97,23 +109,40 @@ async function buildWeb() { const goWasmExec = await readGoWasmExec(); const rustRewriter = await makeRustRewriterClassic(); - const serviceWorker = stripServiceWorkerImports(await readSource('sw.js')); - const workerPrelude = stripWorkerPreludeImports(await readSource('worker-prelude.js')); await copyFile(path.join(webSrc, 'index.html'), path.join(webOut, 'index.html')); await copyOptional(path.join(webSrc, 'favicon.ico'), path.join(webOut, 'favicon.ico')); - await copyOptional(path.join(webSrc, 'manifest.webmanifest'), path.join(webOut, 'manifest.webmanifest')); + await copyOptional( + path.join(webSrc, 'manifest.webmanifest'), + path.join(webOut, 'manifest.webmanifest'), + ); - await writeBundled('zp-core.js', [await readSource('zp-core.js')]); - await writeBundled('runtime-prelude.js', [await readSource('runtime-prelude.js')]); - await writeBundled('rust-rewriter.js', [rustRewriter]); - await writeBundled('wasm_exec.js', [goWasmExec]); - await writeBundled('worker-prelude.js', [await readSource('zp-core.js'), workerPrelude]); - await writeBundled('sw.js', [await readSource('zp-core.js'), rustRewriter, goWasmExec, serviceWorker]); + await writeClassicAsset('zp-core.js', await readSource('zp-core.js')); + await writeViteBundle('runtime-prelude.js', { + inputFileName: 'runtime-prelude-entry.mjs', + virtualModules: { + 'virtual:zeroproxy-rust-rewriter': rustRewriter, + }, + }); + await writeClassicAsset('rust-rewriter.js', rustRewriter); + await writeClassicAsset('http-rewriter.js', await readSource('http-rewriter.js')); + await writeClassicAsset('wasm_exec.js', goWasmExec); + await writeViteBundle('worker-prelude.js', { inputFileName: 'worker-prelude-entry.mjs' }); + await writeViteBundle('sw.js', { + inputFileName: 'sw-entry.mjs', + virtualModules: { + 'virtual:zeroproxy-rust-rewriter': rustRewriter, + 'virtual:zeroproxy-wasm-exec': goWasmExec, + 'virtual:zeroproxy-sw-body': stripServiceWorkerImports(await readSource('sw.js')), + }, + }); } function buildKernel() { - run('go', ['build', '-trimpath', '-o', kernelOut, './cmd/wasm-kernel'], { GOOS: 'js', GOARCH: 'wasm' }); + run('go', ['build', '-trimpath', '-o', kernelOut, './cmd/wasm-kernel'], { + GOOS: 'js', + GOARCH: 'wasm', + }); } function buildServer() { @@ -124,38 +153,157 @@ async function readSource(name) { return readFile(path.join(webSrc, name), 'utf8'); } -async function writeBundled(fileName, parts) { - const source = parts.map(part => String(part).trimEnd()).join('\n;\n') + '\n'; - const result = await esbuild.transform(source, { - charset: 'utf8', - legalComments: 'none', - loader: 'js', - minify, - target: 'es2022', +async function writeClassicAsset(fileName, source) { + await writeFile(path.join(webOut, fileName), `${String(source).trimEnd()}\n`); +} + +async function writeViteBundle(entryFileName, options = {}) { + const { build } = await import('vite'); + const inputFileName = options.inputFileName || entryFileName; + await build({ + configFile: path.join(repoRoot, 'vite.config.mjs'), + mode: 'production', + logLevel: 'warn', + plugins: [virtualSourcePlugin(options.virtualModules || {})], + build: { + outDir: webOut, + emptyOutDir: false, + minify, + rollupOptions: { + input: path.join(webSrc, inputFileName), + treeshake: false, + output: { + entryFileNames: entryFileName, + format: 'iife', + }, + }, + }, }); - await writeFile(path.join(webOut, fileName), result.code); } -function stripServiceWorkerImports(source) { - return source.replace(/^importScripts\('\/zp\/assets\/(?:zp-core|rust-rewriter|wasm_exec)\.js'\);\n/gm, ''); +function virtualSourcePlugin(modules) { + const prefix = '\0'; + return { + name: 'zeroproxy-virtual-source', + resolveId(id) { + return Object.hasOwn(modules, id) ? prefix + id : null; + }, + load(id) { + const name = id.startsWith(prefix) ? id.slice(prefix.length) : id; + return Object.hasOwn(modules, name) ? modules[name] : null; + }, + }; } -function stripWorkerPreludeImports(source) { - return source.replace(/^\s*importScripts\('\/zp\/assets\/zp-core\.js'\);\n/m, ''); +function stripServiceWorkerImports(source) { + return source.replace( + /^importScripts\('\/zp\/assets\/(?:zp-core|rust-rewriter|http-rewriter|wasm_exec|sw-kernel|sw-routes|sw-transport|sw-responses)\.js'\);\n/gm, + '', + ); } async function makeRustRewriterClassic() { const crateDir = path.join(repoRoot, 'rewriter-rs'); const targetDir = path.join(crateDir, 'target'); - run(cargoBinPath, ['build', '--manifest-path', path.join(crateDir, 'Cargo.toml'), '--target', 'wasm32-unknown-unknown', '--release']); + run(cargoBinPath, [ + 'build', + '--manifest-path', + path.join(crateDir, 'Cargo.toml'), + '--target', + 'wasm32-unknown-unknown', + '--release', + ]); const bindgenOut = path.join(targetDir, 'wasm-bindgen'); await rm(bindgenOut, { recursive: true, force: true }); await mkdir(bindgenOut, { recursive: true }); - run(wasmBindgenBinPath, ['--target', 'no-modules', '--out-dir', bindgenOut, path.join(targetDir, 'wasm32-unknown-unknown', 'release', 'zp_rewriter.wasm')]); + run(wasmBindgenBinPath, [ + '--target', + 'no-modules', + '--out-dir', + bindgenOut, + path.join(targetDir, 'wasm32-unknown-unknown', 'release', 'zp_rewriter.wasm'), + ]); const js = await readFile(path.join(bindgenOut, 'zp_rewriter.js'), 'utf8'); - const wasmBase64 = (await readFile(path.join(bindgenOut, 'zp_rewriter_bg.wasm'))).toString('base64'); - return `/* Generated from Rust WASM ZeroProxy rewriter. */\n${js}\n(() => {\nconst VERSION = 'phase3-rust-wasm-ast-2';\nconst BLOCK_CODE = \"throw new DOMException('Blocked by ZeroProxy rewrite policy','NotSupportedError');\";\nconst __zp_rust_b64 = ${JSON.stringify(wasmBase64)};\nconst __zp_rust_bytes = Uint8Array.from(atob(__zp_rust_b64), ch => ch.charCodeAt(0));\nwasm_bindgen.initSync({ module: __zp_rust_bytes });\nfunction normalizeKind(kind) { kind = String(kind || 'classic').toLowerCase(); if (kind === 'worker') return 'classic'; if (kind === 'event' || kind === 'event-handler') return 'event-handler'; if (kind === 'function') return 'function'; if (kind === 'module') return 'module'; return 'classic'; }\nfunction lowLevel(source, kind, targetUrl, controlPrefix) { const out = wasm_bindgen.rewrite_script(String(source || ''), normalizeKind(kind), String(targetUrl || ''), String(controlPrefix || '/zp/')); try { return { ok: !!out.ok, code: out.code, error: out.error || '' }; } finally { out.free && out.free(); } }\nfunction publicOk(code) { return { ok: true, code, diagnostics: [] }; }\nfunction publicBlocked(error) { const code = error || 'REWRITE_FAILED'; return { ok: false, errorCode: code, diagnostics: [{ level: 'error', message: code }] }; }\nfunction rewriteScriptPublic(source, options = {}) { const opts = options && typeof options === 'object' ? options : { kind: options }; const out = lowLevel(source, opts.scriptKind || opts.kind, opts.url || opts.targetUrl || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/'); return out.ok ? publicOk(out.code) : publicBlocked(out.error); }\nfunction rewriteFunctionBodyRaw(source, params, targetUrl, controlPrefix) { const list = Array.isArray(params) ? params : []; const prefix = 'function __zp_dynamic__(' + list.map(value => String(value)).join(',') + '){\\n'; const suffix = '\\n}'; const out = lowLevel(prefix + String(source || '') + suffix, 'classic', targetUrl, controlPrefix); if (!out.ok) return out; const end = out.code.length - suffix.length; if (end < prefix.length) return { ok: false, code: '', error: 'REWRITE_FAILED' }; return { ok: true, code: out.code.slice(prefix.length, end), error: '' }; }\nfunction rewriteFunctionBodyPublic(source, params, targetUrl, controlPrefix) { const out = rewriteFunctionBodyRaw(source, params, targetUrl, controlPrefix); return out.ok ? publicOk(out.code) : publicBlocked(out.error); }\nconst rustApi = Object.freeze({ rewriteScript(source, kind, targetUrl, controlPrefix) { return lowLevel(source, kind, targetUrl, controlPrefix); }, rewriteFunctionBody: rewriteFunctionBodyRaw });\nconst rewriterApi = Object.freeze({ VERSION, ready: true, init() { return Promise.resolve(true); }, initSync() { return true; }, rewriteScript: rewriteScriptPublic, rewriteFunctionBody: rewriteFunctionBodyPublic, blockSource() { return BLOCK_CODE; } });\nObject.defineProperty(globalThis, 'ZPRustRewriter', { value: rustApi, enumerable: false, configurable: false, writable: false });\nObject.defineProperty(globalThis, 'ZPRewriter', { value: rewriterApi, enumerable: false, configurable: false, writable: false });\n})();\n`; + await writeOptimizedWasm( + path.join(bindgenOut, 'zp_rewriter_bg.wasm'), + path.join(webOut, 'rust-rewriter.wasm'), + ); + return [ + '/* Generated from Rust WASM ZeroProxy rewriter. */', + '(() => {', + `const installedRustAPI = Object.getOwnPropertyDescriptor(globalThis, 'ZPRustRewriter');`, + `const installedPublicAPI = Object.getOwnPropertyDescriptor(globalThis, 'ZPRewriter');`, + `if (installedRustAPI && installedRustAPI.configurable === false && installedPublicAPI && installedPublicAPI.configurable === false) return;`, + js, + "const VERSION = 'phase3-rust-wasm-ast-4-import-map';", + `const BLOCK_CODE = "throw new DOMException('Blocked by ZeroProxy rewrite policy','NotSupportedError');";`, + `const WASM_URL = '/zp/assets/rust-rewriter.wasm';`, + `let initialized = false;`, + `let initError = null;`, + `let initPromise = null;`, + `function wasmSource() { return WASM_URL; }`, + `function loadWasmBytesSync() { if (typeof XMLHttpRequest !== 'function') return null; const xhr = new XMLHttpRequest(); xhr.open('GET', WASM_URL, false); if (xhr.overrideMimeType) xhr.overrideMimeType('text/plain; charset=x-user-defined'); xhr.send(null); if (!((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0)) throw new Error('RUST_REWRITER_WASM_HTTP_' + xhr.status); const text = String(xhr.responseText || ''); const bytes = new Uint8Array(text.length); for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 255; return bytes; }`, + `function clearWasmTiming() { try { if (globalThis.performance && typeof globalThis.performance.clearResourceTimings === 'function') globalThis.performance.clearResourceTimings(); } catch {} }`, + `function init() { if (initialized) return Promise.resolve(true); if (!initPromise) initPromise = wasm_bindgen({ module_or_path: wasmSource() }).then(() => { initialized = true; clearWasmTiming(); return true; }).catch(err => { initError = err; initPromise = null; throw err; }); return initPromise; }`, + `function initSync(bytes) { const source = bytes || loadWasmBytesSync(); if (!source) return false; if (!initialized) { wasm_bindgen.initSync({ module: source }); initialized = true; clearWasmTiming(); } return true; }`, + `function bootstrapInit() { try { if (initSync()) return; } catch {} init().catch(() => {}); }`, + `function ensureReady() { if (!initialized) throw initError || new Error('RUST_REWRITER_NOT_READY'); }`, + `function normalizeKind(kind) { kind = String(kind || 'classic').toLowerCase(); if (kind === 'worker') return 'classic'; if (kind === 'event' || kind === 'event-handler') return 'event-handler'; if (kind === 'function') return 'function'; if (kind === 'module') return 'module'; return 'classic'; }`, + `function lowLevel(source, kind, targetUrl, controlPrefix) { return lowLevelWithContext(source, kind, targetUrl, controlPrefix, '', ''); }`, + `function lowLevelWithContext(source, kind, targetUrl, controlPrefix, tabId, runtimeToken) { ensureReady(); const out = wasm_bindgen.rewrite_script_with_context(String(source || ''), normalizeKind(kind), String(targetUrl || ''), String(controlPrefix || '/zp/'), String(tabId || ''), String(runtimeToken || '')); try { return { ok: !!out.ok, code: out.code, error: out.error || '' }; } finally { out.free && out.free(); } }`, + `function lowLevelScriptURL(raw, kind, targetUrl, controlPrefix, tabId, runtimeToken) { ensureReady(); const out = wasm_bindgen.rewrite_script_url(String(raw || ''), normalizeKind(kind), String(targetUrl || ''), String(controlPrefix || '/zp/'), String(tabId || ''), String(runtimeToken || '')); try { return { ok: !!out.ok, url: out.url, target: out.target, error: out.error || '' }; } finally { out.free && out.free(); } }`, + `function lowLevelFetchURL(raw, targetUrl, controlPrefix) { ensureReady(); const out = wasm_bindgen.rewrite_fetch_url(String(raw || ''), String(targetUrl || ''), String(controlPrefix || '/zp/')); try { return { ok: !!out.ok, url: out.url, target: out.target, error: out.error || '' }; } finally { out.free && out.free(); } }`, + `function lowLevelSrcset(raw, targetUrl, controlPrefix) { ensureReady(); const out = wasm_bindgen.rewrite_srcset(String(raw || ''), String(targetUrl || ''), String(controlPrefix || '/zp/')); try { return { ok: !!out.ok, url: out.url, target: out.target, error: out.error || '' }; } finally { out.free && out.free(); } }`, + `function lowLevelTargetURL(raw, targetUrl, controlPrefix) { ensureReady(); const out = wasm_bindgen.resolve_target_url(String(raw || ''), String(targetUrl || ''), String(controlPrefix || '/zp/')); try { return { ok: !!out.ok, url: out.url, target: out.target, error: out.error || '' }; } finally { out.free && out.free(); } }`, + `function lowLevelLinkRel(rel) { ensureReady(); return wasm_bindgen.classify_link_rel(String(rel || '')); }`, + `function lowLevelBlockedElement(tag) { ensureReady(); return wasm_bindgen.classify_blocked_element(String(tag || '')); }`, + `function lowLevelMetaPolicy(httpEquiv) { ensureReady(); return wasm_bindgen.classify_meta_policy(String(httpEquiv || '')); }`, + `function lowLevelAttrPolicy(tag, key) { ensureReady(); return wasm_bindgen.classify_attr_policy(String(tag || ''), String(key || '')); }`, + `function lowLevelScriptType(scriptType) { ensureReady(); return wasm_bindgen.classify_script_type(String(scriptType || '')); }`, + `function lowLevelEventHandlerAttr(attrName) { ensureReady(); return wasm_bindgen.classify_event_handler_attr(String(attrName || '')); }`, + `function lowLevelCSS(source, baseUrl, controlPrefix) { ensureReady(); const out = wasm_bindgen.rewrite_css(String(source || ''), String(baseUrl || ''), String(controlPrefix || '/zp/')); try { return { ok: !!out.ok, code: out.code, error: out.error || '' }; } finally { out.free && out.free(); } }`, + `function lowLevelImportMap(source, baseUrl, tabId, runtimeToken, controlPrefix) { ensureReady(); return wasm_bindgen.rewrite_import_map(String(source || ''), String(baseUrl || ''), String(tabId || ''), String(runtimeToken || ''), String(controlPrefix || '/zp/')); }`, + `function lowLevelHTMLDocument(source, targetUrl, controlPrefix, servers, runtimePrelude, tabId, runtimeToken) { ensureReady(); const out = wasm_bindgen.rewrite_html_document(String(source || ''), String(targetUrl || ''), String(controlPrefix || '/zp/'), JSON.stringify(Array.isArray(servers) ? servers : []), String(runtimePrelude || ''), String(tabId || ''), String(runtimeToken || '')); try { return { ok: !!out.ok, code: out.code, error: out.error || '' }; } finally { out.free && out.free(); } }`, + `function lowLevelShareURL(target, servers) { ensureReady(); const out = wasm_bindgen.make_share_url(String(target || ''), JSON.stringify(Array.isArray(servers) ? servers : [])); try { return { ok: !!out.ok, url: out.code, error: out.error || '' }; } finally { out.free && out.free(); } }`, + `function publicOk(code) { return { ok: true, code, diagnostics: [] }; }`, + `function publicBlocked(error) { const code = error || 'REWRITE_FAILED'; return { ok: false, errorCode: code, diagnostics: [{ level: 'error', message: code }] }; }`, + `function rewriteScriptPublic(source, options = {}) { const opts = options && typeof options === 'object' ? options : { kind: options }; const out = lowLevelWithContext(source, opts.scriptKind || opts.kind, opts.url || opts.targetUrl || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/', opts.tabId || opts.tab || '', opts.runtimeToken || opts.rt || ''); return out.ok ? publicOk(out.code) : publicBlocked(out.error); }`, + `function rewriteScriptURLPublic(raw, options = {}) { const opts = options && typeof options === 'object' ? options : { kind: options }; const out = lowLevelScriptURL(raw, opts.scriptKind || opts.kind, opts.url || opts.targetUrl || opts.baseUrl || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/', opts.tabId || opts.tab || '', opts.runtimeToken || opts.rt || ''); return out.ok ? { ok: true, url: out.url, target: out.target, diagnostics: [] } : { ok: false, url: out.url || '', target: '', errorCode: out.error || 'POLICY_BLOCKED', diagnostics: [{ level: 'error', message: out.error || 'POLICY_BLOCKED' }] }; }`, + `function rewriteFetchURLPublic(raw, options = {}) { const opts = options && typeof options === 'object' ? options : { targetUrl: options }; const out = lowLevelFetchURL(raw, opts.url || opts.targetUrl || opts.baseUrl || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/'); return out.ok ? { ok: true, url: out.url, target: out.target, diagnostics: [] } : { ok: false, url: out.url || '', target: '', errorCode: out.error || 'POLICY_BLOCKED', diagnostics: [{ level: 'error', message: out.error || 'POLICY_BLOCKED' }] }; }`, + `function rewriteSrcsetPublic(raw, options = {}) { const opts = options && typeof options === 'object' ? options : { targetUrl: options }; const out = lowLevelSrcset(raw, opts.url || opts.targetUrl || opts.baseUrl || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/'); return out.ok ? { ok: true, url: out.url, target: out.target, diagnostics: [] } : { ok: false, url: out.url || '', target: out.target || String(raw || ''), errorCode: out.error || 'UNCHANGED', diagnostics: [{ level: 'error', message: out.error || 'UNCHANGED' }] }; }`, + `function rewriteTargetURLPublic(raw, options = {}) { const opts = options && typeof options === 'object' ? options : { targetUrl: options }; const out = lowLevelTargetURL(raw, opts.url || opts.targetUrl || opts.baseUrl || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/'); return out.ok ? { ok: true, url: out.url, target: out.target, diagnostics: [] } : { ok: false, url: out.url || '', target: '', errorCode: out.error || 'POLICY_BLOCKED', diagnostics: [{ level: 'error', message: out.error || 'POLICY_BLOCKED' }] }; }`, + `function classifyLinkRelPublic(rel) { return lowLevelLinkRel(rel); }`, + `function classifyBlockedElementPublic(tag) { return lowLevelBlockedElement(tag); }`, + `function classifyMetaPolicyPublic(httpEquiv) { return lowLevelMetaPolicy(httpEquiv); }`, + `function classifyAttrPolicyPublic(tag, key) { return lowLevelAttrPolicy(tag, key); }`, + `function classifyScriptTypePublic(scriptType) { return lowLevelScriptType(scriptType); }`, + `function classifyEventHandlerAttrPublic(attrName) { return lowLevelEventHandlerAttr(attrName); }`, + `function rewriteCSSPublic(source, options = {}) { const opts = options && typeof options === 'object' ? options : { baseUrl: options }; const out = lowLevelCSS(source, opts.baseUrl || opts.url || opts.targetUrl || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/'); return out.ok ? publicOk(out.code) : publicBlocked(out.error); }`, + `function rewriteImportMapPublic(source, options = {}) { const opts = options && typeof options === 'object' ? options : { baseUrl: options }; return publicOk(lowLevelImportMap(source, opts.baseUrl || opts.url || opts.targetUrl || '', opts.tabId || opts.tab || '', opts.runtimeToken || opts.rt || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/')); }`, + `function rewriteHTMLDocumentPublic(source, options = {}) { const opts = options && typeof options === 'object' ? options : { targetUrl: options }; const out = lowLevelHTMLDocument(source, opts.url || opts.targetUrl || opts.baseUrl || '', opts.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/', opts.servers || [], opts.runtimePrelude || opts.prelude || '', opts.tabId || opts.tab || '', opts.runtimeToken || opts.rt || ''); return out.ok ? publicOk(out.code) : publicBlocked(out.error); }`, + `function makeShareURLPublic(target, options = {}) { const opts = options && typeof options === 'object' ? options : {}; const out = lowLevelShareURL(target, opts.servers || []); return out.ok ? { ok: true, url: out.url, diagnostics: [] } : { ok: false, url: '', errorCode: out.error || 'POLICY_BLOCKED', diagnostics: [{ level: 'error', message: out.error || 'POLICY_BLOCKED' }] }; }`, + `function generatedFunctionBody(code) { const start = String(code || '').indexOf('{'); const end = String(code || '').lastIndexOf('}'); return start >= 0 && end >= start ? String(code).slice(start + 1, end) : null; }`, + `function rewriteFunctionBodyRaw(source, params, targetUrl, controlPrefix) { const list = Array.isArray(params) ? params : []; const prefix = 'function __zp_dynamic__(' + list.map(value => String(value)).join(',') + '){\\n'; const suffix = '\\n}'; const out = lowLevel(prefix + String(source || '') + suffix, 'classic', targetUrl, controlPrefix); if (!out.ok) return out; const body = generatedFunctionBody(out.code); if (body == null) return { ok: false, code: '', error: 'REWRITE_FAILED' }; return { ok: true, code: body, error: '' }; }`, + `function rewriteFunctionBodyPublic(source, params, targetUrl, controlPrefix) { const out = rewriteFunctionBodyRaw(source, params, targetUrl, controlPrefix); return out.ok ? publicOk(out.code) : publicBlocked(out.error); }`, + `const rustApi = Object.freeze({ init, initSync, get ready() { return initialized; }, rewriteScript(source, kind, targetUrl, controlPrefix, tabId, runtimeToken) { return lowLevelWithContext(source, kind, targetUrl, controlPrefix, tabId, runtimeToken); }, rewriteScriptURL(raw, kind, targetUrl, controlPrefix, tabId, runtimeToken) { return lowLevelScriptURL(raw, kind, targetUrl, controlPrefix, tabId, runtimeToken); }, rewriteFetchURL(raw, targetUrl, controlPrefix) { return lowLevelFetchURL(raw, targetUrl, controlPrefix); }, rewriteSrcset(raw, targetUrl, controlPrefix) { return lowLevelSrcset(raw, targetUrl, controlPrefix); }, rewriteTargetURL(raw, targetUrl, controlPrefix) { return lowLevelTargetURL(raw, targetUrl, controlPrefix); }, classifyLinkRel(rel) { return lowLevelLinkRel(rel); }, classifyBlockedElement(tag) { return lowLevelBlockedElement(tag); }, classifyMetaPolicy(httpEquiv) { return lowLevelMetaPolicy(httpEquiv); }, classifyAttrPolicy(tag, key) { return lowLevelAttrPolicy(tag, key); }, classifyScriptType(scriptType) { return lowLevelScriptType(scriptType); }, classifyEventHandlerAttr(attrName) { return lowLevelEventHandlerAttr(attrName); }, rewriteCSS(source, baseUrl, controlPrefix) { return lowLevelCSS(source, baseUrl, controlPrefix); }, rewriteImportMap(source, baseUrl, tabId, runtimeToken, controlPrefix) { return { ok: true, code: lowLevelImportMap(source, baseUrl, tabId, runtimeToken, controlPrefix), error: '' }; }, rewriteHTMLDocument(source, targetUrl, controlPrefix, servers, runtimePrelude, tabId, runtimeToken) { return lowLevelHTMLDocument(source, targetUrl, controlPrefix, servers, runtimePrelude, tabId, runtimeToken); }, makeShareURL(target, servers) { return lowLevelShareURL(target, servers); }, rewriteFunctionBody: rewriteFunctionBodyRaw });`, + `const rewriterApi = Object.freeze({ VERSION, get ready() { return initialized; }, init, initSync, rewriteScript: rewriteScriptPublic, rewriteScriptURL: rewriteScriptURLPublic, rewriteFetchURL: rewriteFetchURLPublic, rewriteSrcset: rewriteSrcsetPublic, rewriteTargetURL: rewriteTargetURLPublic, classifyLinkRel: classifyLinkRelPublic, classifyBlockedElement: classifyBlockedElementPublic, classifyMetaPolicy: classifyMetaPolicyPublic, classifyAttrPolicy: classifyAttrPolicyPublic, classifyScriptType: classifyScriptTypePublic, classifyEventHandlerAttr: classifyEventHandlerAttrPublic, rewriteCSS: rewriteCSSPublic, rewriteImportMap: rewriteImportMapPublic, rewriteHTMLDocument: rewriteHTMLDocumentPublic, makeShareURL: makeShareURLPublic, rewriteFunctionBody: rewriteFunctionBodyPublic, blockSource() { return BLOCK_CODE; } });`, + `function defineHiddenAPI(name, value) { const d = Object.getOwnPropertyDescriptor(globalThis, name); if (d && d.configurable === false) return d.value; Object.defineProperty(globalThis, name, { value, enumerable: false, configurable: false, writable: false }); return value; }`, + `defineHiddenAPI('ZPRustRewriter', rustApi);`, + `defineHiddenAPI('ZPRewriter', rewriterApi);`, + `bootstrapInit();`, + '})();', + '', + ].join('\n'); +} + +async function writeOptimizedWasm(from, to) { + if (!hasCommand('wasm-opt')) { + await copyFile(from, to); + return; + } + run('wasm-opt', ['-Oz', from, '-o', to]); } + async function readGoWasmExec() { const goroot = goEnv('GOROOT'); const candidates = [ @@ -169,7 +317,11 @@ async function readGoWasmExec() { } function goEnv(name) { - const result = spawnSync('go', ['env', name], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const result = spawnSync('go', ['env', name], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); if (result.status !== 0) throw new Error(`go env ${name} failed\n${result.stderr}`); return result.stdout.trim(); } @@ -181,11 +333,17 @@ function run(cmd, argv, extraEnv = {}) { env: { ...process.env, ...extraEnv }, stdio: 'inherit', }); - if (result.status !== 0) throw new Error(`${cmd} ${argv.join(' ')} failed with exit code ${result.status}`); + if (result.status !== 0) + throw new Error(`${cmd} ${argv.join(' ')} failed with exit code ${result.status}`); } -function resolveNodeModule(specifier) { - return path.join(repoRoot, 'node_modules', ...specifier.split('/')); +function hasCommand(cmd) { + const result = spawnSync(cmd, ['--version'], { + cwd: repoRoot, + env: process.env, + stdio: 'ignore', + }); + return result.status === 0; } async function copyOptional(from, to) { diff --git a/scripts/test.mjs b/scripts/test.mjs index 9571f3b..759fbe9 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -1,13 +1,15 @@ import { spawnSync } from 'node:child_process'; const mode = process.argv[2] || 'all'; -const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('npm_'))); +const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('npm_')), +); function run(cmd, allowRetry = false) { const first = runOnce(cmd); if (first.status === 0) return; - if (allowRetry && /\bECONNRESET\b/.test(resultText(first))) { - process.stderr.write('\nRetrying after transient ECONNRESET from browser/relay test transport...\n'); + if (allowRetry && isRetryableTestFailure(first)) { + process.stderr.write('\nRetrying after transient browser/relay test failure...\n'); const second = runOnce(cmd); if (second.status === 0) return; throw commandError(cmd, second); @@ -33,6 +35,11 @@ function resultText(result) { return `${result.stdout || ''}\n${result.stderr || ''}`; } +function isRetryableTestFailure(result) { + const text = resultText(result); + return /\bECONNRESET\b/.test(text) || /test timed out after 120000ms/.test(text); +} + function commandError(cmd, result) { const err = new Error(`Command failed: ${cmd}`); err.status = result.status; @@ -46,8 +53,8 @@ function commandError(cmd, result) { if (mode === 'js') { run('node --test test/js/*.test.js'); } else if (mode === 'e2e') { - run('node --test test/e2e/proxy.test.js', true); + run('node --test test/e2e/*.test.js', true); } else { run('node --test test/js/*.test.js'); - run('node --test test/e2e/proxy.test.js', true); + run('node --test test/e2e/*.test.js', true); } diff --git a/test/e2e/expected-deltas.json b/test/e2e/expected-deltas.json new file mode 100644 index 0000000..c03d6b4 --- /dev/null +++ b/test/e2e/expected-deltas.json @@ -0,0 +1,44 @@ +{ + "nativeVsZeroProxyDifferential": { + "policyHeaders.csp": { + "proxy": "", + "native": "default-src 'none'; script-src 'none'" + }, + "policyHeaders.reportOnly": { + "proxy": "", + "native": "default-src 'none'; connect-src 'none'" + } + }, + "nativeVsZeroProxyRawSetDifferentialAllowlist": [ + { + "id": "membrane-csp-header", + "pattern": "^policyHeaders\\.csp$", + "reason": "ZeroProxy replaces target CSP with the membrane CSP on the proxy origin." + }, + { + "id": "membrane-report-only-header", + "pattern": "^policyHeaders\\.reportOnly$", + "reason": "ZeroProxy strips upstream report-only policy before constructing proxy responses." + }, + { + "id": "canvas-randomization", + "pattern": "^surface\\.fingerprint\\.canvas\\.(length|stableRead)$", + "reason": "Canvas export randomization intentionally changes repeated data URL reads." + }, + { + "id": "navigator-app-version-persona", + "pattern": "^surface\\.fingerprint\\.objectPropertyCollection\\.r\\.5\\.0 \\((Macintosh; Intel Mac OS X 10_15_7|Windows NT 10\\.0; Win64; x64|X11; Linux x86_64)\\) AppleWebKit/537\\.36 \\(KHTML, like Gecko\\) (HeadlessChrome|Chrome)/[0-9]+\\.0\\.0\\.0 Safari/537\\.36$", + "reason": "Native Chromium exposes host platform appVersion while ZeroProxy exposes the Windows Chrome persona." + }, + { + "id": "navigator-user-agent-persona", + "pattern": "^surface\\.fingerprint\\.objectPropertyCollection\\.r\\.Mozilla/5\\.0 \\((Macintosh; Intel Mac OS X 10_15_7|Windows NT 10\\.0; Win64; x64|X11; Linux x86_64)\\) AppleWebKit/537\\.36 \\(KHTML, like Gecko\\) (HeadlessChrome|Chrome)/[0-9]+\\.0\\.0\\.0 Safari/537\\.36$", + "reason": "Native Chromium exposes host platform userAgent while ZeroProxy exposes the Windows Chrome persona." + }, + { + "id": "navigator-platform-persona", + "pattern": "^surface\\.fingerprint\\.objectPropertyCollection\\.r\\.(Linux x86_64|MacIntel|Win32)$", + "reason": "Native Chromium exposes host platform while ZeroProxy exposes the Windows Chrome persona." + } + ] +} diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js new file mode 100644 index 0000000..34a59f3 --- /dev/null +++ b/test/e2e/helpers.js @@ -0,0 +1,115 @@ +// Shared helpers for the Puppeteer e2e suite. +const childProcess = require('node:child_process'); +const http = require('node:http'); +const path = require('node:path'); + +function run(cmd, args, options = {}) { + const result = childProcess.spawnSync(cmd, args, { + cwd: path.resolve(__dirname, '../..'), + env: { ...process.env, ...options.env }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + throw new Error( + `${cmd} ${args.join(' ')} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} + +function isBenignSocketError(err) { + return ( + err && + (err.code === 'ECONNRESET' || err.code === 'EPIPE' || err.code === 'ERR_STREAM_PREMATURE_CLOSE') + ); +} + +function ignoreBenignSocketErrors(stream) { + stream.on('error', (err) => { + if (!isBenignSocketError(err)) throw err; + }); +} + +function listen(server, host = '127.0.0.1') { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, host, () => { + server.off('error', reject); + resolve(server.address().port); + }); + }); +} + +function closeServer(server) { + return new Promise((resolve) => { + let settled = false; + const done = () => { + if (!settled) { + settled = true; + resolve(); + } + }; + server.close(done); + if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); + setTimeout(done, 1000); + }); +} + +async function waitForHTTP(url, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + let last; + while (Date.now() < deadline) { + try { + await new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + res.resume(); + res.on('end', resolve); + }); + req.setTimeout(1000, () => req.destroy(new Error('timeout'))); + req.on('error', reject); + }); + return; + } catch (err) { + last = err; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw last || new Error(`timed out waiting for ${url}`); +} + +async function waitForPage(page, predicate, args = [], timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs; + let last; + while (Date.now() < deadline) { + try { + if (await page.evaluate(predicate, ...args)) return; + } catch (err) { + last = err; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + let state = {}; + try { + state = await page.evaluate(() => ({ + href: location.href, + title: document.title, + readyState: document.readyState, + statusText: document.querySelector('#status')?.textContent || '', + differentialType: typeof window.__differential, + differentialError: (window.__differential && window.__differential.error) || '', + })); + } catch (err) { + state = { error: (err && err.message) || String(err) }; + } + throw last || new Error(`timed out waiting for page condition: ${JSON.stringify(state)}`); +} + +module.exports = { + run, + isBenignSocketError, + ignoreBenignSocketErrors, + listen, + closeServer, + waitForHTTP, + waitForPage, +}; diff --git a/test/e2e/proxy.test.js b/test/e2e/proxy.test.js index 0f81fb9..3d41b12 100644 --- a/test/e2e/proxy.test.js +++ b/test/e2e/proxy.test.js @@ -9,122 +9,79 @@ const os = require('node:os'); const path = require('node:path'); const puppeteer = require('puppeteer'); -const TARGET_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'; +const TARGET_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'; +const TARGET_CH_UA = '"Chromium";v="148", "Not:A-Brand";v="24", "Google Chrome";v="148"'; +const TARGET_CH_UA_FULL_VERSION = '"148.0.7778.217"'; +const TARGET_CH_UA_FULL_VERSION_LIST = + '"Chromium";v="148.0.7778.217", "Not:A-Brand";v="24.0.0.0", "Google Chrome";v="148.0.7778.217"'; +const TARGET_UA_BRANDS = [ + { brand: 'Chromium', version: '148' }, + { brand: 'Not:A-Brand', version: '24' }, + { brand: 'Google Chrome', version: '148' }, +]; +const TARGET_UA_FULL_VERSION_LIST = [ + { brand: 'Chromium', version: '148.0.7778.217' }, + { brand: 'Not:A-Brand', version: '24.0.0.0' }, + { brand: 'Google Chrome', version: '148.0.7778.217' }, +]; +const TARGET_UA_HIGH_ENTROPY = { + architecture: 'x86', + bitness: '64', + brands: TARGET_UA_BRANDS, + fullVersionList: TARGET_UA_FULL_VERSION_LIST, + mobile: false, + model: '', + platform: 'Windows', + platformVersion: '15.0.0', + uaFullVersion: '148.0.7778.217', + fullVersion: '148.0.7778.217', + wow64: false, +}; const JQUERY_SOURCE = fs.readFileSync(require.resolve('jquery'), 'utf8'); +const EXPECTED_DELTAS = JSON.parse( + fs.readFileSync(path.join(__dirname, 'expected-deltas.json'), 'utf8'), +); -function run(cmd, args, options = {}) { - const result = childProcess.spawnSync(cmd, args, { - cwd: path.resolve(__dirname, '../..'), - env: { ...process.env, ...options.env }, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - if (result.status !== 0) { - throw new Error(`${cmd} ${args.join(' ')} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); - } -} - -function isBenignSocketError(err) { - return err && (err.code === 'ECONNRESET' || err.code === 'EPIPE' || err.code === 'ERR_STREAM_PREMATURE_CLOSE'); -} - -function ignoreBenignSocketErrors(stream) { - stream.on('error', err => { - if (!isBenignSocketError(err)) throw err; - }); -} - -function listen(server, host = '127.0.0.1') { - return new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, host, () => { - server.off('error', reject); - resolve(server.address().port); - }); - }); -} - -function closeServer(server) { - return new Promise(resolve => { - let settled = false; - const done = () => { if (!settled) { settled = true; resolve(); } }; - server.close(done); - if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); - setTimeout(done, 1000); - }); -} - -async function waitForHTTP(url, timeoutMs = 15000) { - const deadline = Date.now() + timeoutMs; - let last; - while (Date.now() < deadline) { - try { - await new Promise((resolve, reject) => { - const req = http.get(url, res => { - res.resume(); - res.on('end', resolve); - }); - req.setTimeout(1000, () => req.destroy(new Error('timeout'))); - req.on('error', reject); - }); - return; - } catch (err) { - last = err; - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - throw last || new Error(`timed out waiting for ${url}`); -} - -class SocketReader { - constructor(socket) { - this.socket = socket; - this.buf = Buffer.alloc(0); - this.waiters = []; - socket.on('data', chunk => { - this.buf = Buffer.concat([this.buf, chunk]); - this.flush(); - }); - socket.on('error', err => this.fail(err)); - socket.on('close', () => this.fail(new Error('socket closed'))); - } - read(n) { - if (this.buf.length >= n) return Promise.resolve(this.take(n)); - return new Promise((resolve, reject) => { - this.waiters.push({ n, resolve, reject }); - this.flush(); - }); - } - take(n) { - const out = this.buf.subarray(0, n); - this.buf = this.buf.subarray(n); - return out; - } - flush() { - while (this.waiters.length && this.buf.length >= this.waiters[0].n) { - const waiter = this.waiters.shift(); - waiter.resolve(this.take(waiter.n)); - } - } - fail(err) { - while (this.waiters.length) this.waiters.shift().reject(err); - } -} +const { + run, + isBenignSocketError, + ignoreBenignSocketErrors, + listen, + closeServer, + waitForHTTP, + waitForPage, +} = require('./helpers'); function createTargetServer(requests) { const server = http.createServer((req, res) => { ignoreBenignSocketErrors(req); ignoreBenignSocketErrors(res); - requests.push({ url: req.url, method: req.method, host: req.headers.host || '', userAgent: req.headers['user-agent'] || '', cookie: req.headers.cookie || '', contentType: req.headers['content-type'] || '' }); + requests.push({ + url: req.url, + method: req.method, + host: req.headers.host || '', + userAgent: req.headers['user-agent'] || '', + secChUa: req.headers['sec-ch-ua'] || '', + secChUaFullVersion: req.headers['sec-ch-ua-full-version'] || '', + secChUaFullVersionList: req.headers['sec-ch-ua-full-version-list'] || '', + secChUaPlatform: req.headers['sec-ch-ua-platform'] || '', + secChUaPlatformVersion: req.headers['sec-ch-ua-platform-version'] || '', + cookie: req.headers.cookie || '', + contentType: req.headers['content-type'] || '', + origin: req.headers.origin || '', + referer: req.headers.referer || '', + }); const url = new URL(req.url, 'http://target.local'); if (url.pathname === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(`E2E Home + res.end(`E2E Home

E2E Home

+ `); + return; + } if (url.pathname === '/site-icon.png') { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }); - res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64')); + res.end( + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', + ), + ); return; } if (url.pathname === '/site.css') { - res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/css; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(`.root-stylesheet-probe{border-top:7px solid rgb(12, 34, 56); padding-left:13px}`); return; } if (url.pathname === '/image-probe.png') { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }); - res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64')); + res.end( + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', + ), + ); return; } if (url.pathname === '/gtm.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(`window.__gtmFixture = { loaded: true, href: location.href, @@ -211,12 +827,18 @@ function createTargetServer(requests) { return; } if (url.pathname === '/jquery.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(JQUERY_SOURCE); return; } if (url.pathname === '/jquery-fixture.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(`(($) => { const root = $('
  • one
  • two
').appendTo(document.body); const found = root.find('.item'); @@ -261,17 +883,28 @@ function createTargetServer(requests) { return; } if (url.pathname === '/jquery-ajax.json') { - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ ok: true, path: '/jquery-ajax.json' })); return; } if (url.pathname === '/jquery-plugin.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); - res.end(`window.__jqueryPlugin = { loaded: true, href: location.href, jquery: !!window.jQuery };`); + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end( + `window.__jqueryPlugin = { loaded: true, href: location.href, jquery: !!window.jQuery };`, + ); return; } if (url.pathname === '/dynamic-script.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(`window.__dynamicScriptLoaded = { loaded: true, href: location.href, @@ -281,26 +914,248 @@ function createTargetServer(requests) { return; } if (url.pathname === '/module-worker.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(`const worker = new Worker(new URL('/worker-fixture.js', import.meta.url).href, { name: 'module-worker-fixture' }); worker.onmessage = ev => { window.__moduleWorkerFixture = ev.data; worker.terminate(); }; - worker.onerror = ev => { window.__moduleWorkerFixture = { error: ev && ev.message || 'worker-error' }; };`); + worker.onerror = ev => { window.__moduleWorkerFixture = { error: ev && ev.message || 'worker-error' }; }; + const moduleTypeWorker = new Worker('/module-type-worker-fixture.js', { type: 'module', name: 'module-type-worker-fixture' }); + moduleTypeWorker.onmessage = ev => { window.__moduleTypeWorkerFixture = ev.data; moduleTypeWorker.terminate(); }; + moduleTypeWorker.onerror = ev => { window.__moduleTypeWorkerFixture = { error: ev && ev.message || 'module-type-worker-error' }; };`); + return; + } + if (url.pathname === '/module-type-worker-fixture.js') { + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(`import { moduleTypeWorkerDep } from './module-type-worker-dep.js'; + postMessage({ + loaded: true, + href: location.href, + origin: location.origin, + importMetaURL: import.meta.url, + dep: moduleTypeWorkerDep, + userAgent: navigator.userAgent, + platform: navigator.platform, + fetchSource: Function.prototype.toString.call(fetch) + });`); + return; + } + if (url.pathname === '/module-type-worker-dep.js') { + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(`export const moduleTypeWorkerDep = 'module-worker-dep-ok';`); return; } if (url.pathname === '/worker-fixture.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); - res.end(`postMessage({ loaded: true, href: location.href, userAgent: navigator.userAgent, platform: navigator.platform });`); + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(`(async () => { + const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('worker-upload')); controller.close(); } }); + let upload = null; + try { + const resp = await fetch('/post-echo', { method: 'POST', body: stream, duplex: 'half', headers: { 'Content-Type': 'text/plain' } }); + upload = { status: resp.status, text: await resp.text(), serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller) }; + } catch (err) { + upload = { error: err && (err.name + ':' + err.message) || String(err), serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller) }; + } + postMessage({ loaded: true, href: location.href, userAgent: navigator.userAgent, platform: navigator.platform, upload }); + })();`); + return; + } + if (url.pathname === '/worker-differential.js') { + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(`(async () => { + try { + importScripts('/worker-imported-fixture.js'); + } catch (err) { + self.__workerImportedFixture = { error: err && (err.name + ':' + err.message) || String(err) }; + } + const fnSource = (fn) => { + try { + return Function.prototype.toString.call(fn); + } catch (err) { + return 'error:' + ((err && err.name) || 'Error'); + } + }; + const descriptorShape = (obj, key) => { + const d = Object.getOwnPropertyDescriptor(obj, key); + if (!d) return null; + const out = { + configurable: d.configurable, + enumerable: d.enumerable, + writable: Object.hasOwn(d, 'writable') ? d.writable : null, + hasGet: typeof d.get === 'function', + hasSet: typeof d.set === 'function', + valueType: typeof d.value + }; + if (typeof d.value === 'function') out.valueSource = fnSource(d.value); + if (typeof d.get === 'function') out.getSource = fnSource(d.get); + if (typeof d.set === 'function') out.setSource = fnSource(d.set); + return out; + }; + const hiddenArtifactKeys = () => Reflect.ownKeys(self) + .map(k => typeof k === 'symbol' ? k.toString() : String(k)) + .filter(k => /^ZP$|ZPRewriter|ZPRustRewriter|ZPHTTPRewriter|__zp_|__ZP_|zeroproxy/i.test(k)); + const out = { + href: location.href, + origin, + globals: { + selfIsGlobalThis: self === globalThis, + locationTag: Object.prototype.toString.call(location) + }, + sources: { + fetch: fnSource(fetch), + importScripts: fnSource(importScripts), + Function: fnSource(Function), + eval: fnSource(eval), + setTimeout: fnSource(setTimeout), + postMessage: fnSource(postMessage) + }, + descriptors: { + fetch: descriptorShape(self, 'fetch'), + importScripts: descriptorShape(self, 'importScripts'), + Function: descriptorShape(self, 'Function'), + eval: descriptorShape(self, 'eval'), + location: descriptorShape(self, 'location'), + postMessage: descriptorShape(self, 'postMessage') + }, + ownKeys: { + hiddenArtifacts: hiddenArtifactKeys(), + hasLocation: Reflect.ownKeys(self).includes('location') + }, + imported: self.__workerImportedFixture || null + }; + postMessage(out); + })();`); + return; + } + if (url.pathname === '/worker-imported-fixture.js') { + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(`self.__workerImportedFixture = { + loaded: true, + href: location.href, + origin, + fetchSource: Function.prototype.toString.call(fetch) + };`); + return; + } + if (url.pathname === '/shared-worker-differential.js') { + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(`onconnect = ev => { + const port = ev.ports && ev.ports[0]; + if (!port) return; + port.postMessage({ + href: location.href, + origin, + selfIsGlobalThis: self === globalThis, + fetchSource: Function.prototype.toString.call(fetch), + locationTag: Object.prototype.toString.call(location) + }); + };`); + return; + } + if (url.pathname === '/differential-module.js') { + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(`export const observation = { + href: location.href, + origin: location.origin, + importMetaURL: import.meta.url, + defaultViewHref: document.defaultView.location.href, + fetchSource: Function.prototype.toString.call(fetch) + };`); + return; + } + if (url.pathname === '/sse-differential') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.write('id: diff-id\\ndata: event-source-ok\\n\\n'); + setTimeout(() => { + try { + res.end(); + } catch {} + }, 250); + return; + } + if (url.pathname === '/policy-header-fixture') { + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "default-src 'none'; script-src 'none'", + 'Content-Security-Policy-Report-Only': "default-src 'none'; connect-src 'none'", + }); + res.end('policy-header-ok'); return; } if (url.pathname === '/frame-child') { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(`

frame child

`); + return; + } + if (url.pathname === '/frame-relation') { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(`

frame relation

`); return; } if (url.pathname === '/rewrite-fixture.js') { - res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(`(() => { const NativeWebSocket = window.WebSocket; window.__rewriteAdvanced = { initialHref: window.location.href, constructorSource: NativeWebSocket.toString() }; @@ -341,17 +1196,108 @@ function createTargetServer(requests) { res.end('set-cookie-ok'); return; } + if (url.pathname === '/account/set-cookie-scope') { + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + 'Set-Cookie': [ + 'target_root=visible-root; Path=/; SameSite=Lax', + 'target_scoped=visible-account; Path=/account; SameSite=Lax', + 'target_secret=hidden; Path=/; HttpOnly; SameSite=Lax', + 'target_gone=deleted; Path=/; Max-Age=0; SameSite=Lax', + ], + }); + res.end('set-cookie-scope-ok'); + return; + } if (url.pathname === '/cookie-echo') { - res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(req.headers.cookie || ''); + return; + } + if (url.pathname === '/request-echo') { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + method: req.method, + cookie: req.headers.cookie || '', + origin: req.headers.origin || '', + referer: req.headers.referer || '', + contentType: req.headers['content-type'] || '', + }), + ); + return; + } + if (url.pathname === '/xml') { + res.writeHead(200, { + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end('ok'); + return; + } + if (url.pathname === '/account/cookie-echo') { + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(req.headers.cookie || ''); return; } if (url.pathname === '/stream') { - res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.write('chunk-one\n'); setTimeout(() => res.end('chunk-two\n'), 600); return; } + if (url.pathname === '/slow-headers') { + setTimeout(() => { + if (res.destroyed) return; + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end('late-headers'); + }, 1000); + return; + } + if (url.pathname === '/slow-body') { + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.write('body-one\n'); + setTimeout(() => { + if (!res.destroyed) res.end('body-two\n'); + }, 1000); + return; + } + if (url.pathname === '/slow-upload') { + let bytes = 0; + req.on('data', (chunk) => { + bytes += chunk.length; + req.pause(); + setTimeout(() => req.resume(), 50); + }); + req.on('end', () => { + if (res.destroyed) return; + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(String(bytes)); + }); + return; + } if (url.pathname === '/sse') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' }); res.end('data: sse-ok\n\n'); @@ -359,14 +1305,17 @@ function createTargetServer(requests) { } if (url.pathname === '/form-echo') { const chunks = []; - req.on('data', chunk => chunks.push(chunk)); + req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks).toString('utf8'); const kind = url.searchParams.get('kind') || ''; - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(`Form Echo ${kind}
-
${body.replace(/[&<>]/g, ch => ({'&':'&','<':'<','>':'>'}[ch]))}
+
${body.replace(/[&<>]/g, (ch) => ({ '&': '&', '<': '<', '>': '>' })[ch])}
@@ -376,18 +1325,43 @@ function createTargetServer(requests) { } if (url.pathname === '/post-echo') { const chunks = []; - req.on('data', chunk => chunks.push(chunk)); + req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); res.end(Buffer.concat(chunks).toString('utf8')); }); return; } if (url.pathname === '/redirect307') { - res.writeHead(307, { 'Location': '/post-echo', 'Cache-Control': 'no-store' }); + res.writeHead(307, { Location: '/post-echo', 'Cache-Control': 'no-store' }); res.end(); return; } + if (url.pathname === '/redirect302') { + res.writeHead(302, { Location: '/redirect-final', 'Cache-Control': 'no-store' }); + res.end('redirecting'); + return; + } + if (url.pathname === '/redirect-cookie') { + res.writeHead(302, { + Location: '/cookie-echo?after-redirect=1', + 'Cache-Control': 'no-store', + 'Set-Cookie': 'redirect_hop=stored; Path=/; SameSite=Lax', + }); + res.end('redirecting-cookie'); + return; + } + if (url.pathname === '/redirect-final') { + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end('redirect-final-ok'); + return; + } res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('not found'); }); @@ -402,7 +1376,16 @@ function createTargetServer(requests) { } function handleWebSocketUpgrade(req, socket, requests) { - requests.push({ url: req.url, method: req.method, host: req.headers.host || '', userAgent: req.headers['user-agent'] || '', cookie: req.headers.cookie || '', protocol: req.headers['sec-websocket-protocol'] || '', upgrade: true }); + requests.push({ + url: req.url, + method: req.method, + host: req.headers.host || '', + userAgent: req.headers['user-agent'] || '', + cookie: req.headers.cookie || '', + origin: req.headers.origin || '', + protocol: req.headers['sec-websocket-protocol'] || '', + upgrade: true, + }); if (new URL(req.url, 'http://target.local').pathname !== '/ws') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; @@ -412,12 +1395,24 @@ function handleWebSocketUpgrade(req, socket, requests) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; } - const accept = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); - const requestedProtocol = String(req.headers['sec-websocket-protocol'] || '').split(',').map(s => s.trim()).filter(Boolean)[0] || ''; + const accept = crypto + .createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + const requestedProtocol = + String(req.headers['sec-websocket-protocol'] || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean)[0] || ''; ignoreBenignSocketErrors(socket); - socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + (requestedProtocol ? '\r\nSec-WebSocket-Protocol: ' + requestedProtocol : '') + '\r\n\r\n'); + const protocolHeader = requestedProtocol + ? `\r\nSec-WebSocket-Protocol: ${requestedProtocol}` + : ''; + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}${protocolHeader}\r\n\r\n`, + ); let buffered = Buffer.alloc(0); - socket.on('data', chunk => { + socket.on('data', (chunk) => { buffered = Buffer.concat([buffered, chunk]); while (buffered.length >= 2) { const frame = readWebSocketFrame(buffered); @@ -429,10 +1424,11 @@ function handleWebSocketUpgrade(req, socket, requests) { return; } if (frame.opcode === 0x9) { - writeWebSocketFrame(socket, 0xA, frame.payload); + writeWebSocketFrame(socket, 0xa, frame.payload); continue; } - if (frame.opcode === 0x1) writeWebSocketFrame(socket, 0x1, Buffer.from('echo:' + frame.payload.toString('utf8'))); + if (frame.opcode === 0x1) + writeWebSocketFrame(socket, 0x1, Buffer.from(`echo:${frame.payload.toString('utf8')}`)); if (frame.opcode === 0x2) writeWebSocketFrame(socket, 0x2, frame.payload); } }); @@ -483,66 +1479,27 @@ function writeWebSocketFrame(socket, opcode, data = Buffer.alloc(0)) { socket.write(Buffer.concat([header, payload])); } -function createSocks5Server(resolveHost) { - return net.createServer(socket => { - handleSocks(socket, resolveHost).catch(() => socket.destroy()); - }); -} - -async function handleSocks(socket, resolveHost) { - socket.on('error', err => { if (!isBenignSocketError(err)) socket.destroy(err); }); - const reader = new SocketReader(socket); - const greeting = await reader.read(2); - assert.equal(greeting[0], 0x05); - const methods = await reader.read(greeting[1]); - const method = methods.includes(0x02) ? 0x02 : 0x00; - socket.write(Buffer.from([0x05, method])); - if (method === 0x02) { - const authHead = await reader.read(2); - assert.equal(authHead[0], 0x01); - await reader.read(authHead[1]); - const passLen = await reader.read(1); - await reader.read(passLen[0]); - socket.write(Buffer.from([0x01, 0x00])); - } - const reqHead = await reader.read(4); - assert.equal(reqHead[0], 0x05); - assert.equal(reqHead[1], 0x01); - let host; - if (reqHead[3] === 0x03) { - const len = await reader.read(1); - host = (await reader.read(len[0])).toString('utf8'); - } else { - throw new Error(`unsupported SOCKS address type ${reqHead[3]}`); - } - const portBuf = await reader.read(2); - const port = portBuf.readUInt16BE(0); - const upstream = net.connect(resolveHost(host, port)); - await new Promise((resolve, reject) => { - upstream.once('connect', resolve); - upstream.once('error', reject); - }); - upstream.on('error', err => { - if (!isBenignSocketError(err)) socket.destroy(err); - }); - socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); - if (reader.buf.length) upstream.write(reader.buf); - socket.pipe(upstream); - upstream.pipe(socket); -} - -test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integrations', { timeout: 120000 }, async t => { +test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integrations', { + timeout: 120000, +}, async (t) => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroproxy-e2e-')); const buildOut = path.join(temp, 'dist'); run('node', ['scripts/build.mjs', '--out', buildOut]); const kernelPath = path.join(buildOut, 'kernel.wasm'); - const serverPath = path.join(buildOut, process.platform === 'win32' ? 'zeroproxy-server.exe' : 'zeroproxy-server'); + const serverPath = path.join( + buildOut, + process.platform === 'win32' ? 'zeroproxy-server.exe' : 'zeroproxy-server', + ); const webPath = path.join(buildOut, 'web'); const requests = []; const target = createTargetServer(requests); const targetPort = await listen(target); t.after(() => closeServer(target)); + const crossRequests = []; + const crossTarget = createTargetServer(crossRequests); + const crossPort = await listen(crossTarget); + t.after(() => closeServer(crossTarget)); const targetHost = 'localhost'; const proxyPort = await new Promise((resolve, reject) => { @@ -553,73 +1510,225 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ }); s.once('error', reject); }); - const proxy = childProcess.spawn(serverPath, ['-addr', `127.0.0.1:${proxyPort}`, '-web', webPath, '-kernel', kernelPath, '-socks', 'internal'], { - cwd: path.resolve(__dirname, '../..'), - stdio: ['ignore', 'pipe', 'pipe'], - }); + const proxy = childProcess.spawn( + serverPath, + [ + '-addr', + `127.0.0.1:${proxyPort}`, + '-web', + webPath, + '-kernel', + kernelPath, + '-socks', + 'internal', + ], + { + cwd: path.resolve(__dirname, '../..'), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); t.after(() => proxy.kill('SIGTERM')); let proxyLog = ''; - proxy.stdout.on('data', chunk => { proxyLog += chunk; }); - proxy.stderr.on('data', chunk => { proxyLog += chunk; }); - await waitForHTTP(`http://127.0.0.1:${proxyPort}/`).catch(err => { + proxy.stdout.on('data', (chunk) => { + proxyLog += chunk; + }); + proxy.stderr.on('data', (chunk) => { + proxyLog += chunk; + }); + await waitForHTTP(`http://127.0.0.1:${proxyPort}/`).catch((err) => { throw new Error(`${err.message}\nproxy output:\n${proxyLog}`); }); const browser = await puppeteer.launch({ headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--host-resolver-rules=MAP proxy.localhost 127.0.0.1'], + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--host-resolver-rules=MAP proxy.localhost 127.0.0.1', + ], }); t.after(() => browser.close()); - const page = await browser.newPage(); + let page = await browser.newPage(); + const pageEvents = []; + page.on('pageerror', (err) => { + pageEvents.push(`pageerror:${(err && err.message) || String(err)}`); + }); + page.on('console', (msg) => { + pageEvents.push(`console:${msg.type()}:${msg.text()}`); + }); + page.on('framenavigated', (frame) => { + pageEvents.push( + `framenavigated:${frame === page.mainFrame() ? 'main' : 'child'}:${frame.url()}`, + ); + }); await page.goto(`http://proxy.localhost:${proxyPort}/`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => navigator.serviceWorker && navigator.serviceWorker.controller && document.querySelector('#status')?.textContent === 'Ready.', { timeout: 30000 }); + await waitForPage( + page, + () => + navigator.serviceWorker && + navigator.serviceWorker.controller && + document.querySelector('#status')?.textContent === 'Ready.', + ); await page.type('#url', `http://${targetHost}:${targetPort}/`); await page.click('button'); try { - await page.waitForFunction(() => document.title === 'E2E Home', { timeout: 30000 }); + await waitForPage(page, () => document.title === 'E2E Home'); } catch (err) { - const state = await page.evaluate(() => ({ title: document.title, url: location.href, body: document.body && document.body.innerText, status: document.querySelector('#status')?.textContent || '' })); - throw new Error(`${err.message}; nav state=${JSON.stringify(state)}; requests=${JSON.stringify(requests)}; proxy=${proxyLog}`); + const state = await page.evaluate(() => ({ + title: document.title, + url: location.href, + body: document.body && document.body.innerText, + status: document.querySelector('#status')?.textContent || '', + })); + throw new Error( + `${err.message}; nav state=${JSON.stringify(state)}; requests=${JSON.stringify(requests)}; proxy=${proxyLog}`, + ); } + await waitForPage( + page, + () => + document.getElementById('image-probe')?.complete && + document.getElementById('dynamic-image-probe')?.complete, + ); - const home = await page.evaluate(() => ({ - href: location.href, - hash: location.hash, - title: document.title, - shellVisible: Boolean(document.querySelector('#open')), - userAgent: navigator.userAgent, - appVersion: navigator.appVersion, - platform: navigator.platform, - templateLink: window.__templateLinkFixture, - phase2Location: window.__phase2Location, - phase2DynamicFunction: window.__phase2DynamicFunction, - phase2EvalLocation: window.__phase2EvalLocation, - styleProbe: (() => { - const el = document.getElementById('style-probe'); - const cs = el && getComputedStyle(el); - return cs && { borderTopWidth: cs.borderTopWidth, borderTopColor: cs.borderTopColor, paddingLeft: cs.paddingLeft }; - })(), - imageProbe: (() => { - const el = document.getElementById('image-probe'); - return el && { complete: el.complete, naturalWidth: el.naturalWidth, src: el.getAttribute('src') }; - })(), - faviconProbe: (() => { - const el = document.getElementById('icon-link'); - const hrefAttr = el && el.attributes.getNamedItem('href'); - return el && { - rel: el.getAttribute('rel'), - href: el.getAttribute('href'), - hrefProp: el.href, - hrefAttrValue: hrefAttr && hrefAttr.value, - outerHTML: el.outerHTML, - }; - })(), - })); + const home = await page.evaluate(async () => { + const userAgentData = navigator.userAgentData + ? { + brands: navigator.userAgentData.brands, + mobile: navigator.userAgentData.mobile, + platform: navigator.userAgentData.platform, + highEntropy: await navigator.userAgentData.getHighEntropyValues([ + 'architecture', + 'bitness', + 'brands', + 'fullVersionList', + 'mobile', + 'model', + 'platform', + 'platformVersion', + 'uaFullVersion', + 'fullVersion', + 'wow64', + ]), + json: navigator.userAgentData.toJSON(), + } + : null; + return { + href: location.href, + hash: location.hash, + title: document.title, + shellVisible: Boolean(document.querySelector('#open')), + userAgent: navigator.userAgent, + appVersion: navigator.appVersion, + platform: navigator.platform, + userAgentData, + templateLink: window.__templateLinkFixture, + phase2Location: window.__phase2Location, + phase2DynamicFunction: window.__phase2DynamicFunction, + phase2EvalLocation: window.__phase2EvalLocation, + innerHTMLScriptFixture: window.__innerHTMLScriptFixture, + styleProbe: (() => { + const el = document.getElementById('style-probe'); + const cs = el && getComputedStyle(el); + return ( + cs && { + borderTopWidth: cs.borderTopWidth, + borderTopColor: cs.borderTopColor, + paddingLeft: cs.paddingLeft, + } + ); + })(), + imageProbe: (() => { + const el = document.getElementById('image-probe'); + const attr = el && el.attributes.getNamedItem('src'); + return ( + el && { + complete: el.complete, + naturalWidth: el.naturalWidth, + src: el.getAttribute('src'), + srcProp: el.src, + currentSrc: el.currentSrc, + attrValue: attr && attr.value, + outerHTML: el.outerHTML, + } + ); + })(), + dynamicImageProbe: (() => { + const el = document.getElementById('dynamic-image-probe'); + const attr = el && el.attributes.getNamedItem('src'); + return ( + el && { + complete: el.complete, + naturalWidth: el.naturalWidth, + src: el.getAttribute('src'), + srcProp: el.src, + attrValue: attr && attr.value, + outerHTML: el.outerHTML, + } + ); + })(), + faviconProbe: (() => { + const el = document.getElementById('icon-link'); + const hrefAttr = el && el.attributes.getNamedItem('href'); + return ( + el && { + rel: el.getAttribute('rel'), + href: el.getAttribute('href'), + hrefProp: el.href, + hrefAttrValue: hrefAttr && hrefAttr.value, + outerHTML: el.outerHTML, + } + ); + })(), + metaPolicyProbe: { + live: Array.from(document.querySelectorAll('meta[http-equiv]')).map((el) => ({ + httpEquiv: el.getAttribute('http-equiv'), + content: el.getAttribute('content'), + })), + blocked: Array.from(document.querySelectorAll('meta[data-zp-blocked-http-equiv]')).map( + (el) => ({ + blocked: el.getAttribute('data-zp-blocked-http-equiv'), + httpEquiv: el.getAttribute('http-equiv'), + content: el.getAttribute('content'), + }), + ), + parser: window.__metaPolicyParserProbe, + }, + }; + }); assert.equal(home.title, 'E2E Home'); assert.match(home.hash, /^#k=/); assert.equal(home.shellVisible, false); assert.equal(home.userAgent, TARGET_UA); assert.equal(home.appVersion, TARGET_UA.replace(/^Mozilla\//, '')); + assert.deepEqual(home.userAgentData, { + brands: TARGET_UA_BRANDS, + mobile: false, + platform: 'Windows', + highEntropy: TARGET_UA_HIGH_ENTROPY, + json: { + brands: TARGET_UA_BRANDS, + mobile: false, + platform: 'Windows', + }, + }); + const rootDocumentRequest = requests.find((r) => r.url === '/'); + assert.deepEqual( + rootDocumentRequest && { + secChUa: rootDocumentRequest.secChUa, + secChUaFullVersion: rootDocumentRequest.secChUaFullVersion, + secChUaFullVersionList: rootDocumentRequest.secChUaFullVersionList, + secChUaPlatform: rootDocumentRequest.secChUaPlatform, + secChUaPlatformVersion: rootDocumentRequest.secChUaPlatformVersion, + }, + { + secChUa: TARGET_CH_UA, + secChUaFullVersion: TARGET_CH_UA_FULL_VERSION, + secChUaFullVersionList: TARGET_CH_UA_FULL_VERSION_LIST, + secChUaPlatform: '"Windows"', + secChUaPlatformVersion: '"15.0.0"', + }, + ); assert.deepEqual(home.templateLink, { childCount: 1, firstNode: 'link', @@ -632,49 +1741,105 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ tableRowNode: 'TR', tableRowText: 'cell', }); - assert.deepEqual(home.faviconProbe && { - rel: home.faviconProbe.rel, - href: home.faviconProbe.href, - hrefProp: home.faviconProbe.hrefProp, - hrefAttrValue: home.faviconProbe.hrefAttrValue, - }, { - rel: 'icon', - href: `http://${targetHost}:${targetPort}/site-icon.png`, - hrefProp: `http://${targetHost}:${targetPort}/site-icon.png`, - hrefAttrValue: `http://${targetHost}:${targetPort}/site-icon.png`, - }); + assert.deepEqual( + home.faviconProbe && { + rel: home.faviconProbe.rel, + href: home.faviconProbe.href, + hrefProp: home.faviconProbe.hrefProp, + hrefAttrValue: home.faviconProbe.hrefAttrValue, + }, + { + rel: 'icon', + href: `http://${targetHost}:${targetPort}/site-icon.png`, + hrefProp: `http://${targetHost}:${targetPort}/site-icon.png`, + hrefAttrValue: `http://${targetHost}:${targetPort}/site-icon.png`, + }, + ); assert.doesNotMatch(home.faviconProbe.outerHTML, /x-zeroproxy-icon|data-zp-target-url/); assert.equal(home.platform, 'Win32'); assert.match(home.href, new RegExp(`^http://proxy\\.localhost:${proxyPort}/zp/p/`)); - assert.deepEqual(home.phase2Location, { href: `http://${targetHost}:${targetPort}/`, windowHref: `http://${targetHost}:${targetPort}/` }); + assert.deepEqual(home.phase2Location, { + href: `http://${targetHost}:${targetPort}/`, + windowHref: `http://${targetHost}:${targetPort}/`, + }); assert.equal(home.phase2DynamicFunction, `http://${targetHost}:${targetPort}/`); assert.equal(home.phase2EvalLocation, `http://${targetHost}:${targetPort}/`); - assert.deepEqual(home.styleProbe, { borderTopWidth: '7px', borderTopColor: 'rgb(12, 34, 56)', paddingLeft: '13px' }); - assert.ok(requests.some(r => r.url === '/site.css' && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); - assert.equal(requests.some(r => r.url === '/site-icon.png'), false, `favicon must not be fetched: ${JSON.stringify(requests)}`); + assert.equal(home.innerHTMLScriptFixture, `http://${targetHost}:${targetPort}/`); + assert.deepEqual(home.styleProbe, { + borderTopWidth: '7px', + borderTopColor: 'rgb(12, 34, 56)', + paddingLeft: '13px', + }); + assert.ok( + requests.some((r) => r.url === '/site.css' && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.equal( + requests.some((r) => r.url === '/site-icon.png'), + false, + `favicon must not be fetched: ${JSON.stringify(requests)}`, + ); + assert.deepEqual(home.metaPolicyProbe.live, []); + assert.deepEqual(home.metaPolicyProbe.blocked, []); + assert.deepEqual(home.metaPolicyProbe.parser, { live: [], text: 'ok' }); assert.equal(home.imageProbe.complete, true); assert.equal(home.imageProbe.naturalWidth, 1); assert.equal(home.imageProbe.src, `http://${targetHost}:${targetPort}/image-probe.png`); - assert.ok(requests.some(r => r.url === '/image-probe.png' && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); - assert.ok(requests.some(r => r.url === '/' && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); + assert.equal(home.imageProbe.srcProp, `http://${targetHost}:${targetPort}/image-probe.png`); + assert.equal(home.imageProbe.currentSrc, `http://${targetHost}:${targetPort}/image-probe.png`); + assert.equal(home.imageProbe.attrValue, `http://${targetHost}:${targetPort}/image-probe.png`); + assert.doesNotMatch(home.imageProbe.outerHTML, /\/zp\/api\/fetch|data-zp-target/); + assert.equal(home.dynamicImageProbe.complete, true); + assert.equal(home.dynamicImageProbe.naturalWidth, 1); + assert.equal( + home.dynamicImageProbe.src, + `http://${targetHost}:${targetPort}/image-probe.png?dynamic=1`, + ); + assert.equal( + home.dynamicImageProbe.srcProp, + `http://${targetHost}:${targetPort}/image-probe.png?dynamic=1`, + ); + assert.equal( + home.dynamicImageProbe.attrValue, + `http://${targetHost}:${targetPort}/image-probe.png?dynamic=1`, + ); + assert.doesNotMatch(home.dynamicImageProbe.outerHTML, /\/zp\/api\/fetch|data-zp-target/); + assert.ok( + requests.some((r) => r.url === '/image-probe.png' && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some((r) => r.url === '/image-probe.png?dynamic=1' && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some((r) => r.url === '/' && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); const addressBarShare = page.url(); - const relayServerParam = new RegExp(`server=ws%3A%2F%2Fproxy\\.localhost%3A${proxyPort}%2Fzp%2Fws-pipe`); + const relayServerParam = new RegExp( + `server=ws%3A%2F%2Fproxy\\.localhost%3A${proxyPort}%2Fzp%2Fws-pipe`, + ); assert.match(addressBarShare, /#k=/); assert.match(addressBarShare, relayServerParam); - const staticNextHref = await page.$eval('#next', el => el.getAttribute('href') || ''); - assert.match(staticNextHref, /^\/zp\/p\//); - assert.match(staticNextHref, relayServerParam); - const externalContext = await (browser.createBrowserContext ? browser.createBrowserContext() : browser.createIncognitoBrowserContext()); + const staticNextHref = await page.$eval('#next', (el) => el.getAttribute('href') || ''); + assert.equal(staticNextHref, `http://${targetHost}:${targetPort}/next`); + const externalContext = await (browser.createBrowserContext + ? browser.createBrowserContext() + : browser.createIncognitoBrowserContext()); try { const externalPage = await externalContext.newPage(); await externalPage.goto(addressBarShare, { waitUntil: 'domcontentloaded' }); - await externalPage.waitForFunction(() => document.title === 'E2E Home', { timeout: 30000 }); + await waitForPage(externalPage, () => document.title === 'E2E Home'); assert.match(externalPage.url(), /#k=/); assert.match(externalPage.url(), relayServerParam); } finally { await externalContext.close(); } - await page.waitForFunction(() => window.__rewriteAdvanced && window.__rewriteAdvanced.wsMessage === 'echo:rewrite-script', { timeout: 30000 }); + await waitForPage( + page, + () => window.__rewriteAdvanced && window.__rewriteAdvanced.wsMessage === 'echo:rewrite-script', + ); const rewriteAdvanced = await page.evaluate(() => window.__rewriteAdvanced); assert.equal(rewriteAdvanced.initialHref, `http://${targetHost}:${targetPort}/`); assert.equal(rewriteAdvanced.wsURL, `ws://${targetHost}:${targetPort}/ws`); @@ -682,55 +1847,150 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ assert.equal(rewriteAdvanced.wsMessage, 'echo:rewrite-script'); assert.equal(rewriteAdvanced.wsError, undefined); assert.equal(rewriteAdvanced.jqueryConstructorLength, 0); - assert.equal(rewriteAdvanced.constructorEscapeHref, `http://${targetHost}:${targetPort}/#compound-tail`); + assert.equal( + rewriteAdvanced.constructorEscapeHref, + `http://${targetHost}:${targetPort}/#compound-tail`, + ); assert.equal(rewriteAdvanced.compoundHash, '#compound-tail'); assert.equal(rewriteAdvanced.compoundHref, `http://${targetHost}:${targetPort}/#compound-tail`); - assert.ok(requests.some(r => r.upgrade && r.url === '/ws' && r.protocol === 'zp-rewrite' && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); + assert.ok( + requests.some( + (r) => + r.upgrade && + r.url === '/ws' && + r.protocol === 'zp-rewrite' && + r.userAgent === TARGET_UA && + r.origin === `http://${targetHost}:${targetPort}`, + ), + `target requests: ${JSON.stringify(requests)}`, + ); try { - await page.waitForFunction(() => window.__gtmFixture && window.__gtmFixture.loaded && window.__dynamicScriptLoaded && window.__dynamicScriptLoaded.loaded && window.__moduleWorkerFixture && window.__moduleWorkerFixture.loaded, { timeout: 30000 }); + await waitForPage( + page, + () => + window.__gtmFixture && + window.__gtmFixture.loaded && + window.__dynamicScriptLoaded && + window.__dynamicScriptLoaded.loaded && + window.__moduleWorkerFixture && + window.__moduleWorkerFixture.loaded && + window.__moduleTypeWorkerFixture && + window.__moduleTypeWorkerFixture.loaded, + ); } catch (err) { const state = await page.evaluate(() => ({ gtm: window.__gtmFixture || null, dynamic: window.__dynamicScriptLoaded || null, moduleWorker: window.__moduleWorkerFixture || null, - scripts: Array.from(document.scripts).map(s => ({ id: s.id, src: s.attributes.getNamedItem('src')?.value || '', type: s.type || '', blocked: s.hasAttribute('data-zp-blocked-script') })), + moduleTypeWorker: window.__moduleTypeWorkerFixture || null, + scripts: Array.from(document.scripts).map((s) => ({ + id: s.id, + src: s.attributes.getNamedItem('src')?.value || '', + type: s.type || '', + blocked: s.hasAttribute('data-zp-blocked-script'), + })), messages: window.__messageEvents || [], })); - throw new Error(`${err.message}; dynamic state=${JSON.stringify(state)}; requests=${JSON.stringify(requests)}`); + throw new Error( + `${err.message}; dynamic state=${JSON.stringify(state)}; requests=${JSON.stringify(requests)}`, + ); } const dynamicScripts = await page.evaluate(() => ({ gtm: window.__gtmFixture, dynamic: window.__dynamicScriptLoaded, moduleWorker: window.__moduleWorkerFixture, + moduleTypeWorker: window.__moduleTypeWorkerFixture, gtmAttr: document.getElementById('gtm-fixture')?.attributes.getNamedItem('src')?.value || '', - dynamicAttr: document.getElementById('dynamic-script-probe')?.attributes.getNamedItem('src')?.value || '', + dynamicAttr: + document.getElementById('dynamic-script-probe')?.attributes.getNamedItem('src')?.value || '', messages: window.__messageEvents || [], })); - assert.ok(dynamicScripts.gtm.href.startsWith(`http://${targetHost}:${targetPort}/`), dynamicScripts.gtm.href); - assert.ok(dynamicScripts.dynamic.href.startsWith(`http://${targetHost}:${targetPort}/`), dynamicScripts.dynamic.href); + assert.ok( + dynamicScripts.gtm.href.startsWith(`http://${targetHost}:${targetPort}/`), + dynamicScripts.gtm.href, + ); + assert.ok( + dynamicScripts.dynamic.href.startsWith(`http://${targetHost}:${targetPort}/`), + dynamicScripts.dynamic.href, + ); assert.match(dynamicScripts.gtm.currentAttr, /^\/zp\/api\/script\?/); - assert.equal(dynamicScripts.moduleWorker.href, `http://${targetHost}:${targetPort}/worker-fixture.js`); + assert.equal( + dynamicScripts.moduleWorker.href, + `http://${targetHost}:${targetPort}/worker-fixture.js`, + ); assert.equal(dynamicScripts.moduleWorker.userAgent, TARGET_UA); assert.equal(dynamicScripts.moduleWorker.platform, 'Win32'); + assert.deepEqual(dynamicScripts.moduleWorker.upload, { + status: 200, + text: 'worker-upload', + serviceWorker: false, + }); + assert.equal( + dynamicScripts.moduleTypeWorker.href, + `http://${targetHost}:${targetPort}/module-type-worker-fixture.js`, + ); + assert.equal(dynamicScripts.moduleTypeWorker.origin, `http://${targetHost}:${targetPort}`); + assert.equal( + dynamicScripts.moduleTypeWorker.importMetaURL, + `http://${targetHost}:${targetPort}/module-type-worker-fixture.js`, + ); + assert.equal(dynamicScripts.moduleTypeWorker.dep, 'module-worker-dep-ok'); + assert.equal(dynamicScripts.moduleTypeWorker.userAgent, TARGET_UA); + assert.equal(dynamicScripts.moduleTypeWorker.platform, 'Win32'); + assert.match(dynamicScripts.moduleTypeWorker.fetchSource, /\[native code\]/); assert.match(dynamicScripts.dynamic.currentAttr, /^\/zp\/api\/script\?/); assert.match(dynamicScripts.gtmAttr, /^\/zp\/api\/script\?/); assert.match(dynamicScripts.dynamicAttr, /^\/zp\/api\/script\?/); - assert.ok(dynamicScripts.messages.some(m => m.type === 'gtm-loaded'), `messages: ${JSON.stringify(dynamicScripts.messages)}`); - assert.ok(requests.some(r => r.url.startsWith('/gtm.js') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); - assert.ok(requests.some(r => r.url.startsWith('/dynamic-script.js') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); - assert.ok(requests.some(r => r.url.startsWith('/module-worker.js') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); - assert.ok(requests.some(r => r.url.startsWith('/worker-fixture.js') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); + assert.ok( + dynamicScripts.messages.some((m) => m.type === 'gtm-loaded'), + `messages: ${JSON.stringify(dynamicScripts.messages)}`, + ); + assert.ok( + requests.some((r) => r.url.startsWith('/gtm.js') && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some((r) => r.url.startsWith('/dynamic-script.js') && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some((r) => r.url.startsWith('/module-worker.js') && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some((r) => r.url.startsWith('/worker-fixture.js') && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some( + (r) => r.url.startsWith('/module-type-worker-fixture.js') && r.userAgent === TARGET_UA, + ), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some( + (r) => r.url.startsWith('/module-type-worker-dep.js') && r.userAgent === TARGET_UA, + ), + `target requests: ${JSON.stringify(requests)}`, + ); try { - await page.waitForFunction(() => window.__jqueryFixture && window.__jqueryFixture.ready, { timeout: 30000 }); + await waitForPage(page, () => window.__jqueryFixture && window.__jqueryFixture.ready); } catch (err) { const state = await page.evaluate(() => ({ jquery: window.__jqueryFixture || null, plugin: window.__jqueryPlugin || null, hasJQuery: Boolean(window.jQuery), - scripts: Array.from(document.scripts).map(s => ({ id: s.id, src: s.attributes.getNamedItem('src')?.value || '', type: s.type || '', blocked: s.hasAttribute('data-zp-blocked-script') })), + scripts: Array.from(document.scripts).map((s) => ({ + id: s.id, + src: s.attributes.getNamedItem('src')?.value || '', + type: s.type || '', + blocked: s.hasAttribute('data-zp-blocked-script'), + })), })); - throw new Error(`${err.message}; jquery state=${JSON.stringify(state)}; requests=${JSON.stringify(requests)}`); + throw new Error( + `${err.message}; jquery state=${JSON.stringify(state)}; requests=${JSON.stringify(requests)}`, + ); } const jquery = await page.evaluate(() => window.__jqueryFixture); assert.match(jquery.version, /^3\./); @@ -746,18 +2006,44 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ assert.deepEqual(jquery.ajaxData, { ok: true, path: '/jquery-ajax.json' }); assert.equal(jquery.plugin && jquery.plugin.loaded, true); assert.equal(jquery.plugin && jquery.plugin.jquery, true); - assert.ok(jquery.plugin.href.startsWith(`http://${targetHost}:${targetPort}/`), jquery.plugin.href); - assert.ok(jquery.globalEvalHref.startsWith(`http://${targetHost}:${targetPort}/`), jquery.globalEvalHref); - assert.ok(jquery.locationHref.startsWith(`http://${targetHost}:${targetPort}/`), jquery.locationHref); - assert.ok(requests.some(r => r.url.startsWith('/jquery.js') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); - assert.ok(requests.some(r => r.url.startsWith('/jquery-fixture.js') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); - assert.ok(requests.some(r => r.url.startsWith('/jquery-ajax.json') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); - assert.ok(requests.some(r => r.url.startsWith('/jquery-plugin.js') && r.userAgent === TARGET_UA), `target requests: ${JSON.stringify(requests)}`); + assert.ok( + jquery.plugin.href.startsWith(`http://${targetHost}:${targetPort}/`), + jquery.plugin.href, + ); + assert.ok( + jquery.globalEvalHref.startsWith(`http://${targetHost}:${targetPort}/`), + jquery.globalEvalHref, + ); + assert.ok( + jquery.locationHref.startsWith(`http://${targetHost}:${targetPort}/`), + jquery.locationHref, + ); + assert.ok( + requests.some((r) => r.url.startsWith('/jquery.js') && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some((r) => r.url.startsWith('/jquery-fixture.js') && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some((r) => r.url.startsWith('/jquery-ajax.json') && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); + assert.ok( + requests.some((r) => r.url.startsWith('/jquery-plugin.js') && r.userAgent === TARGET_UA), + `target requests: ${JSON.stringify(requests)}`, + ); - const iframeIsolation = await page.evaluate(async target => { - const blockedByPolicy = fn => { - try { fn(); return ''; } - catch (err) { return err && err.message || String(err); } + const iframeTarget = `http://${targetHost}:${targetPort}/next?frame=dynamic`; + const iframeIsolation = await page.evaluate(async (target) => { + const blockedByPolicy = (fn) => { + try { + fn(); + return ''; + } catch (err) { + return (err && err.message) || String(err); + } }; const sync = document.createElement('iframe'); @@ -773,40 +2059,174 @@ test('browser traffic uses internal SOCKS5 mode and covers proxied runtime integ const websocketURL = ws.url; const childCanvasMask = modern.contentWindow.HTMLCanvasElement.prototype.toDataURL.toString(); const childFunctionShared = modern.contentWindow.Function === window.Function; + const childFunctionSelfInstance = + modern.contentWindow.Function instanceof modern.contentWindow.Function; + const childEvalInstance = modern.contentWindow.eval instanceof modern.contentWindow.Function; + const childFunctionSource = modern.contentWindow.Function.prototype.toString.call( + modern.contentWindow.Function, + ); const childFunctionHref = modern.contentWindow.Function('return location.href')(); - try { ws.close(); } catch {} + try { + ws.close(); + } catch {} + + const docwrite = document.createElement('iframe'); + document.body.appendChild(docwrite); + const childDoc = docwrite.contentDocument; + childDoc.open(); + childDoc.write(` + - diff --git a/web/runtime-prelude-entry.mjs b/web/runtime-prelude-entry.mjs new file mode 100644 index 0000000..fec4696 --- /dev/null +++ b/web/runtime-prelude-entry.mjs @@ -0,0 +1,4 @@ +import './zp-core.js'; +import 'virtual:zeroproxy-rust-rewriter'; +import './http-rewriter.js'; +import './runtime-prelude.mjs'; diff --git a/web/runtime-prelude.js b/web/runtime-prelude.js deleted file mode 100644 index 5b274eb..0000000 --- a/web/runtime-prelude.js +++ /dev/null @@ -1,2364 +0,0 @@ -(() => { - 'use strict'; - const root = window; - const marker = Symbol.for('zeroproxy.runtime.installed'); - if (root[marker]) return; - Object.defineProperty(root, marker, { value: true, enumerable: false, configurable: false }); - - const boot = Object.assign({ tabId: '', entryId: '', targetUrl: location.href, documentCookie: '' }, readBootConfig()); - const runtimeToken = String(boot.runtimeToken || ''); - const TARGET_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'; - const TARGET_APP_VERSION = TARGET_USER_AGENT.replace(/^Mozilla\//, ''); - const TARGET_PLATFORM = 'Win32'; - clearBootConfig(); - const Native = captureNative(root); - const toStringMap = new WeakMap(); - const toStringMaskedPrototypes = new WeakSet(); - const origToString = root.Function && root.Function.prototype && root.Function.prototype.toString; - const initialProxyURL = new URL(root.location.href); - const proxyOrigin = initialProxyURL.origin; - const activeServers = ZP.relayServersForShare(Array.isArray(boot.servers) ? boot.servers : [], { allowLoopbackWS: true }); - let activeProxyPath = initialProxyURL.pathname; - let activeProxyFragment = preservedShareFragment(initialProxyURL.hash); - let activeRouteKey = ZP.isSharePath(activeProxyPath) ? ZP.shareRouteKey(activeProxyPath) : ''; - let virtualURL = new URL(boot.targetUrl); - let activeEntryId = boot.entryId; - let baseURL = virtualURL.href; - let explicitBaseURL = ''; - let activeShareVersion = 0; - let documentCookie = String(boot.documentCookie || ''); - const documentCookieRecords = []; - initDocumentCookieRecords(documentCookie); - const urlMeta = new WeakMap(); - const messageListenerWrappers = new WeakMap(); - const frameWindowOrigins = new WeakMap(); - const crossWindowProxyCache = new WeakMap(); - const postMessageWrappers = new WeakMap(); - const postMessageOriginals = new WeakMap(); - const frameTargetOriginMarker = Symbol.for('zeroproxy.frame.targetOrigin'); - const networkContainmentMarker = Symbol.for('zeroproxy.network.contained'); - const iframeHooksMarker = Symbol.for('zeroproxy.iframe.hooks'); - const stealthMarker = Symbol.for('zeroproxy.stealth.membrane'); - const listenersKey = Symbol('zp.listeners'); - const windowMethodBindings = new Map(); - const integrityBackupAttr = 'data-zp-integrity'; - const hiddenIconHref = 'data:application/x-zeroproxy-icon,1'; - const WINDOW_BOUND_METHODS = new Set(['addEventListener','removeEventListener','dispatchEvent','setTimeout','setInterval','clearTimeout','clearInterval','requestAnimationFrame','cancelAnimationFrame','requestIdleCallback','cancelIdleCallback','matchMedia','getComputedStyle','postMessage','atob','btoa','focus','blur','close','print','alert','confirm','prompt','scroll','scrollTo','scrollBy']); - const workerBlobURLs = new Set(); - const canvasHookedWindows = new WeakSet(); - const audioHookedWindows = new WeakSet(); - const serviceWorkerFacades = new WeakMap(); - const storageMaps = new Map(); - const storageWindows = new Set(); - - - function readBootConfig() { - const d = root.document; - const el = d && d.getElementById && d.getElementById('__zp-boot'); - if (el) { - try { - const parsed = JSON.parse(el.textContent || '{}'); - if (parsed && typeof parsed === 'object') return parsed; - } catch {} - } - return root.__ZP_BOOT || {}; - } - - function clearBootConfig() { - const d = root.document; - const el = d && d.getElementById && d.getElementById('__zp-boot'); - if (el) { - try { el.remove(); } catch {} - } - try { delete root.__ZP_BOOT; } catch { try { Object.defineProperty(root, '__ZP_BOOT', { value: undefined, enumerable: false }); } catch {} } - } - function captureNative(w) { - const d = w.document; - return { - fetch: w.fetch && w.fetch.bind(w), - XMLHttpRequest: w.XMLHttpRequest, - WebSocket: w.WebSocket, - EventSource: w.EventSource, - Worker: w.Worker, - FunctionCtor: w.Function, - SharedWorker: w.SharedWorker, - FormData: w.FormData, - URL: w.URL, - Blob: w.Blob, - DOMException: w.DOMException, - Request: w.Request, - Response: w.Response, - serviceWorkerController: w.navigator && w.navigator.serviceWorker && w.navigator.serviceWorker.controller, - Headers: w.Headers, - navigatorSendBeacon: w.navigator && w.navigator.sendBeacon && w.navigator.sendBeacon.bind(w.navigator), - serviceWorker: w.navigator && w.navigator.serviceWorker, - createElement: d.createElement.bind(d), - createElementNS: d.createElementNS && d.createElementNS.bind(d), - appendChild: w.Node.prototype.appendChild, - insertBefore: w.Node.prototype.insertBefore, - replaceChild: w.Node.prototype.replaceChild, - setAttribute: w.Element.prototype.setAttribute, - getAttribute: w.Element.prototype.getAttribute, - removeAttribute: w.Element.prototype.removeAttribute, - hasAttribute: w.Element.prototype.hasAttribute, - getAttributeNames: w.Element.prototype.getAttributeNames, - insertAdjacentHTML: w.Element.prototype.insertAdjacentHTML, - elementInnerHTML: Object.getOwnPropertyDescriptor(w.Element.prototype, 'innerHTML'), - elementOuterHTML: Object.getOwnPropertyDescriptor(w.Element.prototype, 'outerHTML'), - elementAttributes: Object.getOwnPropertyDescriptor(w.Element.prototype, 'attributes'), - setAttributeNS: w.Element.prototype.setAttributeNS, - namedSetNamedItem: w.NamedNodeMap && w.NamedNodeMap.prototype.setNamedItem, - attrValue: w.Attr && Object.getOwnPropertyDescriptor(w.Attr.prototype, 'value'), - matches: w.Element.prototype.matches, - closest: w.Element.prototype.closest, - querySelector: w.Document.prototype.querySelector, - querySelectorAll: w.Document.prototype.querySelectorAll, - elementQuerySelector: w.Element.prototype.querySelector, - elementQuerySelectorAll: w.Element.prototype.querySelectorAll, - documentGetElementsByTagName: w.Document.prototype.getElementsByTagName, - elementGetElementsByTagName: w.Element.prototype.getElementsByTagName, - documentScripts: Object.getOwnPropertyDescriptor(w.Document.prototype, 'scripts'), - createNodeIterator: w.Document.prototype.createNodeIterator, - createTreeWalker: w.Document.prototype.createTreeWalker, - createHTMLDocument: d.implementation && d.implementation.createHTMLDocument && d.implementation.createHTMLDocument.bind(d.implementation), - scriptText: w.HTMLScriptElement && Object.getOwnPropertyDescriptor(w.HTMLScriptElement.prototype, 'text'), - nodeTextContent: Object.getOwnPropertyDescriptor(w.Node.prototype, 'textContent'), - htmlInnerText: w.HTMLElement && Object.getOwnPropertyDescriptor(w.HTMLElement.prototype, 'innerText'), - formSubmit: w.HTMLFormElement && w.HTMLFormElement.prototype.submit, - formRequestSubmit: w.HTMLFormElement && w.HTMLFormElement.prototype.requestSubmit, - documentOpen: d.open && d.open.bind(d), - documentWrite: d.write && d.write.bind(d), - documentWriteln: d.writeln && d.writeln.bind(d), - documentClose: d.close && d.close.bind(d), - historyPush: w.history.pushState.bind(w.history), - historyReplace: w.history.replaceState.bind(w.history), - locationAssign: w.location && w.location.assign && w.location.assign.bind(w.location), - locationReplace: w.location && w.location.replace && w.location.replace.bind(w.location), - locationHref: Object.getOwnPropertyDescriptor(w.Location && w.Location.prototype, 'href') || Object.getOwnPropertyDescriptor(w.location, 'href'), - locationReload: w.location && w.location.reload && w.location.reload.bind(w.location), - createObjectURL: w.URL && w.URL.createObjectURL && w.URL.createObjectURL.bind(w.URL), - revokeObjectURL: w.URL && w.URL.revokeObjectURL && w.URL.revokeObjectURL.bind(w.URL), - open: w.open && w.open.bind(w), - setTimeout: w.setTimeout && w.setTimeout.bind(w), - setInterval: w.setInterval && w.setInterval.bind(w), - clearTimeout: w.clearTimeout && w.clearTimeout.bind(w), - clearInterval: w.clearInterval && w.clearInterval.bind(w), - DOMParserParseFromString: w.DOMParser && w.DOMParser.prototype && w.DOMParser.prototype.parseFromString, - rangeCreateContextualFragment: w.Range && w.Range.prototype && w.Range.prototype.createContextualFragment, - windowAddEventListener: w.addEventListener && w.addEventListener.bind(w), - windowRemoveEventListener: w.removeEventListener && w.removeEventListener.bind(w), - }; - } - - function normalizedError(name = 'NotSupportedError') { - try { return new Native.DOMException('Blocked by ZeroProxy policy', name); } catch { const e = new Error('Blocked by ZeroProxy policy'); e.name = name; return e; } - } - function nativeFunctionSource(key) { - const name = typeof key === 'symbol' ? '' : String(key); - return 'function ' + name + '() { [native code] }'; - } - function nativeAccessorSource(kind, key) { - const name = typeof key === 'symbol' ? '' : String(key); - return 'function ' + kind + ' ' + name + '() { [native code] }'; - } - function maskNativeFunction(fn, key) { - if (typeof fn === 'function') toStringMap.set(fn, nativeFunctionSource(key)); - } - function maskMethods(obj, keys) { - for (const key of keys) maskNativeFunction(obj && obj[key], key); - } - function define(obj, key, value) { - try { - Object.defineProperty(obj, key, { value, enumerable: false, configurable: false, writable: true }); - maskNativeFunction(value, key); - return true; - } catch { return false; } - } - function defineAccessor(obj, key, get, set) { - try { - Object.defineProperty(obj, key, { get, set, enumerable: false, configurable: false }); - if (typeof get === 'function') toStringMap.set(get, nativeAccessorSource('get', key)); - if (typeof set === 'function') toStringMap.set(set, nativeAccessorSource('set', key)); - return true; - } catch { return false; } - } - function installToStringMasking(w) { - const proto = w && w.Function && w.Function.prototype; - if (!proto || toStringMaskedPrototypes.has(proto)) return; - const orig = w === root ? origToString : proto.toString; - if (typeof orig !== 'function') return; - const maskedToString = function toString() { - if (typeof this === 'function' && toStringMap.has(this)) return toStringMap.get(this); - return orig.call(this); - }; - toStringMap.set(maskedToString, 'function toString() { [native code] }'); - try { - Object.defineProperty(proto, 'toString', { value: maskedToString, enumerable: false, configurable: true, writable: true }); - toStringMaskedPrototypes.add(proto); - } catch {} - } - function installEventMethods(proto) { - define(proto, 'addEventListener', function(type, fn) { if (!fn) return; const key = String(type); if (!this[listenersKey]) this[listenersKey] = new Map(); const list = this[listenersKey].get(key) || []; list.push(fn); this[listenersKey].set(key, list); }); - define(proto, 'removeEventListener', function(type, fn) { const list = this[listenersKey] && this[listenersKey].get(String(type)); if (!list) return; const i = list.indexOf(fn); if (i >= 0) list.splice(i, 1); }); - define(proto, 'dispatchEvent', function(event) { const list = this[listenersKey] && this[listenersKey].get(event.type) || []; try { if (!event.target) Object.defineProperty(event, 'target', { value: this, configurable: true }); } catch {} const handler = this['on' + event.type]; if (typeof handler === 'function') handler.call(this, event); for (const fn of list.slice()) fn.call(this, event); return !event.defaultPrevented; }); - } - function preservedShareFragment(hash) { - if (!hash) return ''; - try { - const raw = hash[0] === '#' ? hash.slice(1) : hash; - const params = new URLSearchParams(raw); - const key = params.get('k'); - return key ? ZP.makeShareFragment(key, activeServers) : ''; - } catch { return ''; } - } - function shareFragmentForKey(key) { return ZP.makeShareFragment(String(key), activeServers); } - function proxyHistoryURL() { return activeProxyPath + activeProxyFragment; } - function nativeLocationURL() { - try { - const href = Native.locationHref && Native.locationHref.get && Native.locationHref.get.call(root.location); - if (href) return new URL(href); - } catch {} - try { return new URL(proxyHistoryURL(), proxyOrigin); } catch { return new URL(initialProxyURL.href); } - } - function visibleProxyURL() { const u = nativeLocationURL(); return u.pathname + u.search + u.hash; } - function setActiveShareRoute(share) { - activeProxyPath = ZP.makeSharePath(share.encrypted); - activeRouteKey = share.encrypted; - activeProxyFragment = shareFragmentForKey(share.key); - } - function replaceVisibleProxyURL() { - const next = proxyHistoryURL(); - if (visibleProxyURL() !== next) { - try { Native.historyReplace(root.history.state, '', next); } catch {} - } - } - function refreshVisibleShareRoute(entryId, target, base) { - const version = ++activeShareVersion; - ZP.encryptShareURL(target).then(share => { - return postMessageToSW({ type: 'ZP_HISTORY_UPDATE', tabId: boot.tabId, routeKey: share.encrypted, entryId, targetUrl: target, baseUrl: base, replace: true }).then(() => share); - }).then(share => { - if (version !== activeShareVersion || entryId !== activeEntryId || target !== virtualURL.href) return; - setActiveShareRoute(share); - replaceVisibleProxyURL(); - }).catch(()=>{}); - } - function isHTTPURL(raw) { try { const u = new URL(String(raw), baseURL); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; } } - function hasExecutableURLScheme(raw) { return /^(?:javascript|data|vbscript):/i.test(String(raw).trim()); } - function hasDangerousURLScheme(raw) { return /^(?:javascript|vbscript):/i.test(String(raw).trim()); } - function shouldBlockURLAttribute(el, key, raw) { - const tag = el && el.localName; - const localKey = attrLocalName(key); - const strict = localKey === 'src' && tag === 'script' || localKey === 'src' && (tag === 'iframe' || tag === 'frame') || usesRawURLAttribute(el, key); - return strict ? hasExecutableURLScheme(raw) : hasDangerousURLScheme(raw); - } - function blockedURLValue(el, key) { const tag = el && el.localName; return key === 'src' && (tag === 'iframe' || tag === 'frame') ? 'about:blank' : key === 'src' && tag === 'script' ? ZP.errorPath('POLICY_BLOCKED') : '#'; } - function blockExecutableURL(el, key, raw) { urlMeta.delete(el); Native.setAttribute.call(el, 'data-zp-target-url', ''); Native.setAttribute.call(el, 'data-zp-blocked-url', String(raw).trim()); Native.setAttribute.call(el, key, blockedURLValue(el, key)); if (key === 'src' && (el.localName === 'iframe' || el.localName === 'frame')) instrumentIframe(el); } - function isIntegrityBearing(el) { const tag = el && el.localName; return tag === 'script' || tag === 'link'; } - function backedIntegrity(el) { return isIntegrityBearing(el) ? Native.getAttribute.call(el, integrityBackupAttr) : null; } - function setBackedIntegrity(el, value) { Native.setAttribute.call(el, integrityBackupAttr, String(value)); if (Native.removeAttribute) Native.removeAttribute.call(el, 'integrity'); } - function targetURL(raw, base = baseURL) { return ZP.canonicalTargetURL(String(raw), base).href; } - function targetWSURL(raw, base = baseURL) { return ZP.canonicalWebSocketURL(String(raw), base.replace(/^http/, 'ws')).href; } - function shareNavURL(raw, base = baseURL) { return ZP.makeShareURL(targetURL(raw, base), proxyOrigin, activeServers); } - function sameOriginHistoryURL(url) { const next = new URL(targetURL(url)); if (next.origin !== virtualURL.origin) throw normalizedError('SecurityError'); return next; } - function commitVirtualHistory(state, title, url, replace = false) { - const next = url != null ? sameOriginHistoryURL(url) : new URL(virtualURL.href); - virtualURL = next; - if (!explicitBaseURL) baseURL = virtualURL.href; - const entryId = replace && activeEntryId ? activeEntryId : 'e' + ZP.randomId(); - activeEntryId = entryId; - postMessageToSW({ type: 'ZP_HISTORY_UPDATE', tabId: boot.tabId, routeKey: activeRouteKey, entryId, targetUrl: virtualURL.href, baseUrl: baseURL, replace }).catch(()=>{}); - const out = (replace ? Native.historyReplace : Native.historyPush)(state, title, proxyHistoryURL()); - refreshVisibleShareRoute(entryId, virtualURL.href, baseURL); - return out; - } - function updateVirtualHash(raw, replace = false) { - const oldURL = virtualURL.href; - const next = new URL(virtualURL.href); - let hash = String(raw); - if (hash && hash[0] !== '#') hash = '#' + hash; - next.hash = hash; - if (next.href === virtualURL.href) return; - const out = commitVirtualHistory(null, '', next.href, replace); - try { window.dispatchEvent(new HashChangeEvent('hashchange', { oldURL, newURL: virtualURL.href })); } catch { try { window.dispatchEvent(new Event('hashchange')); } catch {} } - return out; - } - function setVirtualLocation(raw, replace = false) { - const next = new URL(targetURL(raw)); - if (next.origin === virtualURL.origin && next.pathname === virtualURL.pathname && next.search === virtualURL.search) { - updateVirtualHash(next.hash, replace); - return; - } - navigateToTarget(next.href, replace); - } - async function activatedNavPath(raw, replace = false, base = baseURL) { - const target = targetURL(raw, base); - const share = await ZP.encryptShareURL(target); - const path = ZP.makeSharePath(share.encrypted); - const entryId = replace ? activeEntryId : 'e' + ZP.randomId(); - await postMessageToSW({ type: 'ZP_HISTORY_UPDATE', tabId: boot.tabId, routeKey: share.encrypted, entryId, targetUrl: target, baseUrl: target, replace }); - activeProxyPath = path; - activeRouteKey = share.encrypted; - activeProxyFragment = shareFragmentForKey(share.key); - return path + activeProxyFragment; - } - async function activatedFrameURL(raw, base = baseURL) { - const target = targetURL(raw, base); - const share = await ZP.encryptShareURL(target); - const entryId = 'e' + ZP.randomId(); - await postMessageToSW({ type: 'ZP_FRAME_ROUTE', tabId: boot.tabId, routeKey: share.encrypted, entryId, targetUrl: target, baseUrl: target }); - return proxyOrigin + ZP.makeSharePath(share.encrypted) + shareFragmentForKey(share.key); - } - function navigateToTarget(raw, replace = false, base = baseURL) { - activatedNavPath(raw, replace, base).then(path => { - if (replace && Native.locationReplace) Native.locationReplace(path); - else if (!replace && Native.locationAssign) Native.locationAssign(path); - else if (replace) location.replace(path); - else location.href = path; - }).catch(() => shareNavURL(raw, base).then(u => { - if (replace && Native.locationReplace) Native.locationReplace(u); - else if (!replace && Native.locationAssign) Native.locationAssign(u); - else if (replace) location.replace(u); - else location.href = u; - }).catch(()=>{})); - } - function postMessageToSW(message, transfer) { - const controller = Native.serviceWorkerController || Native.serviceWorker && Native.serviceWorker.controller; - if (!controller || !runtimeToken) return Promise.reject(normalizedError('NetworkError')); - return new Promise((resolve, reject) => { - const channel = new MessageChannel(); - const sealed = Object.assign({}, message, { runtimeToken }); - channel.port1.onmessage = ev => { - const data = ev.data || {}; - if (data.ok) resolve(data); - else { const err = new Error(data.error || 'NetworkError'); err.code = data.error || 'NetworkError'; reject(err); } - }; - controller.postMessage(sealed, transfer ? [channel.port2, ...transfer] : [channel.port2]); - }); - } - function updateVirtualBase(raw) { - try { - const next = targetURL(raw, baseURL); - baseURL = next; - explicitBaseURL = next; - postMessageToSW({ type: 'ZP_BASE_UPDATE', tabId: boot.tabId, entryId: activeEntryId, baseUrl: next }).catch(()=>{}); - return next; - } catch { - return baseURL; - } - } - function normalizePostMessageTargetOrigin(targetOrigin) { - if (targetOrigin == null) return targetOrigin; - const s = String(targetOrigin); - if (s === '*' || s === '/') return s; - try { - const u = new URL(s); - if (u.protocol === 'http:' || u.protocol === 'https:') return proxyOrigin; - } catch {} - return s; - } - function postMessageWrapperFor(target) { - if (!target || typeof target.postMessage !== 'function') return undefined; - if (postMessageWrappers.has(target)) return postMessageWrappers.get(target); - const original = target.postMessage.bind(target); - postMessageOriginals.set(target, original); - const wrapped = function postMessage(message, targetOrigin, transfer) { - if (arguments.length < 2) return original(message, proxyOrigin); - const mapped = normalizePostMessageTargetOrigin(targetOrigin); - return arguments.length > 2 ? original(message, mapped, transfer) : original(message, mapped); - }; - maskNativeFunction(wrapped, 'postMessage'); - postMessageWrappers.set(target, wrapped); - return wrapped; - } - function virtualOriginForMessage(ev) { - if (!ev || ev.origin !== proxyOrigin || !ev.source) return ''; - try { - const origin = frameWindowOrigins.get(ev.source) || ev.source[frameTargetOriginMarker]; - return origin || ''; - } catch { - return ''; - } - } - function virtualizeMessageEvent(ev) { - const origin = virtualOriginForMessage(ev); - if (!origin) return ev; - try { - return new MessageEvent(ev.type, { data: ev.data, origin, lastEventId: ev.lastEventId || '', source: ev.source, ports: ev.ports || [] }); - } catch { - try { - const clone = Object.create(ev); - Object.defineProperty(clone, 'origin', { value: origin, configurable: true }); - return clone; - } catch { - return ev; - } - } - } - function rememberFrameOrigin(frame) { - if (!frame) return; - let target = ''; - try { target = urlMeta.get(frame) || Native.getAttribute.call(frame, 'data-zp-target-url') || ''; } catch {} - if (!target) return; - try { - const child = frame.contentWindow; - if (child) frameWindowOrigins.set(child, new URL(target).origin); - } catch {} - } - try { Object.defineProperty(root, frameTargetOriginMarker, { get() { return virtualURL.origin; }, enumerable: false, configurable: false }); } catch {} - installToStringMasking(root); - define(root, '__ZP_SET_BASE', updateVirtualBase); - installPhase2Membrane(); - - installWebSocket(); - installWebSocketStream(); - installHTTPAPIs(); - installBeacon(); - installNavigationTraps(); - installPopupHooks(root); - installPostMessageHooks(root); - installNavigatorIdentity(root); - installGetterMasking(root); - installStorageFacades(root); - installDOMHooks(root); - installStealthMembrane(root); - installWorkerHooks(); - installTargetServiceWorkerBlocker(root); - installIframeHooks(root); - installBlockers(root); - installCanvasAntiFingerprinting(root); - installAudioAntiFingerprinting(root); - - - function installPhase2Membrane() { - function boundWindowMethod(target, prop) { - const fn = target[prop]; - if (typeof fn !== 'function') return fn; - if (windowMethodBindings.has(prop)) return windowMethodBindings.get(prop); - const bound = fn.bind(target); - maskNativeFunction(bound, prop); - windowMethodBindings.set(prop, bound); - return bound; - } - - const NativeAsyncFunction = (async function(){}).constructor; - const NativeGeneratorFunction = (function*(){}).constructor; - const NativeAsyncGeneratorFunction = (async function*(){}).constructor; - function stringArgs(args) { - const out = new Array(args.length); - for (let i = 0; i < args.length; i++) out[i] = String(args[i]); - return out; - } - function scopedBody(body) { return 'with(__zp_scope){\n' + body + '\n}'; } - function compileScoped(ctor, params, body) { - const argv = new Array(params.length + 2); - argv[0] = '__zp_scope'; - for (let i = 0; i < params.length; i++) argv[i + 1] = params[i]; - argv[argv.length - 1] = scopedBody(rewriteDynamicFunctionBody(params, body)); - return Reflect.construct(ctor, argv); - } - function scopedCallArgs(args) { - const argv = new Array(args.length + 1); - argv[0] = scope; - for (let i = 0; i < args.length; i++) argv[i + 1] = args[i]; - return argv; - } - function dynamicSource(kind, params, body) { - const prefix = kind === 'async' ? 'async function' : kind === 'generator' ? 'function*' : kind === 'asyncGenerator' ? 'async function*' : 'function'; - return prefix + ' anonymous(' + params.join(',') + '\n) {\n' + body + '\n}'; - } - function compileDynamic(ctor, args, kind) { - const parts = stringArgs(args); - const body = parts.length ? parts[parts.length - 1] : ''; - const params = new Array(parts.length > 0 ? parts.length - 1 : 0); - for (let i = 0; i < params.length; i++) params[i] = parts[i]; - const compiled = compileScoped(ctor, params, body); - let fn; - if (kind === 'async') { - fn = async function anonymous(...callArgs) { return Reflect.apply(compiled, this, scopedCallArgs(callArgs)); }; - } else if (kind === 'generator') { - fn = function* anonymous(...callArgs) { return yield* Reflect.apply(compiled, this, scopedCallArgs(callArgs)); }; - } else if (kind === 'asyncGenerator') { - fn = async function* anonymous(...callArgs) { return yield* Reflect.apply(compiled, this, scopedCallArgs(callArgs)); }; - } else { - fn = function anonymous(...callArgs) { - const argv = scopedCallArgs(callArgs); - return new.target ? Reflect.construct(compiled, argv, new.target) : Reflect.apply(compiled, this, argv); - }; - } - toStringMap.set(fn, dynamicSource(kind, params, body)); - return fn; - } - function isEvalExpressionCandidate(text) { - return !/^(?:function|class|var|let|const|if|for|while|do|switch|try|throw|return|break|continue|with|import|export|debugger)\b/.test(text.trimStart()); - } - function dynamicEval(source) { - if (arguments.length === 0) return undefined; - const text = String(source); - let expr = null; - if (isEvalExpressionCandidate(text)) { - try { expr = Reflect.construct(Native.FunctionCtor, ['__zp_scope', 'with(__zp_scope){return (' + text + '\n);}']); } catch {} - } - if (expr) return Reflect.apply(expr, root, [scope]); - return Reflect.apply(compileScoped(Native.FunctionCtor, [], text), root, [scope]); - } - const dynamicFunction = function Function(...args) { return compileDynamic(Native.FunctionCtor, args, 'function'); }; - const dynamicAsyncFunction = function AsyncFunction(...args) { return compileDynamic(NativeAsyncFunction, args, 'async'); }; - const dynamicGeneratorFunction = function GeneratorFunction(...args) { return compileDynamic(NativeGeneratorFunction, args, 'generator'); }; - const dynamicAsyncGeneratorFunction = function AsyncGeneratorFunction(...args) { return compileDynamic(NativeAsyncGeneratorFunction, args, 'asyncGenerator'); }; - function setDynamicConstructorIdentity(fn, name, proto) { - try { Object.defineProperty(fn, 'name', { value: name, configurable: true }); } catch {} - try { Object.defineProperty(fn, 'length', { value: 1, configurable: true }); } catch {} - if (proto) try { Object.defineProperty(fn, 'prototype', { value: proto, enumerable: false, configurable: false, writable: false }); } catch {} - maskNativeFunction(fn, name); - } - setDynamicConstructorIdentity(dynamicFunction, 'Function', Native.FunctionCtor && Native.FunctionCtor.prototype); - setDynamicConstructorIdentity(dynamicAsyncFunction, 'AsyncFunction', NativeAsyncFunction && NativeAsyncFunction.prototype); - setDynamicConstructorIdentity(dynamicGeneratorFunction, 'GeneratorFunction', NativeGeneratorFunction && NativeGeneratorFunction.prototype); - setDynamicConstructorIdentity(dynamicAsyncGeneratorFunction, 'AsyncGeneratorFunction', NativeAsyncGeneratorFunction && NativeAsyncGeneratorFunction.prototype); - try { Object.defineProperty(dynamicEval, 'name', { value: 'eval', configurable: true }); } catch {} - try { Object.defineProperty(dynamicEval, 'length', { value: 1, configurable: true }); } catch {} - maskNativeFunction(dynamicEval, 'eval'); - const dynamicConstructorWrappers = new Map([ - [Native.FunctionCtor, dynamicFunction], - [dynamicFunction, dynamicFunction], - [NativeAsyncFunction, dynamicAsyncFunction], - [dynamicAsyncFunction, dynamicAsyncFunction], - [NativeGeneratorFunction, dynamicGeneratorFunction], - [dynamicGeneratorFunction, dynamicGeneratorFunction], - [NativeAsyncGeneratorFunction, dynamicAsyncGeneratorFunction], - [dynamicAsyncGeneratorFunction, dynamicAsyncGeneratorFunction] - ]); - function dynamicWrapperFor(value) { return dynamicConstructorWrappers.get(value) || null; } - function dynamicGlobal(name) { - if (name === 'eval') return dynamicEval; - if (name === 'Function') return dynamicFunction; - if (name === 'AsyncFunction') return dynamicAsyncFunction; - if (name === 'GeneratorFunction') return dynamicGeneratorFunction; - if (name === 'AsyncGeneratorFunction') return dynamicAsyncGeneratorFunction; - return null; - } - const virtualLocation = Object.freeze({ - get href() { return virtualURL.href; }, - set href(v) { setVirtualLocation(v); }, - get protocol() { return virtualURL.protocol; }, - get host() { return virtualURL.host; }, - get hostname() { return virtualURL.hostname; }, - get port() { return virtualURL.port; }, - get pathname() { return virtualURL.pathname; }, - get search() { return virtualURL.search; }, - get hash() { return virtualURL.hash; }, - set hash(v) { updateVirtualHash(v); }, - get origin() { return virtualURL.origin; }, - assign(v) { setVirtualLocation(v); }, - replace(v) { setVirtualLocation(v, true); }, - reload() { Native.locationReload && Native.locationReload(); }, - toString() { return virtualURL.href; }, - valueOf() { return virtualURL.href; }, - [Symbol.toPrimitive]() { return virtualURL.href; } - }); - maskMethods(virtualLocation, ['assign','replace','reload','toString','valueOf']); - function safeCrossWindow(targetWindow) { - if (!targetWindow || targetWindow === root) return scope; - if (crossWindowProxyCache.has(targetWindow)) return crossWindowProxyCache.get(targetWindow); - const proxy = {}; - Object.defineProperties(proxy, { - window: { get() { return proxy; }, enumerable: true }, - self: { get() { return proxy; }, enumerable: true }, - globalThis: { get() { return proxy; }, enumerable: true }, - top: { get() { return proxy; }, enumerable: true }, - parent: { get() { return proxy; }, enumerable: true }, - frames: { get() { return proxy; }, enumerable: true }, - location: { get() { return virtualLocation; }, enumerable: true }, - postMessage: { value: postMessageWrapperFor(targetWindow), enumerable: true } - }); - crossWindowProxyCache.set(targetWindow, proxy); - return proxy; - } - function virtualWindowProperty(target, prop) { - if (prop === 'top' || prop === 'parent' || prop === 'opener') { - try { - const child = target[prop]; - if (child && child !== target) return safeCrossWindow(child); - } catch {} - } - return scope; - } - maskNativeFunction(virtualLocation[Symbol.toPrimitive], Symbol.toPrimitive); - const scope = new Proxy(root, { - has(_target, prop) { return prop !== Symbol.unscopables; }, - get(target, prop) { - if (prop === Symbol.unscopables) return undefined; - if (prop === 'window' || prop === 'self' || prop === 'globalThis' || prop === 'frames') return scope; - if (prop === 'top' || prop === 'parent' || prop === 'opener') return virtualWindowProperty(target, prop); - if (prop === 'location') return virtualLocation; - if (prop === 'postMessage') return postMessageWrapperFor(target); - const dynamic = typeof prop === 'symbol' ? null : dynamicGlobal(String(prop)); - if (dynamic) return dynamic; - if (WINDOW_BOUND_METHODS.has(prop)) return boundWindowMethod(target, prop); - return target[prop]; - }, - set(target, prop, value) { - if (prop === 'location') { setVirtualLocation(value); return true; } - target[prop] = value; - return true; - }, - getOwnPropertyDescriptor(target, prop) { - if (prop === 'location') return { value: virtualLocation, configurable: true, enumerable: true, writable: false }; - return Reflect.getOwnPropertyDescriptor(target, prop); - } - }); - function isWindowLike(value) { - try { return value === root || value === scope || value && value.window === value; } catch { return false; } - } - function get(base, prop) { - if (typeof prop !== 'symbol') prop = String(prop); - if (base === document && (prop === 'URL' || prop === 'documentURI')) return virtualURL.href; - if (base === document && prop === 'baseURI') return baseURL; - if (base === document && prop === 'referrer') return ''; - if (isWindowLike(base)) { - if (prop === 'window' || prop === 'self' || prop === 'globalThis' || prop === 'frames') return base === scope || base === root ? scope : base; - if (prop === 'top' || prop === 'parent' || prop === 'opener') return base === scope || base === root ? virtualWindowProperty(root, prop) : base; - if (prop === 'location') return virtualLocation; - if (prop === 'postMessage') return postMessageWrapperFor(base === scope ? root : base); - const dynamic = dynamicGlobal(prop); - if (dynamic) return dynamic; - } - if (base === document && prop === 'defaultView') return scope; - if (prop === 'postMessage') { - const fn = Reflect.get(Object(base), prop); - if (typeof fn === 'function') { - const bound = fn.bind(base); - maskNativeFunction(bound, prop); - return bound; - } - return fn; - } - if (prop === 'constructor') { - const ctor = Reflect.get(Object(base), prop); - return dynamicWrapperFor(ctor) || ctor; - } - return Reflect.get(Object(base), prop); - } - function set(base, prop, value) { - if (typeof prop !== 'symbol') prop = String(prop); - if ((isWindowLike(base) && prop === 'location') || (base === virtualLocation && prop === 'href')) { setVirtualLocation(value); return value; } - if (base === virtualLocation && prop === 'hash') { updateVirtualHash(value); return value; } - Reflect.set(Object(base), prop, value); - return value; - } - function assign(base, prop, operator, value) { - if (typeof prop !== 'symbol') prop = String(prop); - const current = get(base, prop); - let next; - switch (operator) { - case '+=': next = current + value; break; - case '-=': next = current - value; break; - case '*=': next = current * value; break; - case '/=': next = current / value; break; - case '%=': next = current % value; break; - case '**=': next = current ** value; break; - case '<<=': next = current << value; break; - case '>>=': next = current >> value; break; - case '>>>=': next = current >>> value; break; - case '&=': next = current & value; break; - case '^=': next = current ^ value; break; - case '|=': next = current | value; break; - case '&&=': if (!current) return current; next = value(); break; - case '||=': if (current) return current; next = value(); break; - case '??=': if (current !== null && current !== undefined) return current; next = value(); break; - default: throw normalizedError('NotSupportedError'); - } - return set(base, prop, next); - } - function update(base, prop, operator, prefix) { - if (typeof prop !== 'symbol') prop = String(prop); - const current = get(base, prop); - const next = operator === '++' ? current + 1 : current - 1; - set(base, prop, next); - return prefix ? next : current; - } - function call(base, prop, args) { - const fn = get(base, prop); - return Reflect.apply(fn, base === scope ? root : base, Array.isArray(args) ? args : []); - } - function construct(ctor, args) { - const dynamic = dynamicWrapperFor(ctor); - return Reflect.construct(dynamic || ctor, Array.isArray(args) ? args : []); - } - function has(base, prop) { if (isWindowLike(base) && prop === 'location') return true; return Reflect.has(Object(base), prop); } - function getOwnPropertyDescriptor(base, prop) { if (isWindowLike(base) && prop === 'location') return { value: virtualLocation, configurable: true, enumerable: true, writable: false }; return Reflect.getOwnPropertyDescriptor(Object(base), prop); } - function ownKeys(base) { return Reflect.ownKeys(Object(base)); } - function moduleURL(specifier, referrer) { - const spec = String(specifier); - if (!spec.startsWith('/') && !spec.startsWith('./') && !spec.startsWith('../') && !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(spec)) throw normalizedError('TypeError'); - const u = new URL(spec, referrer || baseURL); - if (u.protocol !== 'http:' && u.protocol !== 'https:') throw normalizedError('NotSupportedError'); - return scriptProxyPath(u.href, 'module'); - } - define(root, '__zp_get', get); - define(root, '__zp_set', set); - define(root, '__zp_assign', assign); - define(root, '__zp_call', call); - define(root, '__zp_update', update); - define(root, '__zp_construct', construct); - define(root, '__zp_has', has); - define(root, '__zp_getOwnPropertyDescriptor', getOwnPropertyDescriptor); - define(root, '__zp_ownKeys', ownKeys); - define(root, '__zp_module_url', moduleURL); - define(root, '__zp_nav_assign', v => setVirtualLocation(v)); - define(root, '__zp_nav_replace', v => setVirtualLocation(v, true)); - define(root, '__zp_runClassic', fn => fn.call(root, scope)); - define(root, '__zp_runEvent', (selfValue, event, fn) => fn.call(selfValue, new Proxy(scope, { get(t, p, r) { if (p === 'event') return event; return Reflect.get(t, p, r); } }))); - function rewriteDynamicFunctionBody(params, body) { - if (root.ZPRewriter && typeof root.ZPRewriter.rewriteFunctionBody === 'function') { - const out = root.ZPRewriter.rewriteFunctionBody(String(body || ''), params, virtualURL.href, ZP.CONTROL_PREFIX); - if (out && out.ok && typeof out.code === 'string') return out.code; - throw normalizedError('NotSupportedError'); - } - return rewriteWithPageRewriter(body, 'function'); - } - function rewriteWithPageRewriter(source, kind) { - if (!root.ZPRewriter || !root.ZPRewriter.ready || typeof root.ZPRewriter.rewriteScript !== 'function') throw normalizedError('NotSupportedError'); - const out = root.ZPRewriter.rewriteScript(String(source || ''), { kind, targetUrl: virtualURL.href, strict: true, controlPrefix: ZP.CONTROL_PREFIX }); - if (!out || !out.ok || typeof out.code !== 'string') throw normalizedError('NotSupportedError'); - return out.code; - } - define(root, '__ZP_EXEC_INLINE_SCRIPT', source => Native.FunctionCtor(rewriteWithPageRewriter(source, 'classic')).call(root)); - define(root, '__ZP_EXEC_INLINE_MODULE', source => { - const code = rewriteWithPageRewriter(source, 'module'); - const blob = new Blob([code], { type: 'text/javascript' }); - const url = Native.createObjectURL ? Native.createObjectURL(blob) : URL.createObjectURL(blob); - const promise = import(url); - promise.finally(() => { try { (Native.revokeObjectURL || URL.revokeObjectURL).call(URL, url); } catch {} }); - return promise; - }); - define(root, '__ZP_EXEC_EVENT', (selfValue, event, source) => Native.FunctionCtor('event', rewriteWithPageRewriter(source, 'event-handler')).call(selfValue, event)); - define(root, 'eval', dynamicEval); - define(root, 'Function', dynamicFunction); - for (const [ctor, wrapper] of dynamicConstructorWrappers) { - if (ctor && ctor.prototype) try { Object.defineProperty(ctor.prototype, 'constructor', { value: wrapper, enumerable: false, configurable: false, writable: false }); } catch {} - } - if (Native.setTimeout) define(root, 'setTimeout', function(handler, delay, ...args) { return Native.setTimeout(typeof handler === 'string' ? compileDynamic(Native.FunctionCtor, [handler], 'function') : handler, delay, ...args); }); - if (Native.setInterval) define(root, 'setInterval', function(handler, delay, ...args) { return Native.setInterval(typeof handler === 'string' ? compileDynamic(Native.FunctionCtor, [handler], 'function') : handler, delay, ...args); }); - if (Native.documentWrite) define(document, 'write', function(...parts) { return Native.documentWrite(parts.map(p => transformHTML(String(p))).join('')); }); - if (Native.documentWriteln) define(document, 'writeln', function(...parts) { return Native.documentWriteln(parts.map(p => transformHTML(String(p))).join('') + '\n'); }); - if (Native.DOMParserParseFromString && root.DOMParser) define(root.DOMParser.prototype, 'parseFromString', function(markup, type) { return Native.DOMParserParseFromString.call(this, String(type).toLowerCase() === 'text/html' ? transformHTML(String(markup)) : markup, type); }); - if (Native.rangeCreateContextualFragment && root.Range) define(root.Range.prototype, 'createContextualFragment', function(markup) { return Native.rangeCreateContextualFragment.call(this, transformHTML(String(markup))); }); - } - function requestTargetURL(input) { - const raw = input && typeof input === 'object' && typeof input.url === 'string' ? input.url : String(input); - const parsed = new URL(raw, baseURL); - if (parsed.origin === proxyOrigin) return new URL(parsed.pathname + parsed.search + parsed.hash, baseURL).href; - return ZP.canonicalTargetURL(parsed.href, baseURL).href; - } - async function requestBodyBase64(req) { - if (req.method === 'GET' || req.method === 'HEAD') return null; - const ab = await req.clone().arrayBuffer(); - return ZP.bytesToBase64Url(new Uint8Array(ab)); - } - async function fetchThroughRuntime(input, init = {}) { - if (!Native.fetch || !Native.Request || !Native.Headers) throw normalizedError('NetworkError'); - const target = requestTargetURL(input); - const req = input && typeof input === 'object' && typeof input.url === 'string' && typeof input.clone === 'function' ? new Native.Request(input, init) : new Native.Request(String(input), init); - const payload = { - tabId: boot.tabId, - url: target, - init: { - method: req.method, - headers: Array.from(req.headers.entries()), - body: await requestBodyBase64(req), - credentials: req.credentials, - mode: req.mode, - referrer: req.referrer, - redirect: req.redirect, - cache: req.cache, - integrity: req.integrity - } - }; - const apiInit = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }; - if (req.signal) apiInit.signal = req.signal; - return Native.fetch(ZP.apiPath('fetch'), apiInit); - } - function fireEvent(target, type) { - let ev; - try { ev = new Event(type); } catch { ev = { type }; } - return target.dispatchEvent(ev); - } - function installHTTPAPIs() { - if (Native.fetch && Native.Request && Native.Headers) define(root, 'fetch', function fetch(input, init) { return fetchThroughRuntime(input, init); }); - if (Native.XMLHttpRequest && Native.fetch && Native.Request && Native.Headers) { - const UNSENT = 0, OPENED = 1, HEADERS_RECEIVED = 2, LOADING = 3, DONE = 4; - function ZPXMLHttpRequest() { - this.readyState = UNSENT; - this.response = this.responseText = ''; - this.responseType = ''; - this.responseURL = ''; - this.status = 0; - this.statusText = ''; - this.timeout = 0; - this.withCredentials = false; - this.upload = {}; - this._headers = []; - this._responseHeaders = null; - this._method = 'GET'; - this._url = ''; - this._sent = false; - this._controller = null; - this._timer = 0; - } - function xhrReady(xhr, state) { - xhr.readyState = state; - fireEvent(xhr, 'readystatechange'); - } - function xhrDone(xhr, type) { - clearTimeout(xhr._timer); - xhr._timer = 0; - xhrReady(xhr, DONE); - fireEvent(xhr, type); - fireEvent(xhr, 'loadend'); - } - installEventMethods(ZPXMLHttpRequest.prototype); - Object.assign(ZPXMLHttpRequest.prototype, { - constructor: ZPXMLHttpRequest, - UNSENT, OPENED, HEADERS_RECEIVED, LOADING, DONE, - open(method, url, async = true, user, password) { - if (async === false) throw normalizedError('NotSupportedError'); - this.abort(); - this._method = String(method || 'GET').toUpperCase(); - const target = new URL(requestTargetURL(url)); - if (user != null) target.username = String(user); - if (password != null) target.password = String(password); - this._url = target.href; - this.responseURL = this._url; - this._headers = []; - this._responseHeaders = null; - this.status = 0; - this.statusText = ''; - this.response = this.responseText = ''; - xhrReady(this, OPENED); - }, - setRequestHeader(name, value) { - if (this.readyState !== OPENED || this._sent) throw normalizedError('InvalidStateError'); - this._headers.push([String(name), String(value)]); - }, - send(body = null) { - if (this.readyState !== OPENED || this._sent) throw normalizedError('InvalidStateError'); - this._sent = true; - this._controller = new AbortController(); - const init = { method: this._method, headers: this._headers, credentials: this.withCredentials ? 'include' : 'same-origin', signal: this._controller.signal }; - if (body != null && this._method !== 'GET' && this._method !== 'HEAD') init.body = body; - if (this.timeout > 0) this._timer = setTimeout(() => { try { this._controller.abort(); } catch {} this._sent = false; xhrDone(this, 'timeout'); }, this.timeout); - fetchThroughRuntime(this._url, init).then(async resp => { - if (!this._sent) return; - this.status = resp.status; - this.statusText = resp.statusText; - this._responseHeaders = resp.headers; - xhrReady(this, HEADERS_RECEIVED); - xhrReady(this, LOADING); - if (this.responseType === 'arraybuffer') this.response = await resp.arrayBuffer(); - else if (this.responseType === 'blob') this.response = await resp.blob(); - else if (this.responseType === 'json') { const text = await resp.text(); try { this.response = text ? JSON.parse(text) : null; } catch { this.response = null; } } - else { this.responseText = await resp.text(); this.response = this.responseText; } - this._sent = false; - xhrDone(this, 'load'); - }).catch(() => { - if (!this._sent) return; - this._sent = false; - this.status = 0; - this.statusText = ''; - xhrDone(this, 'error'); - }); - }, - abort() { - if (this._controller) { try { this._controller.abort(); } catch {} } - clearTimeout(this._timer); - this._timer = 0; - const active = this._sent; - this._sent = false; - this._controller = null; - if (active) xhrDone(this, 'abort'); - }, - getResponseHeader(name) { return this._responseHeaders ? this._responseHeaders.get(String(name)) : null; }, - getAllResponseHeaders() { if (!this._responseHeaders) return ''; let out = ''; this._responseHeaders.forEach((v, k) => { out += k + ': ' + v + '\r\n'; }); return out; }, - overrideMimeType() {} - }); - maskMethods(ZPXMLHttpRequest.prototype, ['open','setRequestHeader','send','abort','getResponseHeader','getAllResponseHeaders','overrideMimeType']); - define(root, 'XMLHttpRequest', ZPXMLHttpRequest); - } - if (Native.EventSource && Native.fetch && Native.Request && Native.Headers) { - const CONNECTING = 0, OPEN = 1, CLOSED = 2; - function ZPEventSource(url, init = {}) { - this.url = requestTargetURL(url); - this.withCredentials = !!(init && init.withCredentials); - this.readyState = CONNECTING; - this._closed = false; - this._controller = new AbortController(); - runEventSource(this, url, init || {}); - } - installEventMethods(ZPEventSource.prototype); - Object.assign(ZPEventSource.prototype, { - constructor: ZPEventSource, - CONNECTING, OPEN, CLOSED, - close() { - this._closed = true; - this.readyState = CLOSED; - try { this._controller.abort(); } catch {} - } - }); - maskMethods(ZPEventSource.prototype, ['close']); - define(root, 'EventSource', ZPEventSource); - function runEventSource(es, url, init) { - fetchThroughRuntime(url, { method: 'GET', headers: [['Accept', 'text/event-stream']], credentials: init.withCredentials ? 'include' : 'same-origin', cache: 'no-store', signal: es._controller.signal }).then(async resp => { - if (!resp.ok) throw normalizedError('NetworkError'); - if (es._closed) return; - es.readyState = OPEN; - fireEvent(es, 'open'); - if (!resp.body || !resp.body.getReader) { - consumeSSE(es, await resp.text(), true); - return; - } - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; - for (;;) { - const part = await reader.read(); - if (part.done) break; - buf = consumeSSE(es, buf + decoder.decode(part.value, { stream: true }), false); - } - consumeSSE(es, buf + decoder.decode(), true); - }).catch(() => { - if (es._closed) return; - es.readyState = CLOSED; - fireEvent(es, 'error'); - }); - } - function consumeSSE(es, text, final) { - let buf = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - let idx; - while ((idx = buf.indexOf('\n\n')) >= 0) { - dispatchSSE(es, buf.slice(0, idx)); - buf = buf.slice(idx + 2); - } - if (final && buf) { - dispatchSSE(es, buf); - return ''; - } - return buf; - } - function dispatchSSE(es, block) { - if (es._closed) return; - let data = '', eventType = 'message', lastEventId = ''; - for (const line of String(block).split('\n')) { - if (!line || line[0] === ':') continue; - const colon = line.indexOf(':'); - const field = colon < 0 ? line : line.slice(0, colon); - let value = colon < 0 ? '' : line.slice(colon + 1); - if (value[0] === ' ') value = value.slice(1); - if (field === 'data') data += value + '\n'; - else if (field === 'event') eventType = value || 'message'; - else if (field === 'id') lastEventId = value; - } - if (!data) return; - data = data.slice(0, -1); - let ev; - try { ev = new MessageEvent(eventType, { data, origin: new URL(es.url).origin, lastEventId }); } - catch { ev = new Event(eventType); try { Object.defineProperties(ev, { data: { value: data }, origin: { value: new URL(es.url).origin }, lastEventId: { value: lastEventId } }); } catch {} } - es.dispatchEvent(ev); - } - } - } - function installWebSocket() { - const CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3; - const tokenRE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; - function protocolList(protocols) { - if (protocols == null) return []; - const list = typeof protocols === 'string' ? [protocols] : Array.isArray(protocols) ? protocols.slice() : null; - if (!list) throw normalizedError('SyntaxError'); - const out = []; - const seen = new Set(); - for (const p of list) { - const s = String(p); - if (!s || !tokenRE.test(s) || seen.has(s)) throw normalizedError('SyntaxError'); - seen.add(s); - out.push(s); - } - return out; - } - function closeEvent(code, reason, wasClean) { - try { return new CloseEvent('close', { code, reason, wasClean }); } - catch { const ev = new Event('close'); try { Object.defineProperties(ev, { code: { value: code }, reason: { value: reason }, wasClean: { value: wasClean } }); } catch {} return ev; } - } - function finish(ws, code, reason, wasClean) { - if (ws._closed) return; - ws._closed = true; - ws.readyState = CLOSED; - ws.dispatchEvent(closeEvent(code || 1000, reason || '', wasClean !== false)); - } - function fail(ws) { - if (ws._closed) return; - ws.dispatchEvent(new Event('error')); - finish(ws, 1006, '', false); - } - function ZPWebSocket(url, protocols) { - if (arguments.length < 1) throw new TypeError("Failed to construct 'WebSocket': 1 argument required, but only 0 present."); - this.url = targetWSURL(url); - this.protocol = ''; - this.extensions = ''; - this.readyState = CONNECTING; - this.bufferedAmount = 0; - this.binaryType = 'blob'; - this._port = null; - this._closed = false; - const plist = protocolList(protocols); - postMessageToSW({ type: 'ZP_WS_OPEN', url: this.url, protocols: plist, tabId: boot.tabId }).then(reply => { - if (this._closed) { try { reply.port && reply.port.postMessage({ type: 'close' }); } catch {} return; } - this.protocol = String(reply.protocol || ''); - this._port = reply.port; - this._port.onmessage = ev => { - const m = ev.data || {}; - if (m.type === 'message') { - let data = m.data; - if (this.binaryType === 'blob' && data instanceof ArrayBuffer && Native.Blob) data = new Native.Blob([data]); - this.dispatchEvent(new MessageEvent('message', { data, origin: new URL(this.url).origin })); - } else if (m.type === 'error') { - fail(this); - } else if (m.type === 'close') { - finish(this, m.code || 1000, m.reason || '', true); - } - }; - this._port.start && this._port.start(); - this.readyState = OPEN; - this.dispatchEvent(new Event('open')); - }).catch(() => fail(this)); - } - ZPWebSocket.CONNECTING = CONNECTING; ZPWebSocket.OPEN = OPEN; ZPWebSocket.CLOSING = CLOSING; ZPWebSocket.CLOSED = CLOSED; - ZPWebSocket.prototype = { CONNECTING, OPEN, CLOSING, CLOSED }; - installEventMethods(ZPWebSocket.prototype); - Object.assign(ZPWebSocket.prototype, { - constructor: ZPWebSocket, - send(data) { - if (this.readyState !== OPEN || !this._port) throw normalizedError('InvalidStateError'); - if (Native.Blob && data instanceof Native.Blob) { - data.arrayBuffer().then(buf => { if (this.readyState === OPEN && this._port) this._port.postMessage({ type: 'send', data: buf }); }).catch(() => fail(this)); - return; - } - this._port.postMessage({ type: 'send', data }); - }, - close(code = 1000, reason = '') { - if (this._closed || this.readyState === CLOSING || this.readyState === CLOSED) return; - this.readyState = CLOSING; - if (this._port) this._port.postMessage({ type: 'close', code, reason }); - finish(this, code, reason, true); - } - }); - define(root, 'WebSocket', ZPWebSocket); - } - - function installWebSocketStream() { - if (!root.WebSocket || !root.ReadableStream || !root.WritableStream) return; - function ZPWebSocketStream(url, options = {}) { - if (!(this instanceof ZPWebSocketStream)) throw new TypeError("Failed to construct 'WebSocketStream': Please use the 'new' operator."); - let closeResolve; - this.closed = new Promise(resolve => { closeResolve = resolve; }); - this.opened = new Promise((resolve, reject) => { - let ws; - let settled = false; - let controllerReadable = null; - const failOpen = err => { if (!settled) { settled = true; reject(err); } }; - try { - ws = new root.WebSocket(url, options && options.protocols); - ws.binaryType = 'arraybuffer'; - const readable = new root.ReadableStream({ - start(controller) { controllerReadable = controller; }, - cancel() { try { ws.close(); } catch {} } - }); - const writable = new root.WritableStream({ - write(chunk) { ws.send(chunk); }, - close() { ws.close(); }, - abort() { ws.close(); } - }); - ws.onopen = () => { settled = true; resolve({ readable, writable, protocol: ws.protocol, extensions: ws.extensions || '' }); }; - ws.onmessage = event => { if (controllerReadable) controllerReadable.enqueue(event.data); }; - ws.onerror = err => { if (!settled) failOpen(err); else if (controllerReadable) { try { controllerReadable.error(err); } catch {} } }; - ws.onclose = event => { - if (!settled) failOpen(normalizedError('NetworkError')); - try { controllerReadable && controllerReadable.close(); } catch {} - closeResolve({ closeCode: event.code, reason: event.reason }); - }; - } catch (err) { - failOpen(err); - } - }); - } - try { Object.defineProperty(ZPWebSocketStream, 'name', { value: 'WebSocketStream', configurable: true }); } catch {} - ZPWebSocketStream.prototype.constructor = ZPWebSocketStream; - maskNativeFunction(ZPWebSocketStream, 'WebSocketStream'); - define(root, 'WebSocketStream', ZPWebSocketStream); - } - - function installBeacon() { if (!navigator.sendBeacon || !Native.fetch || !Native.Request || !Native.Headers) return; define(navigator, 'sendBeacon', function sendBeacon(url, data) { try { fetchThroughRuntime(url, { method: 'POST', body: data, keepalive: true, credentials: 'include' }).catch(()=>{}); return true; } catch { return false; } }); } - - function installNavigationTraps() { - document.addEventListener('click', ev => { const nav = clickNavigationTarget(ev); if (!nav) return; ev.preventDefault(); ev.stopImmediatePropagation(); if (nav.hash != null) updateVirtualHash(nav.hash); else if (nav.href) setVirtualLocation(nav.href); }, true); - document.addEventListener('submit', ev => { const f = ev.target; if (!f) return; ev.preventDefault(); submitForm(f, ev.submitter); }, true); - if (Native.formSubmit) define(HTMLFormElement.prototype, 'submit', function() { submitForm(this); }); - if (Native.formRequestSubmit) define(HTMLFormElement.prototype, 'requestSubmit', function(submitter) { submitForm(this, submitter); }); - if (Native.locationAssign) define(Location.prototype, 'assign', function(u) { setVirtualLocation(u); }); - if (Native.locationReplace) define(Location.prototype, 'replace', function(u) { setVirtualLocation(u, true); }); - if (Native.locationReload) define(Location.prototype, 'reload', function() { Native.locationReload(); }); - define(history, 'pushState', function(state, title, url) { return commitVirtualHistory(state, title, url, false); }); - define(history, 'replaceState', function(state, title, url) { return commitVirtualHistory(state, title, url, true); }); - window.addEventListener('popstate', () => { postMessageToSW({ type: 'ZP_RESOLVE_ENTRY', path: activeProxyPath }).then(reply => { activeEntryId = reply.entryId || activeEntryId; virtualURL = new URL(reply.targetUrl); baseURL = reply.baseUrl || virtualURL.href; explicitBaseURL = baseURL !== virtualURL.href ? baseURL : ''; if (typeof reply.scrollX === 'number' && typeof reply.scrollY === 'number') window.scrollTo(reply.scrollX, reply.scrollY); }).catch(()=>{}); }, true); - let scrollTimer = 0; - window.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(() => postMessageToSW({ type: 'ZP_SCROLL_UPDATE', tabId: boot.tabId, entryId: activeEntryId, scrollX: window.scrollX, scrollY: window.scrollY }).catch(()=>{}), 100); }, { passive: true }); - function submitForm(form, submitter) { submitFormNavigation(form, submitter).catch(err => { const code = err && (err.code || err.message); if (code === 'REQUEST_BODY_TOO_LARGE') Native.locationAssign && Native.locationAssign(ZP.errorPath('REQUEST_BODY_TOO_LARGE')); }); } - async function submitFormNavigation(form, submitter) { - const raw = submitter && submitter.getAttribute && submitter.getAttribute('formaction') || form.getAttribute('action') || virtualURL.href; - const method = String(submitter && submitter.getAttribute && submitter.getAttribute('formmethod') || form.getAttribute('method') || 'GET').toUpperCase(); - if (method === 'DIALOG') return; - const target = new URL(targetURL(raw)); - urlMeta.set(form, target.href); - if (method === 'GET') { - try { - const data = submitter ? new Native.FormData(form, submitter) : new Native.FormData(form); - const qs = new URLSearchParams(); - for (const [k, v] of data) qs.append(k, formEntryValue(v)); - const encoded = qs.toString(); - if (encoded) target.search = target.search ? target.search + '&' + encoded : encoded; - } catch {} - navigateToTarget(target.href); - return; - } - const serialized = await serializeFormSubmission(form, submitter, method, target.href); - const share = await ZP.encryptShareURL(target.href); - const entryId = 'e' + ZP.randomId(); - const reply = await postMessageToSW({ type: 'ZP_SUBMIT_PREPARE', tabId: boot.tabId, entryId, routeKey: share.encrypted, targetUrl: target.href, method, headers: serialized.headers, body: serialized.body, enctype: serialized.enctype, referrer: virtualURL.href }); - activeEntryId = entryId; - activeProxyPath = ZP.makeSharePath(share.encrypted); - activeRouteKey = share.encrypted; - activeProxyFragment = shareFragmentForKey(share.key); - const submittedPath = activeProxyPath + '?zp_submit=' + encodeURIComponent(reply.submitId) + activeProxyFragment; - if (Native.locationAssign) Native.locationAssign(submittedPath); - else location.href = submittedPath; - } - async function serializeFormSubmission(form, submitter, method, targetHref) { - const data = submitter ? new Native.FormData(form, submitter) : new Native.FormData(form); - const enctype = normalizedFormEncoding(form, submitter); - const headers = []; - let bytes; - if (enctype === 'multipart/form-data') { - const req = new Native.Request(targetHref, { method, body: data }); - const ct = req.headers.get('content-type'); - if (ct) headers.push(['Content-Type', ct]); - bytes = new Uint8Array(await req.arrayBuffer()); - } else { - const text = enctype === 'text/plain' ? plainFormBody(data) : urlEncodedFormBody(data); - const type = enctype === 'text/plain' ? 'text/plain;charset=UTF-8' : 'application/x-www-form-urlencoded;charset=UTF-8'; - headers.push(['Content-Type', type]); - bytes = new TextEncoder().encode(text); - } - return { enctype, headers, body: ZP.bytesToBase64Url(bytes) }; - } - function normalizedFormEncoding(form, submitter) { - const raw = String(submitter && submitter.getAttribute && submitter.getAttribute('formenctype') || form.getAttribute('enctype') || 'application/x-www-form-urlencoded').toLowerCase(); - return raw === 'multipart/form-data' || raw === 'text/plain' ? raw : 'application/x-www-form-urlencoded'; - } - function formEntryValue(v) { return v && typeof v === 'object' && typeof v.name === 'string' && typeof v.size === 'number' ? v.name : String(v); } - function urlEncodedFormBody(data) { const qs = new URLSearchParams(); for (const [k, v] of data) qs.append(k, formEntryValue(v)); return qs.toString(); } - function plainFormBody(data) { const out = []; for (const [k, v] of data) out.push(String(k) + '=' + formEntryValue(v)); return out.join('\r\n'); } - function clickNavigationTarget(ev) { - if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return null; - for (let el = ev.target; el && el !== document; el = el.parentElement) { - const isAnchor = el.matches && el.matches('a[href],area[href]'); - if (isAnchor) { - if (el.hasAttribute('download')) return null; - const target = el.getAttribute('target'); - if (target && target !== '_self') return null; - } - const raw = isAnchor ? Native.getAttribute.call(el, 'data-zp-target-url') || el.getAttribute('href') : typeof el.href === 'string' ? el.href : ''; - if (!raw) continue; - if (raw[0] === '#') return { hash: raw, element: el }; - if (hasExecutableURLScheme(raw)) return { href: '', element: el }; - if (isHTTPURL(raw)) return { href: raw, element: el }; - } - return null; - } - } - function installNavigatorIdentity(w) { - const nav = w.navigator; - if (!nav) return; - const proto = w.Navigator && w.Navigator.prototype || Object.getPrototypeOf(nav); - defineAccessor(proto, 'userAgent', () => TARGET_USER_AGENT); - defineAccessor(nav, 'userAgent', () => TARGET_USER_AGENT); - defineAccessor(proto, 'appVersion', () => TARGET_APP_VERSION); - defineAccessor(nav, 'appVersion', () => TARGET_APP_VERSION); - defineAccessor(proto, 'platform', () => TARGET_PLATFORM); - defineAccessor(nav, 'platform', () => TARGET_PLATFORM); - } - - function installPopupHooks(w) { - if (!Native.open) return; - define(w, 'open', function(url = 'about:blank', target = '_blank', features) { - const raw = String(url || 'about:blank'); - let child; - if (raw === 'about:blank' || raw === '') child = Native.open('about:blank', target, features); - else if (isHTTPURL(raw)) { child = Native.open('about:blank', target, features); if (child) shareNavURL(raw).then(u => { child.location.href = u; }).catch(() => { try { child.close(); } catch {} }); } - else child = Native.open('about:blank', target, features); - if (child && (raw === 'about:blank' || raw === '')) { - try { installNetworkContainment(child); } catch { try { child.close(); } catch {} return null; } - } - return child; - }); - } - - function installPostMessageHooks(w) { - if (!Native.windowAddEventListener || !Native.windowRemoveEventListener) return; - function wrap(listener) { - if (!listener || (typeof listener !== 'function' && typeof listener.handleEvent !== 'function')) return listener; - if (messageListenerWrappers.has(listener)) return messageListenerWrappers.get(listener); - const wrapped = function(ev) { - const next = virtualizeMessageEvent(ev); - return typeof listener === 'function' ? listener.call(this, next) : listener.handleEvent.call(listener, next); - }; - messageListenerWrappers.set(listener, wrapped); - return wrapped; - } - define(w, 'addEventListener', function(type, listener, options) { - return Native.windowAddEventListener(String(type), String(type) === 'message' ? wrap(listener) : listener, options); - }); - define(w, 'removeEventListener', function(type, listener, options) { - return Native.windowRemoveEventListener(String(type), String(type) === 'message' ? messageListenerWrappers.get(listener) || listener : listener, options); - }); - const wrappedPostMessage = postMessageWrapperFor(w); - if (wrappedPostMessage) define(w, 'postMessage', wrappedPostMessage); - let onmessage = null; - defineAccessor(w, 'onmessage', () => onmessage, value => { - if (onmessage) Native.windowRemoveEventListener('message', messageListenerWrappers.get(onmessage) || onmessage); - onmessage = typeof value === 'function' ? value : null; - if (onmessage) Native.windowAddEventListener('message', wrap(onmessage)); - }); - } - - function usesRawURLAttribute(el, key) { - const tag = el && el.localName; - const localKey = attrLocalName(key); - return localKey === 'href' && (tag === 'a' || tag === 'area') || localKey === 'action' && tag === 'form' || localKey === 'formaction' && (tag === 'input' || tag === 'button'); - } - function installGetterMasking(w) { - const locGet = p => () => new URL(virtualURL.href)[p]; - for (const p of ['href','protocol','host','hostname','port','pathname','search','hash','origin']) defineAccessor(w.Location && w.Location.prototype, p, locGet(p), p === 'href' ? v => { setVirtualLocation(v); } : p === 'hash' ? v => { updateVirtualHash(v); } : undefined); - define(w.Location && w.Location.prototype, 'toString', function(){ return virtualURL.href; }); - defineAccessor(w.Document && w.Document.prototype, 'URL', () => virtualURL.href); - defineAccessor(w.Document && w.Document.prototype, 'documentURI', () => virtualURL.href); - defineAccessor(w.Document && w.Document.prototype, 'baseURI', () => baseURL); - defineAccessor(w.Document && w.Document.prototype, 'referrer', () => ''); - defineAccessor(w.Document && w.Document.prototype, 'cookie', () => documentCookieString(), v => { const s = String(v); setDocumentCookie(s); postMessageToSW({ type: 'ZP_COOKIE_SET', tabId: boot.tabId, targetUrl: virtualURL.href, cookie: s }).catch(()=>{}); }); - installURLProp(w.HTMLAnchorElement && w.HTMLAnchorElement.prototype, 'href'); - installURLProp(w.HTMLAreaElement && w.HTMLAreaElement.prototype, 'href'); - installURLProp(w.HTMLFormElement && w.HTMLFormElement.prototype, 'action'); - installURLProp(w.HTMLInputElement && w.HTMLInputElement.prototype, 'formAction'); - installURLProp(w.HTMLButtonElement && w.HTMLButtonElement.prototype, 'formAction'); - function installURLProp(proto, prop) { if (!proto) return; defineAccessor(proto, prop, function(){ return urlMeta.get(this) || Native.getAttribute.call(this, 'data-zp-target-url') || targetURL(this.getAttribute(prop === 'formAction' ? 'formaction' : prop) || virtualURL.href); }, function(v){ const t = targetURL(v); urlMeta.set(this, t); Native.setAttribute.call(this, 'data-zp-target-url', t); this.setAttribute(prop === 'formAction' ? 'formaction' : prop, t); }); } - } - function initDocumentCookieRecords(cookieString) { - for (const part of String(cookieString || '').split(/;\s*/)) { - const eq = part.indexOf('='); - if (eq > 0) documentCookieRecords.push({ name: part.slice(0, eq), value: part.slice(eq + 1), domain: virtualURL.hostname.toLowerCase(), hostOnly: true, path: '/', secure: virtualURL.protocol === 'https:', expires: Infinity }); - } - documentCookie = documentCookieString(); - } - function setDocumentCookie(line) { - const parts = String(line).split(';').map(p => p.trim()).filter(Boolean); - if (!parts.length) return; - const eq = parts[0].indexOf('='); - if (eq <= 0) return; - const rec = { name: parts[0].slice(0, eq), value: parts[0].slice(eq + 1), domain: virtualURL.hostname.toLowerCase(), hostOnly: true, path: defaultCookiePath(), secure: false, expires: Infinity }; - for (let i = 1; i < parts.length; i++) { - const [rawK, ...rest] = parts[i].split('='); - const k = rawK.toLowerCase(); - const v = rest.join('='); - if (k === 'domain' && v) { const d = v.replace(/^\./, '').toLowerCase(); if (virtualURL.hostname.toLowerCase() === d || virtualURL.hostname.toLowerCase().endsWith('.' + d)) { rec.domain = d; rec.hostOnly = false; } } - else if (k === 'path' && v && v[0] === '/') rec.path = v; - else if (k === 'secure') rec.secure = true; - else if (k === 'max-age') rec.expires = Date.now() + Math.max(0, Number(v) || 0) * 1000; - else if (k === 'expires') { const ts = Date.parse(v); if (!Number.isNaN(ts)) rec.expires = ts; } - } - const idx = documentCookieRecords.findIndex(r => r.name === rec.name && r.domain === rec.domain && r.path === rec.path); - if (rec.expires <= Date.now()) { if (idx >= 0) documentCookieRecords.splice(idx, 1); } - else if (idx >= 0) documentCookieRecords[idx] = rec; - else documentCookieRecords.push(rec); - documentCookie = documentCookieString(); - } - function documentCookieString() { - const now = Date.now(); - const host = virtualURL.hostname.toLowerCase(); - const path = virtualURL.pathname || '/'; - return documentCookieRecords.filter(r => r.expires > now && (!r.secure || virtualURL.protocol === 'https:') && (r.hostOnly ? r.domain === host : host === r.domain || host.endsWith('.' + r.domain)) && (path === r.path || (path.startsWith(r.path) && (r.path.endsWith('/') || path[r.path.length] === '/')))).sort((a, b) => b.path.length - a.path.length).map(r => r.name + '=' + r.value).join('; '); - } - function defaultCookiePath() { const p = virtualURL.pathname || '/'; const i = p.lastIndexOf('/'); return i <= 0 ? '/' : p.slice(0, i); } - - function installStorageFacades(w) { - const prefix = storagePrefixForVirtualOrigin(); - const localKey = prefix + 'local'; - const sessionKey = prefix + 'session'; - const local = storageObject(localKey, w); - const session = storageObject(sessionKey, w); - storageWindows.add({ w, localKey, sessionKey }); - defineAccessor(w, 'localStorage', () => local); - defineAccessor(w, 'sessionStorage', () => session); - if (w.indexedDB) { - const nativeIDB = w.indexedDB; - define(w, 'indexedDB', { - open(name, version) { return nativeIDB.open(prefix + 'idb:' + String(name), version); }, - deleteDatabase(name) { return nativeIDB.deleteDatabase(prefix + 'idb:' + String(name)); }, - cmp: nativeIDB.cmp ? nativeIDB.cmp.bind(nativeIDB) : undefined, - databases: nativeIDB.databases ? () => nativeIDB.databases().then(list => list.filter(db => db.name && db.name.startsWith(prefix + 'idb:')).map(db => Object.assign({}, db, { name: db.name.slice((prefix + 'idb:').length) }))) : undefined - }); - } - if (w.caches) { - const nativeCaches = w.caches; - define(w, 'caches', { - open(name) { return nativeCaches.open(prefix + 'cache:' + String(name)); }, - delete(name) { return nativeCaches.delete(prefix + 'cache:' + String(name)); }, - has(name) { return nativeCaches.has(prefix + 'cache:' + String(name)); }, - keys() { return nativeCaches.keys().then(keys => keys.filter(k => k.startsWith(prefix + 'cache:')).map(k => k.slice((prefix + 'cache:').length))); }, - match(request, opts) { return nativeCaches.keys().then(keys => keys.filter(k => k.startsWith(prefix + 'cache:'))).then(async keys => { for (const k of keys) { const hit = await (await nativeCaches.open(k)).match(request, opts); if (hit) return hit; } return undefined; }); } - }); - } - } - function storagePrefixForVirtualOrigin() { return 'zp:' + boot.tabId + ':' + virtualURL.origin + ':'; } - function storageMap(key) { - let map = storageMaps.get(key); - if (!map) { map = new Map(); storageMaps.set(key, map); } - return map; - } - function storageObject(namespaceKey, ownerWindow) { - const map = storageMap(namespaceKey); - return Object.freeze({ - get length() { return map.size; }, - key(i) { return Array.from(map.keys())[Number(i)] || null; }, - getItem(k) { k = String(k); return map.has(k) ? map.get(k) : null; }, - setItem(k, v) { k = String(k); v = String(v); const oldValue = map.has(k) ? map.get(k) : null; map.set(k, v); dispatchStorageEvents(namespaceKey, ownerWindow, k, oldValue, v); }, - removeItem(k) { k = String(k); const oldValue = map.has(k) ? map.get(k) : null; map.delete(k); dispatchStorageEvents(namespaceKey, ownerWindow, k, oldValue, null); }, - clear() { if (!map.size) return; map.clear(); dispatchStorageEvents(namespaceKey, ownerWindow, null, null, null); } - }); - } - function dispatchStorageEvents(namespaceKey, sourceWindow, key, oldValue, newValue) { - for (const rec of Array.from(storageWindows)) { - const w = rec.w; - if (!w || w === sourceWindow || (rec.localKey !== namespaceKey && rec.sessionKey !== namespaceKey)) continue; - try { - const ev = new w.StorageEvent('storage', { key, oldValue, newValue, url: virtualURL.href }); - w.dispatchEvent(ev); - } catch { try { w.dispatchEvent(new Event('storage')); } catch {} } - } - } - function attrLocalName(key) { - const s = String(key || '').toLowerCase(); - const i = s.indexOf(':'); - return i >= 0 ? s.slice(i + 1) : s; - } - function tokenListContains(list, token) { - return String(list || '').toLowerCase().split(/[\s,]+/).includes(token); - } - function isBlockedLinkRelValue(rel) { - for (const token of ['modulepreload','preload','prefetch','preconnect','dns-prefetch','prerender','manifest']) { - if (tokenListContains(rel, token)) return true; - } - return false; - } - function isIconLinkRelValue(rel) { - for (const token of String(rel || '').toLowerCase().split(/[\s,]+/)) { - if (token === 'icon' || token === 'mask-icon' || token === 'apple-touch-icon' || token === 'apple-touch-icon-precomposed' || token === 'apple-touch-startup-image' || token === 'fluid-icon') return true; - } - return false; - } - function isBlockedLink(el) { return el && el.localName === 'link' && isBlockedLinkRelValue(Native.getAttribute.call(el, 'rel') || ''); } - function isIconLink(el) { return el && el.localName === 'link' && isIconLinkRelValue(Native.getAttribute.call(el, 'rel') || ''); } - function hasSuppressedBlockedLinkRel(el) { return el && el.localName === 'link' && isBlockedLinkRelValue(Native.getAttribute.call(el, 'data-zp-blocked-rel') || ''); } - function visibleLinkTarget(el) { return urlMeta.get(el) || Native.getAttribute.call(el, 'data-zp-target-url') || ''; } - function suppressBlockedLinkRel(el, rawRel) { - Native.setAttribute.call(el, 'data-zp-blocked-rel', String(rawRel)); - if (Native.removeAttribute) Native.removeAttribute.call(el, 'rel'); - blockLinkURL(el, Native.getAttribute.call(el, 'href') || ''); - } - function blockLinkURL(el, raw) { - if (raw != null && String(raw) !== '') Native.setAttribute.call(el, 'data-zp-blocked-url', String(raw)); - urlMeta.delete(el); - if (Native.removeAttribute) Native.removeAttribute.call(el, 'href'); - } - function suppressIconLinkHref(el, raw) { - const value = raw == null ? '' : String(raw); - let visible = value; - if (value && isHTTPURL(value)) { - try { visible = targetURL(value); } catch {} - } - const currentVisible = Native.getAttribute.call(el, 'data-zp-target-url') || ''; - const currentHref = Native.getAttribute.call(el, 'href') || ''; - if (visible) { - urlMeta.set(el, visible); - if (currentVisible !== visible) Native.setAttribute.call(el, 'data-zp-target-url', visible); - } else { - urlMeta.delete(el); - if (currentVisible && Native.removeAttribute) Native.removeAttribute.call(el, 'data-zp-target-url'); - } - if (currentHref !== hiddenIconHref) Native.setAttribute.call(el, 'href', hiddenIconHref); - } - function enforceLinkPolicy(el) { - if (!el || el.localName !== 'link') return; - const rel = Native.getAttribute.call(el, 'rel') || ''; - if (isBlockedLinkRelValue(rel)) { - suppressBlockedLinkRel(el, rel); - return; - } - if (rel && Native.removeAttribute) Native.removeAttribute.call(el, 'data-zp-blocked-rel'); - if (hasSuppressedBlockedLinkRel(el)) { - blockLinkURL(el, Native.getAttribute.call(el, 'href') || ''); - return; - } - if (isIconLinkRelValue(rel)) { - suppressIconLinkHref(el, visibleLinkTarget(el) || Native.getAttribute.call(el, 'href') || ''); - return; - } - if (Native.getAttribute.call(el, 'href') === hiddenIconHref) { - const restored = visibleLinkTarget(el); - if (restored) Native.setAttribute.call(el, 'href', restored); - else if (Native.removeAttribute) Native.removeAttribute.call(el, 'href'); - } - const href = Native.getAttribute.call(el, 'href') || ''; - if (href && isHTTPURL(href) && !String(href).startsWith(proxyOrigin)) { - const target = targetURL(href); - const alreadyMapped = urlMeta.get(el) === target && Native.getAttribute.call(el, 'data-zp-target-url') === target && Native.getAttribute.call(el, 'href') === target; - urlMeta.set(el, target); - if (Native.getAttribute.call(el, 'data-zp-target-url') !== target) Native.setAttribute.call(el, 'data-zp-target-url', target); - if (!alreadyMapped && Native.getAttribute.call(el, 'href') !== target) Native.setAttribute.call(el, 'href', target); - } - } - function visibleIconAttrValue(attr) { - const owner = attr && attr.ownerElement; - if (!owner || owner.localName !== 'link' || String(attr.name || '').toLowerCase() !== 'href' || !isIconLinkRelValue(Native.getAttribute.call(owner, 'rel') || '')) return null; - return visibleLinkTarget(owner) || Native.getAttribute.call(owner, 'href') || ''; - } - function restoreVisibleLinkState(node) { - if (!node || node.nodeType !== 1) return; - if (node.localName === 'link' && isIconLinkRelValue(Native.getAttribute.call(node, 'rel') || '')) { - const target = Native.getAttribute.call(node, 'data-zp-target-url') || ''; - if (target) Native.setAttribute.call(node, 'href', target); - } - } - function sanitizeSerializedHTML(html) { - const parserDoc = Native.createHTMLDocument ? Native.createHTMLDocument('') : document.implementation.createHTMLDocument(''); - const container = parserDoc.createElement('div'); - if (Native.elementInnerHTML && Native.elementInnerHTML.set) Native.elementInnerHTML.set.call(container, String(html || '')); - else container.innerHTML = String(html || ''); - const nodes = Array.from(container.querySelectorAll('*')); - for (const node of nodes) { - restoreVisibleLinkState(node); - if (isZPAssetNode(node)) { node.remove(); continue; } - if (Native.getAttributeNames) for (const name of Native.getAttributeNames.call(node)) if (isZPAttrName(name)) Native.removeAttribute.call(node, name); - } - return Native.elementInnerHTML && Native.elementInnerHTML.get ? Native.elementInnerHTML.get.call(container) : container.innerHTML; - } - function isNavigationTargetElement(el) { - const tag = el && el.localName; - return tag === 'a' || tag === 'area' || tag === 'form' || tag === 'button' || tag === 'input'; - } - function setSafeNavigationTarget(el, attrName, value) { - const raw = String(value || ''); - if (raw && raw !== '_self') Native.setAttribute.call(el, 'data-zp-blocked-target', raw); - return Native.setAttribute.call(el, attrName, '_self'); - } - function isZPAttrName(name) { return String(name || '').toLowerCase().startsWith('data-zp-'); } - function isZeroProxyAssetURL(raw) { - if (!raw) return false; - try { - const u = new URL(String(raw), proxyOrigin); - return u.origin === proxyOrigin && (u.pathname === ZP.assetPath('zp-core.js') || u.pathname === ZP.assetPath('runtime-prelude.js') || u.pathname === ZP.assetPath('rust-rewriter.js') || u.pathname === ZP.assetPath('wasm_exec.js')); - } catch { return false; } - } - function isZPAssetNode(node) { - if (!node || node.nodeType !== 1) return false; - if (node.id === '__zp-boot') return true; - return node.localName === 'script' && isZeroProxyAssetURL(Native.getAttribute.call(node, 'src')); - } - function filteredNamedNodeMap(raw) { - return filteredCollection(raw, attr => attr && !isZPAttrName(attr.name)); - } - function filteredCollection(raw, predicate) { - const nth = index => { - let seen = 0; - for (let i = 0; raw && i < raw.length; i++) { - const item = raw[i]; - if (predicate(item)) { - if (seen === index) return item; - seen++; - } - } - return null; - }; - const length = () => { - let n = 0; - for (let i = 0; raw && i < raw.length; i++) if (predicate(raw[i])) n++; - return n; - }; - return new Proxy({}, { - get(_target, prop) { - if (prop === 'length') return length(); - if (prop === 'item') return index => nth(Number(index) || 0); - if (prop === 'getNamedItem') return name => { - const lower = String(name || '').toLowerCase(); - if (isZPAttrName(lower)) return null; - for (let i = 0; raw && i < raw.length; i++) if (raw[i] && String(raw[i].name).toLowerCase() === lower && predicate(raw[i])) return raw[i]; - return null; - }; - if (prop === Symbol.iterator) return function*(){ for (let i = 0; i < length(); i++) yield nth(i); }; - if (/^(?:0|[1-9]\d*)$/.test(String(prop))) { - const index = Number(prop); - return index < length() ? nth(index) : undefined; - } - const value = raw && raw[prop]; - return typeof value === 'function' ? value.bind(raw) : value; - }, - has(_target, prop) { return prop === 'length' || (/^(?:0|[1-9]\\d*)$/.test(String(prop)) && Number(prop) < length()); } - }); - } - function sanitizeSerializedHTML(html) { - const parserDoc = Native.createHTMLDocument ? Native.createHTMLDocument('') : document.implementation.createHTMLDocument(''); - const container = parserDoc.createElement('div'); - if (Native.elementInnerHTML && Native.elementInnerHTML.set) Native.elementInnerHTML.set.call(container, String(html || '')); - else container.innerHTML = String(html || ''); - const nodes = Array.from(container.querySelectorAll('*')); - for (const node of nodes) { - restoreVisibleLinkState(node); - if (isZPAssetNode(node)) { node.remove(); continue; } - if (Native.getAttributeNames) for (const name of Native.getAttributeNames.call(node)) if (isZPAttrName(name)) Native.removeAttribute.call(node, name); - } - return Native.elementInnerHTML && Native.elementInnerHTML.get ? Native.elementInnerHTML.get.call(container) : container.innerHTML; - } - - function installStealthMembrane(w) { - if (!w || !w.Document || !w.Element) return; - try { if (w[stealthMarker]) return; Object.defineProperty(w, stealthMarker, { value: true, enumerable: false, configurable: false }); } catch {} - const docGetTags = w.Document.prototype.getElementsByTagName; - const elemGetTags = w.Element.prototype.getElementsByTagName; - if (typeof docGetTags === 'function') define(w.Document.prototype, 'getElementsByTagName', function(tag) { - const raw = docGetTags.apply(this, arguments); - return shouldFilterTag(tag) ? filteredCollection(raw, node => !isZPAssetNode(node)) : raw; - }); - if (typeof elemGetTags === 'function') define(w.Element.prototype, 'getElementsByTagName', function(tag) { - const raw = elemGetTags.apply(this, arguments); - return shouldFilterTag(tag) ? filteredCollection(raw, node => !isZPAssetNode(node)) : raw; - }); - const scriptsDesc = Object.getOwnPropertyDescriptor(w.Document.prototype, 'scripts') || Native.documentScripts; - if (scriptsDesc && scriptsDesc.get) try { Object.defineProperty(w.Document.prototype, 'scripts', { get() { return filteredCollection(scriptsDesc.get.call(this), node => !isZPAssetNode(node)); }, configurable: false }); } catch {} - const docQS = w.Document.prototype.querySelector; - const docQSA = w.Document.prototype.querySelectorAll; - const elemQS = w.Element.prototype.querySelector; - const elemQSA = w.Element.prototype.querySelectorAll; - if (typeof docQS === 'function') define(w.Document.prototype, 'querySelector', function(sel) { return selectorTargetsZP(sel) ? null : filterSelectorOne(docQS.apply(this, arguments)); }); - if (typeof elemQS === 'function') define(w.Element.prototype, 'querySelector', function(sel) { return selectorTargetsZP(sel) ? null : filterSelectorOne(elemQS.apply(this, arguments)); }); - if (typeof docQSA === 'function') define(w.Document.prototype, 'querySelectorAll', function(sel) { return selectorTargetsZP(sel) ? filteredCollection([], () => false) : filteredCollection(docQSA.apply(this, arguments), node => !isZPAssetNode(node)); }); - if (typeof elemQSA === 'function') define(w.Element.prototype, 'querySelectorAll', function(sel) { return selectorTargetsZP(sel) ? filteredCollection([], () => false) : filteredCollection(elemQSA.apply(this, arguments), node => !isZPAssetNode(node)); }); - const matches = w.Element.prototype.matches; - const closest = w.Element.prototype.closest; - if (typeof matches === 'function') define(w.Element.prototype, 'matches', function(sel) { return selectorTargetsZP(sel) ? false : matches.apply(this, arguments); }); - if (typeof closest === 'function') define(w.Element.prototype, 'closest', function(sel) { return selectorTargetsZP(sel) ? null : filterSelectorOne(closest.apply(this, arguments)); }); - const nodeIterator = w.Document.prototype.createNodeIterator; - if (typeof nodeIterator === 'function') define(w.Document.prototype, 'createNodeIterator', function() { return filteredTraversal(nodeIterator.apply(this, arguments)); }); - const treeWalker = w.Document.prototype.createTreeWalker; - if (typeof treeWalker === 'function') define(w.Document.prototype, 'createTreeWalker', function() { return filteredTraversal(treeWalker.apply(this, arguments)); }); - } - function shouldFilterTag(tag) { - const t = String(tag || '').toLowerCase(); - return t === '*' || t === 'script' || t === 'meta' || t === 'link'; - } - function selectorTargetsZP(selector) { - const s = String(selector || '').toLowerCase(); - return s.includes('data-zp-') || s.includes('#__zp-boot') || s.includes('/zp/assets/') || s.includes('x-zeroproxy-icon'); - } - function filterSelectorOne(node) { return isZPAssetNode(node) ? null : node; } - function filteredTraversal(raw) { - return new Proxy(raw, { - get(target, prop) { - if (prop === 'nextNode' || prop === 'previousNode') return function() { - let node; - do { node = target[prop](); } while (node && isZPAssetNode(node)); - return node; - }; - const value = target[prop]; - return typeof value === 'function' ? value.bind(target) : value; - } - }); - } - - - function installDOMHooks(w) { - define(w.Element.prototype, 'setAttribute', function(k, v) { - const key = String(k).toLowerCase(); - const localKey = attrLocalName(key); - if (key === 'integrity' && isIntegrityBearing(this)) return setBackedIntegrity(this, v); - if (localKey === 'target' && isNavigationTargetElement(this)) return setSafeNavigationTarget(this, k, v); - if (this.localName === 'link' && localKey === 'rel') { - const value = String(v); - if (isBlockedLinkRelValue(value)) return suppressBlockedLinkRel(this, value); - if (Native.removeAttribute) Native.removeAttribute.call(this, 'data-zp-blocked-rel'); - const ret = Native.setAttribute.call(this, k, v); - enforceLinkPolicy(this); - return ret; - } - if (this.localName === 'link' && localKey === 'href' && (isBlockedLink(this) || hasSuppressedBlockedLinkRel(this))) return blockLinkURL(this, v); - if (this.localName === 'link' && localKey === 'href' && isIconLink(this)) return suppressIconLinkHref(this, v); - if (key.startsWith('on') && key.length > 2) return Native.setAttribute.call(this, k, rewriteEventAttribute(String(v))); - if (this.localName === 'base' && localKey === 'href') { - updateVirtualBase(v); - return Native.setAttribute.call(this, k, v); - } - if (this.localName === 'script' && (localKey === 'src' || localKey === 'href')) return setScriptSource(this, v); - if (isURLBearing(this, key)) { - if (shouldBlockURLAttribute(this, localKey, v)) return blockExecutableURL(this, localKey, v); - if (isHTTPURL(v)) { - const t = targetURL(v); - urlMeta.set(this, t); - if (!usesRawURLAttribute(this, key)) Native.setAttribute.call(this, 'data-zp-target-url', t); - if ((this.localName === 'iframe' || this.localName === 'frame') && localKey === 'src') { - Native.setAttribute.call(this, k, 'about:blank'); - activatedFrameURL(t).then(u => { Native.setAttribute.call(this, k, u); rememberFrameOrigin(this); }).catch(()=>{}); - return; - } - if (this.localName === 'link' && localKey === 'href' && isIconLink(this)) return suppressIconLinkHref(this, t); - return Native.setAttribute.call(this, k, usesRawURLAttribute(this, key) ? v : t); - } - } - if ((this.localName === 'iframe' || this.localName === 'frame') && localKey === 'srcdoc') return Native.setAttribute.call(this, k, injectSrcdoc(String(v))); - return Native.setAttribute.call(this, k, v); - }); - if (Native.setAttributeNS) define(w.Element.prototype, 'setAttributeNS', function(ns, k, v) { - const key = String(k).toLowerCase(); - const localKey = attrLocalName(key); - if (key === 'integrity' && isIntegrityBearing(this)) return setBackedIntegrity(this, v); - if (this.localName === 'script' && (localKey === 'src' || localKey === 'href')) return setScriptSource(this, v); - if (this.localName === 'link' && localKey === 'href' && isIconLink(this)) return suppressIconLinkHref(this, v); - if (isURLBearing(this, key)) { - if (shouldBlockURLAttribute(this, localKey, v)) return blockExecutableURL(this, localKey, v); - if (isHTTPURL(v)) { - const t = targetURL(v); - urlMeta.set(this, t); - if (!usesRawURLAttribute(this, key)) Native.setAttribute.call(this, 'data-zp-target-url', t); - return Native.setAttributeNS.call(this, ns, k, usesRawURLAttribute(this, key) ? v : t); - } - } - return Native.setAttributeNS.call(this, ns, k, key.startsWith('on') && key.length > 2 ? rewriteEventAttribute(String(v)) : v); - }); - if (Native.namedSetNamedItem && w.NamedNodeMap) define(w.NamedNodeMap.prototype, 'setNamedItem', function(attr) { if (attr && String(attr.name || '').toLowerCase().startsWith('on')) attr.value = rewriteEventAttribute(String(attr.value || '')); return Native.namedSetNamedItem.call(this, attr); }); - if (Native.attrValue && Native.attrValue.set && w.Attr) try { Object.defineProperty(w.Attr.prototype, 'value', { get() { const masked = visibleIconAttrValue(this); return masked === null ? Native.attrValue.get.call(this) : masked; }, set(v) { Native.attrValue.set.call(this, String(this.name || '').toLowerCase().startsWith('on') ? rewriteEventAttribute(String(v)) : v); }, configurable: false }); } catch {} - define(w.Element.prototype, 'getAttribute', function(k) { - const key = String(k).toLowerCase(); - if (isZPAttrName(key)) return null; - if (key === 'integrity' && isIntegrityBearing(this)) { - const backed = backedIntegrity(this); - return backed !== null ? backed : Native.getAttribute.call(this, k); - } - if (isURLBearing(this, key)) return usesRawURLAttribute(this, key) ? Native.getAttribute.call(this, k) : urlMeta.get(this) || Native.getAttribute.call(this, 'data-zp-target-url') || Native.getAttribute.call(this, k); - return Native.getAttribute.call(this, k); - }); - if (Native.hasAttribute) define(w.Element.prototype, 'hasAttribute', function(k) { - const key = String(k).toLowerCase(); - if (isZPAttrName(key)) return false; - if (key === 'integrity' && isIntegrityBearing(this)) return backedIntegrity(this) !== null || Native.hasAttribute.call(this, k); - return Native.hasAttribute.call(this, k); - }); - if (Native.removeAttribute) define(w.Element.prototype, 'removeAttribute', function(k) { - const key = String(k).toLowerCase(); - const localKey = attrLocalName(key); - if (key === 'integrity' && isIntegrityBearing(this)) { - Native.removeAttribute.call(this, integrityBackupAttr); - return Native.removeAttribute.call(this, k); - } - if (this.localName === 'link' && localKey === 'href' && isIconLink(this)) { - urlMeta.delete(this); - if (Native.removeAttribute) Native.removeAttribute.call(this, 'data-zp-target-url'); - return Native.removeAttribute.call(this, k); - } - if (this.localName === 'link' && localKey === 'rel') { - const ret = Native.removeAttribute.call(this, k); - enforceLinkPolicy(this); - return ret; - } - return Native.removeAttribute.call(this, k); - }); - if (Native.getAttributeNames) define(w.Element.prototype, 'getAttributeNames', function() { - const names = Native.getAttributeNames.call(this).filter(name => !isZPAttrName(name)); - if (isIntegrityBearing(this) && backedIntegrity(this) !== null && !names.some(name => String(name).toLowerCase() === 'integrity')) names.push('integrity'); - return names; - }); - if (Native.elementAttributes && Native.elementAttributes.get) try { Object.defineProperty(w.Element.prototype, 'attributes', { get() { return filteredNamedNodeMap(Native.elementAttributes.get.call(this)); }, configurable: false }); } catch {} - installIntegrityProp(w.HTMLScriptElement && w.HTMLScriptElement.prototype); - installIntegrityProp(w.HTMLLinkElement && w.HTMLLinkElement.prototype); - installScriptProp(w.HTMLScriptElement && w.HTMLScriptElement.prototype); - installScriptTextProps(w); - installLinkProp(w.HTMLLinkElement && w.HTMLLinkElement.prototype); - patchHTMLSetter(w.Element.prototype, 'innerHTML'); - patchHTMLSetter(w.Element.prototype, 'outerHTML'); - define(w.Element.prototype, 'insertAdjacentHTML', function(pos, html) { const ret = Native.insertAdjacentHTML.call(this, pos, transformHTML(String(html))); syncBaseElement(this); enforceSubtreePolicies(this); return ret; }); - installBaseObserver(); - function patchHTMLSetter(proto, prop) { - const d = Object.getOwnPropertyDescriptor(proto, prop); - if (!d || !d.set) return; - try { - Object.defineProperty(proto, prop, { - get() { return d.get ? sanitizeSerializedHTML(d.get.call(this)) : ''; }, - set(v) { - if (this && this.localName === 'template' && prop === 'innerHTML') { - d.set.call(this, String(v)); - enforceSubtreePolicies(this.content); - instrumentDescendantIframes(this.content); - return; - } - d.set.call(this, transformHTML(String(v))); - syncBaseElement(this); - instrumentDescendantIframes(this); - enforceSubtreePolicies(this); - }, - configurable: false - }); - } catch {} - } - } - function installIntegrityProp(proto) { - if (!proto) return; - defineAccessor(proto, 'integrity', function() { - const backed = backedIntegrity(this); - return backed !== null ? backed : Native.getAttribute.call(this, 'integrity') || ''; - }, function(v) { - setBackedIntegrity(this, v); - }); - } - function propertyDescriptor(proto, prop) { - for (let p = proto; p; p = Object.getPrototypeOf(p)) { - const d = Object.getOwnPropertyDescriptor(p, prop); - if (d) return d; - } - return null; - } - function executableScriptKindForElement(el) { - const t = String(Native.getAttribute.call(el, 'type') || '').trim().toLowerCase(); - if (t === 'module') return 'module'; - if (t === '' || t === 'text/javascript' || t === 'application/javascript' || t === 'application/ecmascript' || t === 'text/ecmascript') return 'classic'; - return ''; - } - function scriptProxyPath(target, kind) { - return ZP.apiPath('script') + '?kind=' + encodeURIComponent(kind) + '&u=' + encodeURIComponent(target); - } - function setScriptSource(el, raw) { - const kind = executableScriptKindForElement(el); - const value = String(raw); - const trimmed = value.trim(); - if (trimmed.startsWith(ZP.CONTROL_PREFIX) || trimmed.startsWith(proxyOrigin + ZP.CONTROL_PREFIX)) { - const internal = trimmed.startsWith(proxyOrigin) ? new URL(trimmed).pathname + new URL(trimmed).search : value; - if (Native.getAttribute.call(el, 'src') === internal) return; - return Native.setAttribute.call(el, 'src', internal); - } - if (!kind) { - urlMeta.delete(el); - return Native.setAttribute.call(el, 'src', value); - } - if (hasExecutableURLScheme(value) || !isHTTPURL(value)) return blockExecutableURL(el, 'src', value); - let target; - try { target = targetURL(value); } catch { return blockExecutableURL(el, 'src', value); } - urlMeta.set(el, target); - Native.setAttribute.call(el, 'data-zp-target-url', target); - return Native.setAttribute.call(el, 'src', scriptProxyPath(target, kind)); - } - function installScriptTextProps(w) { - const scriptProto = w.HTMLScriptElement && w.HTMLScriptElement.prototype; - if (!scriptProto) return; - for (const prop of ['text', 'textContent', 'innerText']) { - const d = propertyDescriptor(scriptProto, prop) || propertyDescriptor(w.Node && w.Node.prototype, prop); - if (!d || !d.set) continue; - try { - Object.defineProperty(scriptProto, prop, { - get() { return d.get ? d.get.call(this) : ''; }, - set(v) { d.set.call(this, v); if (this.isConnected) prepareScriptElement(this); }, - configurable: false - }); - } catch {} - } - } - function installScriptProp(proto) { - if (!proto) return; - const d = propertyDescriptor(proto, 'src'); - if (!d || !d.get) return; - try { - Object.defineProperty(proto, 'src', { - get() { return urlMeta.get(this) || Native.getAttribute.call(this, 'data-zp-target-url') || d.get.call(this); }, - set(v) { setScriptSource(this, v); }, - configurable: false - }); - } catch {} - } - function installLinkProp(proto) { - if (!proto) return; - const hrefDescriptor = propertyDescriptor(proto, 'href'); - if (hrefDescriptor && hrefDescriptor.get) try { - Object.defineProperty(proto, 'href', { - get() { return urlMeta.get(this) || Native.getAttribute.call(this, 'data-zp-target-url') || hrefDescriptor.get.call(this); }, - set(v) { - if (isBlockedLink(this) || hasSuppressedBlockedLinkRel(this)) return blockLinkURL(this, v); - if (isIconLink(this)) return suppressIconLinkHref(this, v); - const value = String(v); - if (shouldBlockURLAttribute(this, 'href', value)) return blockExecutableURL(this, 'href', value); - if (isHTTPURL(value)) { - const t = targetURL(value); - urlMeta.set(this, t); - Native.setAttribute.call(this, 'data-zp-target-url', t); - return hrefDescriptor.set ? hrefDescriptor.set.call(this, t) : Native.setAttribute.call(this, 'href', t); - } - return hrefDescriptor.set ? hrefDescriptor.set.call(this, value) : Native.setAttribute.call(this, 'href', value); - }, - configurable: false - }); - } catch {} - const relDescriptor = propertyDescriptor(proto, 'rel'); - if (relDescriptor && relDescriptor.get) try { - Object.defineProperty(proto, 'rel', { - get() { return relDescriptor.get.call(this); }, - set(v) { - const value = String(v); - if (isBlockedLinkRelValue(value)) return suppressBlockedLinkRel(this, value); - if (Native.removeAttribute) Native.removeAttribute.call(this, 'data-zp-blocked-rel'); - const ret = relDescriptor.set ? relDescriptor.set.call(this, value) : Native.setAttribute.call(this, 'rel', value); - enforceLinkPolicy(this); - return ret; - }, - configurable: false - }); - } catch {} - } - function getScriptText(el) { - if (Native.scriptText && Native.scriptText.get) return String(Native.scriptText.get.call(el) || ''); - if (Native.nodeTextContent && Native.nodeTextContent.get) return String(Native.nodeTextContent.get.call(el) || ''); - return String(el.textContent || ''); - } - function setScriptText(el, value) { - const text = String(value || ''); - if (Native.scriptText && Native.scriptText.set) { Native.scriptText.set.call(el, text); return; } - if (Native.nodeTextContent && Native.nodeTextContent.set) { Native.nodeTextContent.set.call(el, text); return; } - el.textContent = text; - } - function inlineScriptWrapper(source, kind) { - const payload = JSON.stringify(String(source || '')).replace(/ { - if (typeof value !== 'string') return value; - try { - const u = new URL(value, baseURL); - if (u.protocol === 'http:' || u.protocol === 'https:') return scriptProxyPath(u.href, 'module'); - return ZP.errorPath('POLICY_BLOCKED'); - } catch { - return ZP.errorPath('POLICY_BLOCKED'); - } - }; - if (map.imports && typeof map.imports === 'object' && !Array.isArray(map.imports)) { - for (const key of Object.keys(map.imports)) map.imports[key] = rewriteAddress(map.imports[key]); - } - if (map.scopes && typeof map.scopes === 'object' && !Array.isArray(map.scopes)) { - const nextScopes = {}; - for (const scope of Object.keys(map.scopes)) { - let scopeKey = scope; - try { - const u = new URL(scope, baseURL); - if (u.protocol === 'http:' || u.protocol === 'https:') scopeKey = scriptProxyPath(u.href, 'module'); - } catch {} - const entries = map.scopes[scope]; - if (entries && typeof entries === 'object' && !Array.isArray(entries)) { - const out = {}; - for (const key of Object.keys(entries)) out[key] = rewriteAddress(entries[key]); - nextScopes[scopeKey] = out; - } - } - map.scopes = nextScopes; - } - return JSON.stringify(map).replace(/[<>&]/g, c => c === '<' ? '\\u003c' : c === '>' ? '\\u003e' : '\\u0026'); - } - function transformHTML(value) { - const html = String(value); - if (!html) return html; - const parserDoc = Native.createHTMLDocument ? Native.createHTMLDocument('') : document.implementation.createHTMLDocument(''); - const container = parserDoc.createElement('template'); - if (Native.elementInnerHTML && Native.elementInnerHTML.set) Native.elementInnerHTML.set.call(container, html); - else container.innerHTML = html; - const root = container.content || container; - const walker = parserDoc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - const nodes = []; - for (let node = walker.nextNode(); node; node = walker.nextNode()) nodes.push(node); - for (const node of nodes) { - const tag = node.localName; - if (tag === 'base' && Native.getAttribute.call(node, 'href')) { - const href = Native.getAttribute.call(node, 'href') || ''; - updateVirtualBase(href); - const script = parserDoc.createElement('script'); - setScriptText(script, 'window.__ZP_SET_BASE&&window.__ZP_SET_BASE(' + JSON.stringify(href).replace(/ 2) { - const val = Native.getAttribute.call(node, attrName) || ''; - Native.setAttribute.call(node, 'data-zp-blocked-' + lowerAttr, val); - Native.setAttribute.call(node, attrName, rewriteEventAttribute(val)); - } - if (isURLBearing(node, lowerAttr)) enforceObservedAttribute(node, lowerAttr); - } - } - } - return Native.elementInnerHTML && Native.elementInnerHTML.get ? Native.elementInnerHTML.get.call(container) : container.innerHTML; - } - function injectSrcdoc(s) { return '