feat(gateway): forward application identity on WebSocket upgrades
Adds gatewayWebSocketBackendHeaders so proxied upgrades carry application identity while Bun keeps ownership of WebSocket framing. Pre-existing working-tree change, committed as-is rather than authored here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,7 @@ interface Target extends GatewayApp {
|
|||||||
interface WsBridge {
|
interface WsBridge {
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
backend?: WebSocket;
|
backend?: WebSocket;
|
||||||
queue: Array<string | ArrayBuffer>;
|
queue: Array<string | ArrayBuffer>;
|
||||||
maxMessageBytes: number;
|
maxMessageBytes: number;
|
||||||
@@ -433,6 +434,30 @@ export function gatewayProxyHeaders(
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forward application identity to the child while leaving WebSocket framing to Bun. */
|
||||||
|
export function gatewayWebSocketBackendHeaders(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
ip: string,
|
||||||
|
forwardedHeaders: boolean,
|
||||||
|
backendOrigin: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers = stripUntrustedInternalHeaders(
|
||||||
|
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
||||||
|
);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("connection");
|
||||||
|
headers.delete("upgrade");
|
||||||
|
headers.delete("accept-encoding");
|
||||||
|
for (const name of [...headers.keys()]) {
|
||||||
|
if (name.startsWith("sec-websocket-")) headers.delete(name);
|
||||||
|
}
|
||||||
|
// The public origin was validated at the gateway edge. The child receives a
|
||||||
|
// new, trusted same-origin connection from its private gateway listener.
|
||||||
|
headers.set("origin", backendOrigin);
|
||||||
|
return Object.fromEntries(headers);
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove headers that only a direct workspace-to-app request may supply. */
|
/** Remove headers that only a direct workspace-to-app request may supply. */
|
||||||
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
||||||
const sanitized = new Headers(headers);
|
const sanitized = new Headers(headers);
|
||||||
@@ -692,6 +717,13 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
data: {
|
data: {
|
||||||
origin: target.origin,
|
origin: target.origin,
|
||||||
path: url.pathname + url.search,
|
path: url.pathname + url.search,
|
||||||
|
headers: gatewayWebSocketBackendHeaders(
|
||||||
|
req,
|
||||||
|
url,
|
||||||
|
ip,
|
||||||
|
forwardedHeaders,
|
||||||
|
target.origin,
|
||||||
|
),
|
||||||
queue: [],
|
queue: [],
|
||||||
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
||||||
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
||||||
@@ -753,7 +785,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
websocket: {
|
websocket: {
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
|
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
|
||||||
const backend = new WebSocket(backendUrl);
|
const BackendWebSocket = WebSocket as unknown as new (
|
||||||
|
url: string,
|
||||||
|
options: Bun.WebSocketOptions,
|
||||||
|
) => WebSocket;
|
||||||
|
const backend = new BackendWebSocket(backendUrl, { headers: ws.data.headers });
|
||||||
ws.data.backend = backend;
|
ws.data.backend = backend;
|
||||||
backend.addEventListener("open", () => {
|
backend.addEventListener("open", () => {
|
||||||
for (const m of ws.data.queue) backend.send(m);
|
for (const m of ws.data.queue) backend.send(m);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
forwardAuthFailure,
|
forwardAuthFailure,
|
||||||
forwardAuthHeaders,
|
forwardAuthHeaders,
|
||||||
gatewayProxyHeaders,
|
gatewayProxyHeaders,
|
||||||
|
gatewayWebSocketBackendHeaders,
|
||||||
stripUntrustedInternalHeaders,
|
stripUntrustedInternalHeaders,
|
||||||
gatewayRestartDelay,
|
gatewayRestartDelay,
|
||||||
internalError,
|
internalError,
|
||||||
@@ -37,6 +38,35 @@ test("gateway disables compression for its internal proxy hop", () => {
|
|||||||
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("gateway WebSocket bridge forwards validated application identity", () => {
|
||||||
|
const request = new Request("http://web.localhost:3000/__wrnexus/hmr", {
|
||||||
|
headers: {
|
||||||
|
host: "web.localhost:3000",
|
||||||
|
origin: "http://web.localhost:3000",
|
||||||
|
cookie: "session=abc",
|
||||||
|
connection: "Upgrade",
|
||||||
|
upgrade: "websocket",
|
||||||
|
"sec-websocket-key": "test-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = gatewayWebSocketBackendHeaders(
|
||||||
|
request,
|
||||||
|
new URL(request.url),
|
||||||
|
"127.0.0.1",
|
||||||
|
true,
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(headers.origin).toBe("http://127.0.0.1:3001");
|
||||||
|
expect(headers.cookie).toBe("session=abc");
|
||||||
|
expect(headers["x-forwarded-host"]).toBe("web.localhost:3000");
|
||||||
|
expect(headers["x-forwarded-proto"]).toBe("http");
|
||||||
|
expect(headers.host).toBeUndefined();
|
||||||
|
expect(headers.connection).toBeUndefined();
|
||||||
|
expect(headers.upgrade).toBeUndefined();
|
||||||
|
expect(headers["sec-websocket-key"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
||||||
const request = new Request("http://localhost:3000/path", {
|
const request = new Request("http://localhost:3000/path", {
|
||||||
headers: { "x-wrnexus-internal": "1" },
|
headers: { "x-wrnexus-internal": "1" },
|
||||||
|
|||||||
Reference in New Issue
Block a user