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:
2026-08-18 19:16:54 +05:30
co-authored by Claude Opus 5
parent 10421465df
commit 5e114d867f
2 changed files with 67 additions and 1 deletions
+37 -1
View File
@@ -113,6 +113,7 @@ interface Target extends GatewayApp {
interface WsBridge {
origin: string;
path: string;
headers: Record<string, string>;
backend?: WebSocket;
queue: Array<string | ArrayBuffer>;
maxMessageBytes: number;
@@ -433,6 +434,30 @@ export function gatewayProxyHeaders(
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. */
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
const sanitized = new Headers(headers);
@@ -692,6 +717,13 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
data: {
origin: target.origin,
path: url.pathname + url.search,
headers: gatewayWebSocketBackendHeaders(
req,
url,
ip,
forwardedHeaders,
target.origin,
),
queue: [],
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
@@ -753,7 +785,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
websocket: {
open(ws) {
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;
backend.addEventListener("open", () => {
for (const m of ws.data.queue) backend.send(m);