fix: support dev server previews over relay

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent da6ab52a65
commit a87f068256
16 changed files with 504 additions and 36 deletions
@@ -41,7 +41,7 @@ Relay is not a separate link format: it is one transport candidate inside the un
Everything a client normally sends to the single OpenChamber origin:
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
- **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation, and desktop dev-server previews).
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
+3 -1
View File
@@ -33,7 +33,9 @@ const ALLOWED_WS_PATHS = new Set([
'/api/event/ws',
'/api/terminal/ws',
'/api/dictation/ws',
'/api/dev-tunnel',
]);
export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname);
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
// to the loopback origin. content-length is dropped too because the body is
@@ -425,7 +427,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
void sendAbort(streamId, error?.message ?? 'malformed ws open');
return;
}
if (!ALLOWED_WS_PATHS.has(open.path)) {
if (!isAllowedRelayWebSocketPath(open.path)) {
void sendAbort(streamId, 'Path is not allowed through the relay');
return;
}
@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test';
import http from 'node:http';
import { createTunnelHost } from './tunnel-host.js';
import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js';
import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
const startLoopback = () =>
@@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => {
await loopback.stop();
});
});
describe('relay host WebSocket allowlist', () => {
test('allows only the exact dev-server tunnel path', () => {
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true);
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false);
expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
});
});