Skip to content

Commit eb649cb

Browse files
committed
up
1 parent 66c17b9 commit eb649cb

3 files changed

Lines changed: 270 additions & 24 deletions

File tree

docs/1.guide/7.proxy.md

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,34 @@ Every incoming peer opens a matching upstream connection. Text and binary messag
3030
> [!TIP]
3131
> Messages sent by the client before the upstream connection is ready are buffered and flushed as soon as the upstream is open.
3232
33+
> [!CAUTION]
34+
> **The default proxy is an open relay.** It accepts every incoming connection and forwards it to the configured upstream without any authorization check. Always combine it with an [`upgrade` hook](#authentication) when the upstream is not itself publicly accessible — otherwise anyone who can reach the proxy can reach the upstream.
35+
36+
## Authentication
37+
38+
`createWebSocketProxy()` returns a plain hooks object, so you can spread it and override individual hooks. Authenticate the upgrade request before proxying by wrapping the proxy's `upgrade` hook:
39+
40+
```ts
41+
import { createWebSocketProxy } from "crossws";
42+
43+
const proxyHooks = createWebSocketProxy("wss://backend.example.com");
44+
45+
const hooks = {
46+
...proxyHooks,
47+
async upgrade(req) {
48+
const token = req.headers.get("authorization");
49+
if (!(await isValidToken(token))) {
50+
return new Response("Unauthorized", { status: 401 });
51+
}
52+
// Delegate to the proxy's own `upgrade` so subprotocol echoing still works.
53+
return proxyHooks.upgrade?.(req);
54+
},
55+
};
56+
```
57+
58+
> [!NOTE]
59+
> The WHATWG `WebSocket` constructor cannot forward cookies, `Authorization`, or `Origin` to the upstream, so upstream identity checks relying on those headers will silently fail. Authenticate at the proxy, or pass a custom `WebSocket` client and use the [`headers` option](#forwarding-headers) to propagate identity.
60+
3361
## Dynamic target
3462

3563
Pass a function to resolve the upstream URL from the incoming [`Peer`](/guide/peer) — useful for routing based on request URL, headers, or authenticated context.
@@ -47,6 +75,9 @@ const hooks = createWebSocketProxy({
4775
});
4876
```
4977

78+
> [!WARNING]
79+
> **SSRF risk.** A dynamic `target` resolver is a trust boundary. Never interpolate untrusted input (query strings, headers, path segments a client controls) directly into the returned URL — a naive resolver turns the proxy into an SSRF primitive that can dial `ws://127.0.0.1`, `ws://169.254.169.254`, or any reachable internal service. Always resolve against a hard-coded allowlist of hosts you control.
80+
5081
## Subprotocol negotiation
5182

5283
By default, the proxy forwards the client's `sec-websocket-protocol` header to the upstream and echoes the first requested subprotocol back in the upgrade response so the client handshake succeeds. Disable this if you want to negotiate subprotocols yourself:
@@ -75,35 +106,51 @@ const hooks = createWebSocketProxy({
75106
});
76107
```
77108

78-
## Combining with custom hooks
109+
### Unix domain sockets
79110

80-
`createWebSocketProxy()` returns a plain hooks object, so you can spread it and override individual hooks — for example, to authenticate the upgrade request before proxying:
111+
The proxy does not enforce any scheme allowlist — whatever the configured `WebSocket` constructor accepts is accepted. For example, the [`ws`](https://github.com/websockets/ws) package supports Unix domain sockets via its `ws+unix:` scheme:
81112

82113
```ts
114+
import { WebSocket } from "ws";
83115
import { createWebSocketProxy } from "crossws";
84116

85-
const proxyHooks = createWebSocketProxy("wss://backend.example.com");
117+
const hooks = createWebSocketProxy({
118+
target: "ws+unix:/var/run/backend.sock:/chat",
119+
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
120+
});
121+
```
86122

87-
const hooks = {
88-
...proxyHooks,
89-
upgrade(req) {
90-
if (!req.headers.get("authorization")) {
91-
return new Response("Unauthorized", { status: 401 });
92-
}
93-
return proxyHooks.upgrade?.(req);
94-
},
95-
};
123+
## Forwarding headers
124+
125+
Passing a `headers` option attaches extra headers to the upstream handshake. This is the usual way to forward identity (`cookie`, `authorization`, `origin`) or inject a shared secret to the upstream.
126+
127+
```ts
128+
import { WebSocket } from "ws";
129+
import { createWebSocketProxy } from "crossws";
130+
131+
const hooks = createWebSocketProxy({
132+
target: "wss://backend.example.com",
133+
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
134+
headers: (peer) => ({
135+
cookie: peer.request.headers.get("cookie") ?? "",
136+
"x-forwarded-for": peer.remoteAddress ?? "",
137+
}),
138+
});
96139
```
97140

141+
> [!IMPORTANT]
142+
> The WHATWG global `WebSocket` constructor does **not** accept custom headers. `headers` is only honored when a `WebSocket` constructor that takes a third options argument is passed via the [`WebSocket` option](#custom-websocket-constructor) — e.g. [`ws`](https://github.com/websockets/ws) or [`undici`](https://undici.nodejs.org). With the global constructor the option is silently ignored.
143+
98144
## API
99145

100146
### `createWebSocketProxy(target)`
101147

102148
Accepts either a target URL (`string` or `URL`), a resolver function, or an options object:
103149

104-
- **`target`**`string | URL | (peer: Peer) => string | URL`. The upstream WebSocket URL, or a function that resolves it per peer.
105-
- **`forwardProtocol`**`boolean` (default `true`). When enabled, the client's `sec-websocket-protocol` header is forwarded to the upstream and echoed back in the upgrade response.
106-
- **`maxBufferSize`**`number` (default `1048576`, i.e. 1 MiB). Maximum number of bytes buffered per peer while the upstream is still connecting. When exceeded, the peer is closed with code `1009` (Message Too Big). Set to `0` to disable.
150+
- **`target`**`string | URL | (peer: Peer) => string | URL`. The upstream WebSocket URL, or a function that resolves it per peer. The proxy does not enforce a scheme allowlist; any URL the configured `WebSocket` constructor accepts (including `ws+unix:` with `ws`) works. See the [SSRF warning](#dynamic-target) before using a dynamic resolver.
151+
- **`forwardProtocol`**`boolean` (default `true`). When enabled, the client's `sec-websocket-protocol` header is forwarded to the upstream and echoed back in the upgrade response. Values that are not valid RFC 7230 tokens are dropped.
152+
- **`headers`**`HeadersInit | (peer: Peer) => HeadersInit`. Extra headers to send on the upstream handshake. Only honored when a custom `WebSocket` constructor that accepts a third options argument is supplied — the WHATWG global ignores it.
153+
- **`maxBufferSize`**`number` (default `1048576`, i.e. 1 MiB). Maximum number of bytes buffered per peer while the upstream is still connecting. String frames are accounted at their UTF-8 worst case (3 bytes per UTF-16 code unit) to avoid undercounting multi-byte content. When exceeded, the peer is closed with code `1009` (Message Too Big). Set to `0` to disable.
107154
- **`connectTimeout`**`number` (default `10000`). Milliseconds to wait for the upstream WebSocket handshake to complete. If exceeded, the peer is closed with code `1011`. Set to `0` to disable.
108155
- **`WebSocket`**`typeof WebSocket` (default `globalThis.WebSocket`). Custom `WebSocket` constructor used to dial the upstream. Falls back to the global when omitted; throws at setup time if neither is available.
109156

src/proxy.ts

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ const DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024;
88
// 10 seconds — aligns with common reverse-proxy defaults (nginx, haproxy).
99
const DEFAULT_CONNECT_TIMEOUT = 10_000;
1010

11+
// RFC 7230 `token` grammar — the on-wire form of a WebSocket subprotocol
12+
// per RFC 6455 §4.1. Used to validate values we echo back in the upgrade
13+
// response so client-controlled input can't coerce unexpected header content.
14+
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
15+
1116
export interface WebSocketProxyOptions {
1217
/**
1318
* Target WebSocket URL to proxy to (`ws://` or `wss://`).
@@ -51,6 +56,34 @@ export interface WebSocketProxyOptions {
5156
* @default globalThis.WebSocket
5257
*/
5358
WebSocket?: typeof WebSocket;
59+
60+
/**
61+
* Extra headers to send on the upstream handshake. Can be a static
62+
* object or a resolver called per peer.
63+
*
64+
* Useful to forward identity from the incoming request (`cookie`,
65+
* `authorization`, `origin`), or to inject a shared secret the
66+
* upstream expects.
67+
*
68+
* > [!NOTE]
69+
* > The WHATWG global `WebSocket` constructor does not accept custom
70+
* > headers — this option is only honored by `WebSocket` constructors
71+
* > that take a third options argument (e.g. `ws`, `undici`). Pass
72+
* > one via the {@link WebSocket} option to use it.
73+
*
74+
* @example
75+
* ```ts
76+
* createWebSocketProxy({
77+
* target: "wss://backend.example.com",
78+
* WebSocket: WsFromNodeWs,
79+
* headers: (peer) => ({
80+
* cookie: peer.request.headers.get("cookie") ?? "",
81+
* "x-forwarded-for": peer.remoteAddress ?? "",
82+
* }),
83+
* });
84+
* ```
85+
*/
86+
headers?: HeadersInit | ((peer: Peer) => HeadersInit | undefined | void);
5487
}
5588

5689
/**
@@ -92,15 +125,42 @@ export function createWebSocketProxy(
92125
// Accept the first requested subprotocol so the upgrade handshake
93126
// echoes a value the client expects. Upstream must support it too.
94127
const accepted = reqProtocol.split(",")[0]!.trim();
128+
// Defense-in-depth: only echo RFC 7230 tokens. The Fetch `Headers`
129+
// API already rejects CRLF, but restricting to the subprotocol
130+
// grammar ensures no other client-controlled bytes can land in a
131+
// response header — even under buggy or custom header writers.
132+
if (!TOKEN_RE.test(accepted)) {
133+
return;
134+
}
95135
return { headers: { "sec-websocket-protocol": accepted } };
96136
},
97137

98138
open(peer) {
99-
const url = _resolveTarget(options.target, peer);
100-
const protocols = _resolveProtocols(peer, options.forwardProtocol);
101-
102-
const ws = new WebSocketCtor(url, protocols);
103-
ws.binaryType = "arraybuffer";
139+
let ws: WebSocket;
140+
try {
141+
const url = _resolveTarget(options.target, peer);
142+
const protocols = _resolveProtocols(peer, options.forwardProtocol);
143+
const wsOptions = _resolveWsOptions(options.headers, peer);
144+
// The WHATWG WebSocket constructor only takes (url, protocols);
145+
// additional arguments are ignored. Custom clients like `ws` and
146+
// `undici` accept a third options object where `headers` is
147+
// honored — so always pass it when the user configured headers.
148+
ws = wsOptions
149+
? new (WebSocketCtor as unknown as new (
150+
url: URL,
151+
protocols: string[] | undefined,
152+
opts: { headers: HeadersInit },
153+
) => WebSocket)(url, protocols, wsOptions)
154+
: new WebSocketCtor(url, protocols);
155+
ws.binaryType = "arraybuffer";
156+
} catch {
157+
// Bad target URL, disallowed scheme, invalid subprotocol token,
158+
// or a throwing custom resolver — close the peer with a
159+
// generic internal-error code rather than letting the exception
160+
// escape the hook.
161+
_safeClose(peer, 1011, "Upstream setup failed");
162+
return;
163+
}
104164

105165
const state: UpstreamState = {
106166
ws,
@@ -165,7 +225,13 @@ export function createWebSocketProxy(
165225
}
166226
return;
167227
}
168-
const size = typeof raw === "string" ? raw.length : raw.byteLength;
228+
// Strings become UTF-8 on the wire: a UTF-16 code unit encodes to
229+
// at most 3 UTF-8 bytes (surrogate pairs use 4 bytes spread across
230+
// 2 code units, so the per-unit worst case still bounds at 3).
231+
// Use the upper bound to keep the check O(1) while guaranteeing
232+
// the buffered payload can't exceed the configured limit on the
233+
// wire, even for multi-byte content.
234+
const size = typeof raw === "string" ? raw.length * 3 : raw.byteLength;
169235
const limit = options.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE;
170236
if (limit > 0 && state.bufferSize + size > limit) {
171237
_cleanupState(upstreams, peer.id, state);
@@ -247,6 +313,16 @@ function _resolveTarget(
247313
return raw instanceof URL ? raw : new URL(raw);
248314
}
249315

316+
function _resolveWsOptions(
317+
headers: WebSocketProxyOptions["headers"],
318+
peer: Peer,
319+
): { headers: HeadersInit } | undefined {
320+
if (!headers) return;
321+
const resolved = typeof headers === "function" ? headers(peer) : headers;
322+
if (!resolved) return;
323+
return { headers: resolved };
324+
}
325+
250326
function _resolveProtocols(
251327
peer: Peer,
252328
forwardProtocol: boolean | undefined,

test/proxy.test.ts

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,14 +237,137 @@ describe("createWebSocketProxy", () => {
237237
ws.ws.addEventListener("close", (e) => resolve(e as CloseEvent));
238238
});
239239
// Upstream has a 100ms upgrade delay, so these messages queue in the
240-
// proxy's buffer before the upstream connection opens.
241-
await ws.send("aaaaa"); // 5 bytes — fills the limit exactly
242-
await ws.send("bbbbb"); // 5 more bytes — exceeds
240+
// proxy's buffer before the upstream connection opens. The proxy
241+
// accounts strings at their UTF-8 worst case (3 bytes per code unit)
242+
// so any non-empty frame exceeds the 5-byte limit configured above.
243+
await ws.send("aaaaa");
243244
const event = await closed;
244245
expect(event.code).toBe(1009);
245246
});
246247
});
247248

249+
describe("createWebSocketProxy unit hooks", () => {
250+
test("echoes valid subprotocol tokens in upgrade response", () => {
251+
const hooks = createWebSocketProxy("ws://localhost/");
252+
const req = new Request("http://localhost/", {
253+
headers: { "sec-websocket-protocol": "chat" },
254+
});
255+
const result = hooks.upgrade?.(req);
256+
expect(result).toMatchObject({
257+
headers: { "sec-websocket-protocol": "chat" },
258+
});
259+
});
260+
261+
test("drops subprotocol values that are not RFC 7230 tokens", () => {
262+
// Defense-in-depth: even if a buggy runtime lets a client smuggle a
263+
// non-token character into the header, the proxy must not echo it
264+
// into the upgrade response.
265+
const hooks = createWebSocketProxy("ws://localhost/");
266+
for (const bad of ["a/b", "has space", "semi;colon", "ctl\u0001"]) {
267+
const req = new Request("http://localhost/", {
268+
headers: { "sec-websocket-protocol": bad },
269+
});
270+
expect(hooks.upgrade?.(req)).toBeUndefined();
271+
}
272+
});
273+
274+
test("passes headers option through to custom WebSocket constructor", () => {
275+
const calls: Array<{
276+
url: unknown;
277+
protocols: unknown;
278+
options: unknown;
279+
}> = [];
280+
class StubWS extends EventTarget {
281+
binaryType = "arraybuffer";
282+
readyState = 0;
283+
constructor(url: unknown, protocols: unknown, options?: unknown) {
284+
super();
285+
calls.push({ url, protocols, options });
286+
}
287+
send(): void {}
288+
close(): void {}
289+
}
290+
const hooks = createWebSocketProxy({
291+
target: "ws://upstream.invalid/",
292+
WebSocket: StubWS as unknown as typeof WebSocket,
293+
connectTimeout: 0,
294+
headers: (peer) => ({
295+
cookie: peer.request?.headers.get("cookie") ?? "",
296+
"x-trace": "t1",
297+
}),
298+
});
299+
const peer = {
300+
id: "p-headers",
301+
request: new Request("http://localhost/", {
302+
headers: { cookie: "sid=abc" },
303+
}),
304+
close() {},
305+
send() {},
306+
};
307+
hooks.open?.(peer as never);
308+
expect(calls).toHaveLength(1);
309+
expect(calls[0]!.options).toEqual({
310+
headers: { cookie: "sid=abc", "x-trace": "t1" },
311+
});
312+
});
313+
314+
test("closes peer with 1011 when WebSocket constructor rejects the target", async () => {
315+
// The WHATWG `WebSocket` constructor throws `SyntaxError` for any
316+
// scheme other than `ws:`/`wss:`. The open hook must catch that
317+
// and close the peer instead of letting the exception escape.
318+
const badAdapter = nodeAdapter({
319+
hooks: createWebSocketProxy({
320+
target: () => new URL("http://localhost:1/"),
321+
}),
322+
});
323+
const server = createServer((_req, res) => res.end("ok"));
324+
server.on("upgrade", badAdapter.handleUpgrade);
325+
const port = await getRandomPort("localhost");
326+
await new Promise<void>((resolve) => server.listen(port, resolve));
327+
await waitForPort(port);
328+
try {
329+
const ws = await wsConnect(`ws://localhost:${port}/`);
330+
const event = await new Promise<CloseEvent>((resolve) => {
331+
ws.ws.addEventListener("close", (e) => resolve(e as CloseEvent));
332+
});
333+
expect(event.code).toBe(1011);
334+
} finally {
335+
server.closeAllConnections?.();
336+
server.close();
337+
}
338+
});
339+
340+
test("passes ws+unix targets through to a custom WebSocket client", () => {
341+
// No built-in scheme validation: anything the custom constructor
342+
// accepts (e.g. the `ws+unix:` syntax supported by `ws`) works.
343+
const calls: unknown[] = [];
344+
class StubWS extends EventTarget {
345+
binaryType = "arraybuffer";
346+
readyState = 0;
347+
constructor(url: unknown) {
348+
super();
349+
calls.push(url);
350+
}
351+
send(): void {}
352+
close(): void {}
353+
}
354+
const hooks = createWebSocketProxy({
355+
target: "ws+unix:/tmp/sock:/",
356+
WebSocket: StubWS as unknown as typeof WebSocket,
357+
connectTimeout: 0,
358+
});
359+
const peer = {
360+
id: "p-unix",
361+
request: new Request("http://localhost/"),
362+
close() {},
363+
send() {},
364+
};
365+
hooks.open?.(peer as never);
366+
expect(calls).toHaveLength(1);
367+
expect(String(calls[0])).toContain("ws+unix:");
368+
});
369+
});
370+
248371
describe("createWebSocketProxy internals", () => {
249372
test("_normalizeOutgoingCode allows 1000 and 3000-4999 range", () => {
250373
// state.ws is a client-side WebSocket; WHATWG forbids anything else.

0 commit comments

Comments
 (0)