Skip to content

Commit ddee152

Browse files
committed
feat(socks5): added internal SOCKS5 parser for local testing
- Implement internal SOCKS5 parser in relay server for direct target dialing in local tests. - Add postMessage hooks to preserve frame target origins across compartments. - Add stable test runner for consistent local and CI test execution. - Update README and ARCHITECTURE documentation for internal SOCKS5 mode. - Extend E2E test suite with additional proxy behavior and origin validation.
1 parent df0d58e commit ddee152

14 files changed

Lines changed: 907 additions & 113 deletions

File tree

ARCHITECTURE.md

Lines changed: 39 additions & 12 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
ZeroProxy is a client-owned virtual browsing prototype that runs target pages on the proxy origin without a browser extension. Its design goal is that target-site HTTP, TLS, and WebSocket traffic leaves only through this path:
44

55
```text
6-
Service Worker -> Go WASM kernel -> WebSocket/yamux -> Tor SOCKS5 -> uTLS -> HTTP/2 or HTTP/1.1
6+
Service Worker -> Go WASM kernel -> WebSocket/yamux -> SOCKS5 CONNECT -> uTLS -> HTTP/2 or HTTP/1.1
77
```
88

9-
The relay server terminates only the browser WebSocket/yamux pipe. Target HTTP parsing, redirects, cookies, header policy, HTML rewriting, and target WebSocket framing are owned by the Go WASM kernel and browser-side runtime.
9+
The relay server terminates only the browser WebSocket/yamux pipe. In production it byte-bridges each yamux stream to a Tor SOCKS5 listener; for local compatibility tests `-socks internal` makes the relay parse the kernel's SOCKS5 CONNECT itself and dial the requested target directly. Target HTTP parsing, redirects, cookies, header policy, HTML rewriting, and target WebSocket framing are owned by the Go WASM kernel and browser-side runtime.
1010

1111
## Status
1212

@@ -18,16 +18,16 @@ Implemented core spine:
1818
- AES-256-CBC + HMAC-SHA256 URL envelope with HKDF-separated encryption/MAC keys and HMAC verification before decryption.
1919
- Service Worker request classifier that handles every controlled request, blocks unknown requests instead of falling back to native `fetch(event.request)`, and requires a per-tab runtime capability token on privileged runtime bridge messages.
2020
- Go WASM exports: `__go_jshttp`, `__zp_stream`, `__zp_kernel_init`, and `__zp_cookie_set`.
21-
- A single browser WebSocket pipe carrying yamux streams to the relay server, then Tor SOCKS5 DOMAINNAME CONNECT, uTLS for HTTPS, HTTP/2 when ALPN selects `h2`, and HTTP/1.1 fallback/direct handling.
22-
- Tokenizer-based HTML transform that injects the runtime prelude, launders document navigation URLs through encrypted `/p` routes, drops dangerous tags and headers, and handles `srcdoc`.
21+
- A single browser WebSocket pipe carrying yamux streams to the relay server, then SOCKS5 DOMAINNAME CONNECT, uTLS for HTTPS, HTTP/2 when ALPN selects `h2`, and HTTP/1.1 fallback/direct handling. `-socks 127.0.0.1:9050` preserves the Tor bridge; `-socks internal` is a Tor-free development/test mode that parses SOCKS5 on the relay and dials targets directly from the relay process.
22+
- Tokenizer-based HTML transform that injects the runtime prelude, launders executable external scripts through `/__zp/api/script?u=...`, rewrites iframe/frame document URLs to encrypted `/p` routes, preserves author-visible anchor/form attributes for runtime navigation interception, drops dangerous tags and headers, strips executable event attributes through the JS rewriter, and handles `srcdoc`.
2323
- Runtime containment for main-window `fetch`, XHR, EventSource, WebSocket, `sendBeacon`, navigation, forms, history/location masking, storage facades, workers, iframes, and high-risk device/network APIs. Main-window and worker `fetch` paths are bridged through `/__zp/api/fetch` so strict `connect-src 'self'` does not block target API calls before the Service Worker can route them. Runtime-to-Service-Worker control messages carry a closure-held per-tab capability token. The runtime also applies basic self-fingerprint masking for patched function source strings, Canvas/Audio extraction jitter, and speech voice lists; broad anti-bot spoofing is not a project goal.
2424
- Phase 2 JavaScript rewriting is wired through an OXC parser/WASM service: target-response CSP no longer permits `connect-src *`, external and inline script sources are parsed before execution, dangerous global/window/location access is rewritten to runtime membrane helpers, parse/transform failures fail closed, and dynamic compilation paths such as `Function`, constructor-constructor escapes, `eval`, and string timers execute under the runtime's virtual global scope; blob/data worker scripts remain blocked when they cannot be rewritten synchronously.
2525
- Relay server static asset service and `/__zp/ws-pipe` WebSocket endpoint.
2626
- Go and JavaScript share URL implementations that use the same envelope format.
2727

2828
Not complete enough for production or high-assurance acceptance:
2929

30-
- Browser E2E tests cover the current iframe clean-realm and basic fingerprint-masking checks, but do not yet prove every worker, direct navigation, form, device API, and unclassified subresource non-escape path.
30+
- Browser E2E tests cover internal SOCKS5 relay mode, dynamic script laundering, compound location assignments, iframe postMessage delivery, iframe clean-realm containment, and basic fingerprint-masking checks, but do not yet prove every worker, direct navigation, form, device API, and unclassified subresource non-escape path.
3131
- Dynamic iframe containment is synchronous for `contentWindow`/`contentDocument` reads and common insertion APIs, but remains prototype-level and should keep gaining adversarial browser coverage.
3232
- Main-window runtime API compatibility is prototype-level for fetch, XHR, EventSource, WebSocket, uploads, descriptor edge cases, and fingerprinting surface fidelity.
3333
- Response bodies are streamed into JavaScript `Response` objects, but request/upload body handling and browser backpressure/cancellation behavior are still prototype-level.
@@ -41,7 +41,7 @@ See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the implementation map and accept
4141
- Go 1.26 or the Go toolchain version required by `go.mod`.
4242
- Node.js LTS and npm for the JavaScript and Puppeteer E2E tests.
4343
- A browser with Service Worker and WebAssembly support. CI uses Puppeteer's pinned Chrome for Testing.
44-
- A Tor SOCKS5 listener configured with stream isolation for manual target browsing. CI uses an in-process test SOCKS5 proxy instead of Tor.
44+
- A Tor SOCKS5 listener configured with stream isolation for anonymized manual target browsing. Tor is not required for the automated Puppeteer suite or local compatibility checks that run the relay with `-socks internal`.
4545

4646
Example Tor setting:
4747

@@ -58,6 +58,14 @@ tor --SocksPort "127.0.0.1:9050 IsolateSOCKSAuth" --DataDirectory /tmp/zeroproxy
5858

5959
Keep that process running and wait until Tor logs `Bootstrapped 100% (done)` before expecting target browsing to work. If Tor is managed by your OS service manager instead, use the same `SocksPort` setting in `torrc` and start the service before starting ZeroProxy.
6060

61+
For Tor-free local compatibility testing, start ZeroProxy with the internal relay SOCKS5 parser instead of starting Tor:
62+
63+
```sh
64+
./dist/zeroproxy-server -addr :8080 -socks internal
65+
```
66+
67+
Internal mode is not an anonymity mode: target TCP connections are direct dials from the relay process. It exists so CI and local browser compatibility tests can exercise the browser → Service Worker → WASM → WebSocket/yamux → SOCKS5 parsing pipeline without an external proxy daemon.
68+
6169
## Build and run locally
6270

6371
Build the browser bundle, Go WASM kernel, and relay server from the repository root:
@@ -92,15 +100,15 @@ Server flags:
92100
- `-addr`: HTTP listen address. Default: `:8080`.
93101
- `-web`: built static web asset directory containing `index.html`, `sw.js`, and `/__zp/*` assets. Default: `dist/web`.
94102
- `-kernel`: compiled Go WASM kernel served at `/__zp/kernel.wasm`. Default: `dist/kernel.wasm`.
95-
- `-socks`: Tor SOCKS5 address. Default: `127.0.0.1:9050`.
103+
- `-socks`: Tor SOCKS5 address, or `internal` for the relay's built-in SOCKS5 CONNECT parser/direct dialer used by tests. Default: `127.0.0.1:9050`.
96104

97105
Open the browser shell on the proxy origin:
98106

99107
```text
100108
http://proxy.localhost:8080/
101109
```
102110

103-
Use `proxy.localhost` from the start so the shell, Service Worker, and encrypted `/p/<encrypted>#k=<key>` routes share one origin. The server starts even if Tor is not reachable, but target browsing needs the configured Tor SOCKS5 listener.
111+
Use `proxy.localhost` from the start so the shell, Service Worker, and encrypted `/p/<encrypted>#k=<key>` routes share one origin. The server starts even if Tor is not reachable. Target browsing needs either a configured Tor SOCKS5 listener or the explicit non-anonymous `-socks internal` test mode.
104112

105113
## Verification commands
106114

@@ -113,11 +121,11 @@ npm test
113121
npm run build
114122
```
115123

116-
`npm test` runs both JavaScript source-policy tests and the Puppeteer E2E suite. The E2E test builds temporary ZeroProxy artifacts with `scripts/build.mjs`, starts a local target HTTP server, starts an in-process SOCKS5 proxy, launches Puppeteer's Chrome, and verifies browser traffic through the ZeroProxy server without requiring Tor.
124+
`npm test` runs both JavaScript source-policy tests and the Puppeteer E2E suite. The E2E test builds temporary ZeroProxy artifacts with `scripts/build.mjs`, starts a local target HTTP server, starts the relay with `-socks internal`, launches Puppeteer's Chrome, and verifies browser traffic through the ZeroProxy server without requiring Tor.
117125

118126
CI is defined in `.github/workflows/ci.yml` and runs on pushes to `main`, pull requests, and manual dispatch. It uses an Ubuntu 24.04 LTS runner, installs Go from `go.mod`, installs the current Node.js LTS release, runs `npm ci`, runs the Go and full JavaScript/Puppeteer test suites, and builds deployable `dist/` artifacts.
119127

120-
These checks cover source/unit policy invariants, buildability, and a local-browser E2E path through a test SOCKS5 proxy. They do not start Tor, validate real Tor deployment behavior, or prove production traffic compatibility.
128+
These checks cover source/unit policy invariants, buildability, and a local-browser E2E path through the relay's internal SOCKS5 parser/direct dialer. They do not start Tor, validate real Tor deployment behavior, or prove production traffic compatibility.
121129

122130
## Repository map
123131

@@ -127,8 +135,9 @@ These checks cover source/unit policy invariants, buildability, and a local-brow
127135
| `web/sw.js` | Service Worker classifier, in-memory tab state, runtime API bridge, WASM kernel calls. |
128136
| `web/runtime-prelude.js`, `web/worker-prelude.js` | Target-realm containment hooks and worker bootstrap. |
129137
| `scripts/build.mjs` | Full build pipeline for browser bundles, generated WASM support assets, Go WASM kernel, and relay server. |
138+
| `scripts/test.mjs` | Stable local/CI test runner that executes JavaScript policy tests and the Puppeteer E2E suite in sequence. |
130139
| `cmd/wasm-kernel` | Go WASM transport kernel exposed to the Service Worker. |
131-
| `cmd/zeroproxy-server` | Static asset server and Gorilla WebSocket/yamux-to-Tor relay. |
140+
| `cmd/zeroproxy-server` | Static asset server and Gorilla WebSocket/yamux relay to Tor SOCKS5 or the `-socks internal` direct-dial SOCKS5 parser. |
132141
| `internal/zphttp`, `internal/socks5`, `internal/utlskernel`, `internal/wsproto`, `internal/yamuxconn`, `internal/wsconn` | Target transport path. |
133142
| `internal/htmltx`, `internal/headers`, `internal/cookiejar`, `internal/shareurl`, `internal/zpiso` | HTML rewriting, response header policy, cookie handling, share URL envelope, Tor isolation tokens. |
134143
| `test/js`, `test/e2e`, `internal/*/*_test.go` | JavaScript source-policy tests, Puppeteer browser E2E tests, and Go unit tests. |

cmd/zeroproxy-server/main.go

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/binary"
56
"errors"
67
"flag"
78
"fmt"
@@ -27,13 +28,15 @@ type server struct {
2728
socksAddr string
2829
}
2930

31+
const internalSOCKSMode = "internal"
32+
3033
func main() {
3134
var addr string
3235
s := &server{}
3336
flag.StringVar(&addr, "addr", ":8080", "HTTP listen address")
3437
flag.StringVar(&s.webDir, "web", "dist/web", "built static web asset directory")
3538
flag.StringVar(&s.kernelWASM, "kernel", "dist/kernel.wasm", "compiled Go WASM kernel path")
36-
flag.StringVar(&s.socksAddr, "socks", "127.0.0.1:9050", "Tor SOCKS5 address configured with IsolateSOCKSAuth")
39+
flag.StringVar(&s.socksAddr, "socks", "127.0.0.1:9050", "Tor SOCKS5 address with IsolateSOCKSAuth, or 'internal' for the built-in test SOCKS5 parser/direct dialer")
3740
flag.Parse()
3841
mux := http.NewServeMux()
3942
mux.HandleFunc("/", s.handle)
@@ -235,8 +238,167 @@ func (s *server) acceptStreams(ctx context.Context, sess *yamuxconn.Session) {
235238
if err != nil {
236239
return
237240
}
238-
go s.bridgeToTor(ctx, stream)
241+
go s.bridgeTargetStream(ctx, stream)
242+
}
243+
}
244+
245+
func (s *server) bridgeTargetStream(ctx context.Context, stream net.Conn) {
246+
if strings.EqualFold(strings.TrimSpace(s.socksAddr), internalSOCKSMode) {
247+
s.bridgeInternalSOCKS(ctx, stream)
248+
return
249+
}
250+
s.bridgeToTor(ctx, stream)
251+
}
252+
253+
func (s *server) bridgeInternalSOCKS(ctx context.Context, stream net.Conn) {
254+
defer stream.Close()
255+
stopDeadline := make(chan struct{})
256+
go func() {
257+
select {
258+
case <-ctx.Done():
259+
_ = stream.SetDeadline(time.Now())
260+
case <-stopDeadline:
261+
}
262+
}()
263+
host, port, err := readSOCKS5Connect(ctx, stream)
264+
close(stopDeadline)
265+
if err != nil {
266+
return
267+
}
268+
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
269+
target, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
270+
if err != nil {
271+
_, _ = stream.Write([]byte{0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
272+
return
273+
}
274+
if _, err := stream.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
275+
_ = target.Close()
276+
return
277+
}
278+
bridgeConns(ctx, stream, target)
279+
}
280+
281+
func readSOCKS5Connect(ctx context.Context, rw net.Conn) (string, string, error) {
282+
var head [2]byte
283+
if err := readFull(ctx, rw, head[:]); err != nil {
284+
return "", "", err
285+
}
286+
if head[0] != 0x05 || head[1] == 0 {
287+
return "", "", fmt.Errorf("invalid SOCKS5 greeting")
288+
}
289+
methods := make([]byte, int(head[1]))
290+
if err := readFull(ctx, rw, methods); err != nil {
291+
return "", "", err
292+
}
293+
method := byte(0xff)
294+
for _, m := range methods {
295+
if m == 0x02 {
296+
method = 0x02
297+
break
298+
}
299+
if m == 0x00 {
300+
method = 0x00
301+
}
302+
}
303+
if _, err := rw.Write([]byte{0x05, method}); err != nil {
304+
return "", "", err
305+
}
306+
if method == 0xff {
307+
return "", "", fmt.Errorf("no acceptable SOCKS5 auth method")
308+
}
309+
if method == 0x02 {
310+
if err := acceptSOCKS5UserPass(ctx, rw); err != nil {
311+
return "", "", err
312+
}
313+
}
314+
var req [4]byte
315+
if err := readFull(ctx, rw, req[:]); err != nil {
316+
return "", "", err
317+
}
318+
if req[0] != 0x05 || req[1] != 0x01 || req[2] != 0x00 {
319+
return "", "", fmt.Errorf("unsupported SOCKS5 request")
320+
}
321+
host, err := readSOCKS5Address(ctx, rw, req[3])
322+
if err != nil {
323+
return "", "", err
324+
}
325+
var portBuf [2]byte
326+
if err := readFull(ctx, rw, portBuf[:]); err != nil {
327+
return "", "", err
328+
}
329+
port := binary.BigEndian.Uint16(portBuf[:])
330+
if port == 0 {
331+
return "", "", fmt.Errorf("invalid SOCKS5 port")
332+
}
333+
return host, fmt.Sprint(port), nil
334+
}
335+
336+
func acceptSOCKS5UserPass(ctx context.Context, rw net.Conn) error {
337+
var head [2]byte
338+
if err := readFull(ctx, rw, head[:]); err != nil {
339+
return err
340+
}
341+
if head[0] != 0x01 {
342+
_, _ = rw.Write([]byte{0x01, 0x01})
343+
return fmt.Errorf("invalid SOCKS5 auth version")
344+
}
345+
user := make([]byte, int(head[1]))
346+
if err := readFull(ctx, rw, user); err != nil {
347+
return err
348+
}
349+
var passLen [1]byte
350+
if err := readFull(ctx, rw, passLen[:]); err != nil {
351+
return err
352+
}
353+
pass := make([]byte, int(passLen[0]))
354+
if err := readFull(ctx, rw, pass); err != nil {
355+
return err
356+
}
357+
_, err := rw.Write([]byte{0x01, 0x00})
358+
return err
359+
}
360+
361+
func readSOCKS5Address(ctx context.Context, rw net.Conn, atyp byte) (string, error) {
362+
switch atyp {
363+
case 0x01:
364+
var ip [4]byte
365+
if err := readFull(ctx, rw, ip[:]); err != nil {
366+
return "", err
367+
}
368+
return net.IP(ip[:]).String(), nil
369+
case 0x03:
370+
var n [1]byte
371+
if err := readFull(ctx, rw, n[:]); err != nil {
372+
return "", err
373+
}
374+
if n[0] == 0 {
375+
return "", fmt.Errorf("empty SOCKS5 domain")
376+
}
377+
host := make([]byte, int(n[0]))
378+
if err := readFull(ctx, rw, host); err != nil {
379+
return "", err
380+
}
381+
return string(host), nil
382+
case 0x04:
383+
var ip [16]byte
384+
if err := readFull(ctx, rw, ip[:]); err != nil {
385+
return "", err
386+
}
387+
return net.IP(ip[:]).String(), nil
388+
default:
389+
return "", fmt.Errorf("unsupported SOCKS5 address type")
390+
}
391+
}
392+
393+
func readFull(ctx context.Context, r io.Reader, p []byte) error {
394+
if err := ctx.Err(); err != nil {
395+
return err
396+
}
397+
_, err := io.ReadFull(r, p)
398+
if err != nil {
399+
return err
239400
}
401+
return ctx.Err()
240402
}
241403

242404
func (s *server) bridgeToTor(ctx context.Context, stream net.Conn) {
@@ -246,14 +408,18 @@ func (s *server) bridgeToTor(ctx context.Context, stream net.Conn) {
246408
_ = stream.Close()
247409
return
248410
}
411+
bridgeConns(ctx, stream, tor)
412+
}
413+
414+
func bridgeConns(ctx context.Context, a, b net.Conn) {
249415
closeBoth := func() {
250-
_ = stream.Close()
251-
_ = tor.Close()
416+
_ = a.Close()
417+
_ = b.Close()
252418
}
253419
defer closeBoth()
254420
done := make(chan struct{}, 2)
255-
go func() { _, _ = io.Copy(tor, stream); closeBoth(); done <- struct{}{} }()
256-
go func() { _, _ = io.Copy(stream, tor); closeBoth(); done <- struct{}{} }()
421+
go func() { _, _ = io.Copy(b, a); closeBoth(); done <- struct{}{} }()
422+
go func() { _, _ = io.Copy(a, b); closeBoth(); done <- struct{}{} }()
257423
select {
258424
case <-ctx.Done():
259425
closeBoth()

0 commit comments

Comments
 (0)