Skip to content

Commit cdf04be

Browse files
committed
Implement Phase 3 compatibility cutover
1 parent f595f1c commit cdf04be

23 files changed

Lines changed: 2429 additions & 300 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ web/wasm_exec.js
66
web/oxc_parser_wasm_bg.wasm
77
node_modules/
88
coverage/
9+
rewriter-rs/target/
10+
wasm-kernel

ARCHITECTURE.md

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,39 @@ ZeroProxy is a client-owned virtual browsing prototype. The browser renders targ
66

77
```text
88
Browser top-level target document
9-
├─ /sw.js Service Worker
10-
│ ├─ request classifier for every controlled fetch
11-
│ ├─ in-memory tab/history/context state
12-
│ ├─ encrypted /p route activation
9+
├─ /zp/sw.js Service Worker
10+
│ ├─ request classifier for every controlled fetch under `/zp/`
11+
│ ├─ in-memory tab/history/context state plus inherited relay-server list
12+
│ ├─ encrypted /zp/p route activation
1313
│ └─ Go WASM kernel exports
1414
│ ├─ __go_jshttp(request) -> target HTTP/2 or HTTP/1.1 fetch
1515
│ ├─ __zp_stream(options) -> target WebSocket stream
1616
│ ├─ __zp_kernel_init() -> transport readiness
1717
│ └─ __zp_cookie_set(request) -> document.cookie bridge
18-
├─ /__zp/runtime-prelude.js
18+
├─ /zp/assets/rust-rewriter.js
19+
│ └─ Rust WASM AST walker returning rewritten JavaScript
20+
├─ /zp/assets/runtime-prelude.js
1921
│ ├─ fetch/XHR/EventSource/WebSocket/sendBeacon wrappers routed through same-origin runtime APIs
2022
│ ├─ navigation, form, history, location, and getter masking hooks
2123
│ ├─ storage namespace facades
2224
│ ├─ worker and iframe containment hooks
23-
│ ├─ basic self-fingerprint masking hooks
24-
│ └─ WebRTC/WebTransport/device API blocking stubs
25+
│ ├─ dynamic HTML parser traversal and stealth membrane
26+
│ └─ WebRTC/WebTransport/device API blocking stubs plus `WebSocketStream`
2527
└─ transformed target HTML as the top-level document
2628
2729
Proxy origin server
28-
├─ static assets: /, /sw.js, /__zp/*, /__zp/kernel.wasm
29-
└─ /__zp/ws-pipe WebSocket endpoint
30+
├─ static assets: /zp/, /zp/sw.js, /zp/assets/*, /zp/kernel.wasm
31+
└─ /zp/ws-pipe WebSocket endpoint
3032
└─ yamux server session
3133
└─ per-stream SOCKS5 handling
3234
├─ external Tor SOCKS5 byte bridge (`-socks host:port`)
3335
└─ internal SOCKS5 parser + direct relay dialer (`-socks internal`, tests only)
3436
```
35-
36-
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.
37+
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.
3738

3839
## Core invariants
3940

40-
- Target document navigations use encrypted `/p/<encrypted>#k=<key>` routes on the proxy origin.
41+
- Target document navigations use encrypted `/zp/p/<encrypted>#k=<key>&server=...` routes on the proxy origin.
4142
- The `#k` fragment is decrypted in the browser shell, removed with `history.replaceState`, and not sent to the server.
4243
- Every Service Worker-controlled request is classified. Unknown requests are blocked; there is no native `fetch(event.request)` fallback.
4344
- 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.
@@ -51,38 +52,38 @@ The relay server terminates only the browser WebSocket and yamux session. It use
5152
| Area | Files | Responsibility |
5253
|---|---|---|
5354
| Static shell | `web/index.html`, `web/zp-core.js` | Service Worker registration, target URL canonicalization, share URL encryption/decryption, initial target open. |
54-
| Share URL envelope | `web/zp-core.js`, `internal/shareurl/*` | Compatible JavaScript and Go implementations of `/p/<encrypted>#k=<key>` using AES-256-CBC, HMAC-SHA256, HKDF, and raw base64url. |
55-
| Service Worker | `web/sw.js` | Classifies every controlled request, blocks unknowns, manages in-memory tab/entry state, requires per-tab capability tokens on privileged runtime bridge messages, calls the WASM kernel, exposes runtime bridge APIs. |
56-
| 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` to satisfy strict proxy-origin CSP while preserving the Tor/yamux/uTLS transport path. Runtime membrane helpers (`__zp_get`, `__zp_set`, `__zp_call`, `__zp_construct`, `__zp_getOwnPropertyDescriptor`, `__zp_ownKeys`) and dynamic compilation wrappers execute `Function`/`eval`/string timer bodies under the virtual global scope. Patched function source strings, Canvas/Audio extraction, and speech voices receive limited self-fingerprint masking. |
55+
| Share URL envelope | `web/zp-core.js`, `internal/shareurl/*` | Compatible JavaScript and Go implementations of `/zp/p/<encrypted>#k=<key>` using AES-256-CBC, HMAC-SHA256, HKDF, raw base64url, and inherited relay-server fragments. |
56+
| 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. |
57+
| 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` to satisfy strict proxy-origin CSP while preserving the Tor/yamux/uTLS transport path. Runtime membrane helpers (`__zp_get`, `__zp_set`, `__zp_call`, `__zp_construct`, `__zp_getOwnPropertyDescriptor`, `__zp_ownKeys`) and dynamic compilation wrappers execute `Function`/`eval`/string timer bodies under the virtual global scope. Rust WASM rewrite helpers are loaded before runtime hooks so static and dynamic inline scripts can be rewritten or blocked fail-closed. |
5758
| WASM kernel | `cmd/wasm-kernel/main.go`, `internal/swhttp/*` | Converts JS `Request`/`Response`, initializes transport, owns target HTTP and WebSocket execution. |
5859
| 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. |
59-
| HTML/header/cookie policy | `internal/htmltx/*`, `internal/headers/*`, `internal/cookiejar/*`, `internal/zpiso/*` | HTML transformation, inline script/event-handler rewriting hook points, safe response header constructor policy, target cookie jar, Tor isolation token derivation. |
60-
| Relay server | `cmd/zeroproxy-server/main.go` | Serves 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. |
60+
| HTML/header/cookie policy | `internal/htmltx/*`, `internal/headers/*`, `internal/cookiejar/*`, `internal/zpiso/*` | HTML transformation, inline script/event-handler rewrite wrappers, safe response header constructor policy, target cookie jar, and relay-server inheritance metadata. |
61+
| 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. |
6162

6263
## Request flow
6364

64-
1. The shell registers `/sw.js`, waits for a controller, canonicalizes an `http:` or `https:` target, encrypts it, and navigates to `/p/<encrypted>#k=<key>` on the proxy origin.
65-
2. The shell loaded on `/p/<encrypted>#k=<key>` decrypts the fragment key in window context, validates the HMAC before decryption, removes `#k` with `history.replaceState`, and sends `ZP_OPEN_SHARE` to the Service Worker.
66-
3. The Service Worker stores the decrypted target in in-memory tab/entry maps and activates `/p/<encrypted>` as a proxy document route.
67-
4. A `/p/<encrypted>` document request is resolved back to the target URL. The Service Worker calls `__go_jshttp` with `X-ZP-*` internal metadata.
68-
5. The WASM kernel ensures one long-lived WebSocket connection to `/__zp/ws-pipe`, wraps it in a yamux client, and opens one yamux stream per target TCP connection.
65+
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/<encrypted>#k=<key>` on the proxy origin.
66+
2. The shell loaded on `/zp/p/<encrypted>#k=<key>` decrypts the fragment key in window context, validates the HMAC before decryption, normalizes repeated `server=` relay fragments, removes the fragment from the visible URL when policy allows, and sends `ZP_OPEN_SHARE` to the Service Worker.
67+
3. The Service Worker stores the decrypted target plus relay-server list in in-memory tab/entry maps and activates `/zp/p/<encrypted>` as a proxy document route.
68+
4. A `/zp/p/<encrypted>` 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.
69+
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.
6970
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.
7071
7. HTTPS fetch targets advertise `h2` and `http/1.1` through uTLS ALPN; target WebSocket connections advertise only `http/1.1`.
7172
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`.
7273
9. Redirects are followed inside the kernel so raw `Location` headers are not exposed to browser code.
73-
10. HTML document responses are transformed: runtime prelude is injected, document navigation URLs are rewritten to encrypted `/p/<encrypted>#k=<key>` routes, risky tags and headers are removed, and the browser receives a same-origin `Response` with ZeroProxy CSP.
74+
10. HTML document responses are transformed: Rust/JS rewrite assets plus runtime prelude are injected, document navigation URLs are rewritten to encrypted `/zp/p/<encrypted>#k=<key>` routes, risky tags and headers are removed, and the browser receives a same-origin `Response` with ZeroProxy CSP.
7475

7576
## Shared URL flow
7677

7778
Shared links use this envelope:
7879

7980
```text
80-
/p/<base64url(iv || AES-256-CBC(ciphertext) || HMAC-SHA256 tag)>#k=<base64url 64-byte seed>
81+
/zp/p/<base64url(iv || AES-256-CBC(ciphertext) || HMAC-SHA256 tag)>#k=<base64url 64-byte seed>&server=<wss relay>...
8182
```
8283

8384
`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:`.
8485

85-
The Go HTML transformer uses `internal/shareurl.New` when laundering document-navigation attributes, so transformed links/forms/frames keep using encrypted `/p` routes instead of legacy virtual URL paths.
86+
The Go HTML transformer uses `internal/shareurl.New` when laundering document-navigation attributes, so transformed links/forms/frames keep using encrypted `/zp/p` routes instead of legacy virtual URL paths.
8687

8788
## Service Worker classification
8889

cmd/wasm-kernel/main.go

Lines changed: 79 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"crypto/rand"
88
"encoding/base64"
9+
"encoding/json"
910
"fmt"
1011
"io"
1112
"net/http"
@@ -26,9 +27,10 @@ import (
2627
)
2728

2829
type Kernel struct {
29-
mu sync.Mutex
30-
engine *zphttp.Engine
31-
tabs map[string]*zphttp.TabState
30+
mu sync.Mutex
31+
engine *zphttp.Engine
32+
engineServer string
33+
tabs map[string]*zphttp.TabState
3234
}
3335

3436
func NewKernel() *Kernel { return &Kernel{tabs: make(map[string]*zphttp.TabState)} }
@@ -43,9 +45,10 @@ func main() {
4345
select {}
4446
}
4547

46-
func (k *Kernel) ensure(ctx context.Context) error {
48+
func (k *Kernel) ensure(ctx context.Context, servers []string) error {
49+
server := selectedRelayServer(servers)
4750
k.mu.Lock()
48-
ready := k.engine != nil
51+
ready := k.engine != nil && k.engineServer == server
4952
if ready {
5053
if closable, ok := k.engine.Mux.(interface{ IsClosed() bool }); ok && closable.IsClosed() {
5154
k.engine = nil
@@ -56,13 +59,7 @@ func (k *Kernel) ensure(ctx context.Context) error {
5659
if ready {
5760
return nil
5861
}
59-
loc := js.Global().Get("self").Get("location")
60-
proto := "ws:"
61-
if loc.Get("protocol").String() == "https:" {
62-
proto = "wss:"
63-
}
64-
raw := proto + "//" + loc.Get("host").String() + "/__zp/ws-pipe"
65-
conn, err := wsconn.Dial(ctx, raw)
62+
conn, err := wsconn.Dial(ctx, server)
6663
if err != nil {
6764
return err
6865
}
@@ -72,8 +69,9 @@ func (k *Kernel) ensure(ctx context.Context) error {
7269
return err
7370
}
7471
k.mu.Lock()
75-
if k.engine == nil {
72+
if k.engine == nil || k.engineServer != server {
7673
k.engine = &zphttp.Engine{Mux: sess}
74+
k.engineServer = server
7775
sess = nil
7876
}
7977
k.mu.Unlock()
@@ -87,7 +85,7 @@ func (k *Kernel) jsInit(this js.Value, args []js.Value) any {
8785
return promise(func(resolve, reject js.Value) {
8886
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
8987
defer cancel()
90-
if err := k.ensure(ctx); err != nil {
88+
if err := k.ensure(ctx, jsServers(args)); err != nil {
9189
reject.Invoke(jsError("TARGET_CONNECT_FAILED"))
9290
return
9391
}
@@ -124,7 +122,7 @@ func (k *Kernel) jsHTTP(this js.Value, args []js.Value) any {
124122
cancel()
125123
}
126124
}()
127-
if err := k.ensure(ctx); err != nil {
125+
if err := k.ensure(ctx, requestServers(reqv)); err != nil {
128126
resolve.Invoke(safeResponse("TARGET_CONNECT_FAILED", http.StatusBadGateway))
129127
return
130128
}
@@ -155,7 +153,7 @@ func (k *Kernel) jsHTTP(this js.Value, args []js.Value) any {
155153
}
156154
pr, pw := io.Pipe()
157155
go func() {
158-
err := htmltx.TransformTo(pw, source, htmltx.Options{TabID: tab.TabID, EntryID: req.Header.Get("X-Zp-Entry-Id"), TargetURL: finalURL, DocumentCookie: tab.CookieJar.DocumentCookie(finalURL), RuntimeToken: req.Header.Get("X-Zp-Runtime-Token"), RewriteScript: rewriteScript})
156+
err := htmltx.TransformTo(pw, source, htmltx.Options{TabID: tab.TabID, EntryID: req.Header.Get("X-Zp-Entry-Id"), TargetURL: finalURL, DocumentCookie: tab.CookieJar.DocumentCookie(finalURL), RuntimeToken: req.Header.Get("X-Zp-Runtime-Token"), Servers: headerServers(req.Header.Get("X-Zp-Relay-Servers")), RewriteScript: rewriteScript})
159157
closeErr := source.Close()
160158
if err != nil {
161159
_ = pw.CloseWithError(err)
@@ -200,7 +198,7 @@ func (k *Kernel) jsStream(this js.Value, args []js.Value) any {
200198
opts := args[0]
201199
return promise(func(resolve, reject js.Value) {
202200
ctx, cancel := context.WithCancel(context.Background())
203-
if err := k.ensure(ctx); err != nil {
201+
if err := k.ensure(ctx, jsServers(args)); err != nil {
204202
cancel()
205203
reject.Invoke(jsError("TARGET_CONNECT_FAILED"))
206204
return
@@ -234,6 +232,58 @@ func (k *Kernel) jsStream(this js.Value, args []js.Value) any {
234232
})
235233
}
236234

235+
func selectedRelayServer(servers []string) string {
236+
if len(servers) > 0 && servers[0] != "" {
237+
return servers[0]
238+
}
239+
loc := js.Global().Get("self").Get("location")
240+
proto := "ws:"
241+
if loc.Get("protocol").String() == "https:" {
242+
proto = "wss:"
243+
}
244+
return proto + "//" + loc.Get("host").String() + "/zp/ws-pipe"
245+
}
246+
247+
func jsServers(args []js.Value) []string {
248+
if len(args) == 0 {
249+
return nil
250+
}
251+
v := args[0]
252+
if v.IsUndefined() || v.IsNull() {
253+
return nil
254+
}
255+
return jsStringArray(v.Get("servers"))
256+
}
257+
258+
func requestServers(v js.Value) []string {
259+
if v.IsUndefined() || v.IsNull() {
260+
return nil
261+
}
262+
headers := v.Get("headers")
263+
if headers.IsUndefined() || headers.IsNull() || headers.Get("get").Type() != js.TypeFunction {
264+
return nil
265+
}
266+
raw := headers.Call("get", "X-ZP-Relay-Servers")
267+
if raw.IsUndefined() || raw.IsNull() {
268+
raw = headers.Call("get", "X-Zp-Relay-Servers")
269+
}
270+
if raw.IsUndefined() || raw.IsNull() {
271+
return nil
272+
}
273+
return headerServers(raw.String())
274+
}
275+
276+
func headerServers(raw string) []string {
277+
if raw == "" {
278+
return nil
279+
}
280+
var out []string
281+
if err := json.Unmarshal([]byte(raw), &out); err != nil {
282+
return nil
283+
}
284+
return out
285+
}
286+
237287
func (k *Kernel) tabFor(req *http.Request) *zphttp.TabState {
238288
return k.tabFromValues(req.Header.Get("X-Zp-Tab-Id"), req.Header.Get("X-Zp-Stream-Isolation-Key"))
239289
}
@@ -370,6 +420,18 @@ func safeResponse(code string, status int, host ...string) js.Value {
370420
return js.Global().Get("Response").New(body, map[string]any{"status": status, "headers": h})
371421
}
372422

423+
func rewriteScript(source, kind string) (string, bool) {
424+
r := js.Global().Get("__zp_rewrite_script")
425+
if r.IsUndefined() || r.IsNull() || r.Type() != js.TypeFunction {
426+
return "", false
427+
}
428+
code := r.Invoke(source, kind, "").String()
429+
if code == "" {
430+
return "", false
431+
}
432+
return code, true
433+
}
434+
373435
func htmlEscape(s string) string {
374436
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&#34;", "'", "&#39;").Replace(s)
375437
}
@@ -401,21 +463,6 @@ func isHTML(ct string) bool {
401463
return strings.Contains(strings.ToLower(ct), "text/html") || strings.Contains(strings.ToLower(ct), "application/xhtml")
402464
}
403465
func isDocumentRequest(req *http.Request) bool { return req.Header.Get("X-Zp-Document-Request") == "1" }
404-
func rewriteScript(source, kind string) (string, bool) {
405-
r := js.Global().Get("ZPRewriter")
406-
if r.IsUndefined() || r.IsNull() || r.Get("ready").Type() != js.TypeBoolean || !r.Get("ready").Bool() {
407-
return "", false
408-
}
409-
out := r.Call("rewriteScript", source, map[string]any{"kind": kind, "strict": true})
410-
if out.IsUndefined() || out.IsNull() || !out.Get("ok").Bool() {
411-
return "", false
412-
}
413-
code := out.Get("code")
414-
if code.Type() != js.TypeString {
415-
return "", false
416-
}
417-
return code.String(), true
418-
}
419466

420467
type closeWithSource struct {
421468
io.ReadCloser

0 commit comments

Comments
 (0)