From c10930dfd0ca6286416531b31b897d4b5bb2e819 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 01:21:42 +0300 Subject: [PATCH] feat(desktop): proxy realtime requests with runtime headers --- packages/electron/main.mjs | 4 + packages/ui/src/lib/runtime-auth.ts | 70 +++++ packages/ui/src/lib/runtime-url.test.ts | 50 +++- packages/ui/src/lib/runtime-url.ts | 39 ++- packages/web/server/index.js | 23 +- packages/web/server/lib/realtime-proxy.js | 279 ++++++++++++++++++ .../web/server/lib/realtime-proxy.test.js | 259 ++++++++++++++++ packages/web/server/lib/ui-auth/ui-auth.js | 2 + packages/web/src/runtimeConfig.ts | 5 +- 9 files changed, 724 insertions(+), 7 deletions(-) create mode 100644 packages/web/server/lib/realtime-proxy.js create mode 100644 packages/web/server/lib/realtime-proxy.test.js diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 2a07debd..1272ec3a 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1204,6 +1204,10 @@ const spawnLocalServer = async () => { apiOnly: false, onDesktopNotification: (payload) => maybeShowNativeNotification(payload), getIsWindowFocused: isAnyWindowFocused, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl: state.apiBaseUrl || '', + requestHeaders: sanitizeRuntimeRequestHeaders(state.requestHeaders || {}), + }), }); const port = handle.getPort(); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index b02fd8f5..efd1cd93 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -10,6 +10,9 @@ let runtimeExtraHeaders: Record = {}; let runtimeUrlAuthToken = ''; let runtimeUrlAuthTokenExpiresAt = 0; let runtimeUrlAuthRefreshPromise: Promise | null = null; +let localRuntimeUrlAuthToken = ''; +let localRuntimeUrlAuthTokenExpiresAt = 0; +let localRuntimeUrlAuthRefreshPromise: Promise | null = null; let runtimeAuthGeneration = 0; const URL_AUTH_REFRESH_SKEW_MS = 10_000; @@ -58,6 +61,8 @@ const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): stri export const clearRuntimeUrlAuthToken = (): void => { runtimeUrlAuthToken = ''; runtimeUrlAuthTokenExpiresAt = 0; + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; }; const resetRuntimeAuthGeneration = (): void => { @@ -120,6 +125,17 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires } }; +export const setLocalRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => { + const normalized = normalizeBearerToken(token); + if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + return; + } + localRuntimeUrlAuthToken = normalized; + localRuntimeUrlAuthTokenExpiresAt = expiresAt; +}; + const readValidRuntimeUrlAuthTokenSync = (): string => { if (!runtimeUrlAuthToken || runtimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) { clearRuntimeUrlAuthToken(); @@ -128,6 +144,15 @@ const readValidRuntimeUrlAuthTokenSync = (): string => { return runtimeUrlAuthToken; }; +const readValidLocalRuntimeUrlAuthTokenSync = (): string => { + if (!localRuntimeUrlAuthToken || localRuntimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + return ''; + } + return localRuntimeUrlAuthToken; +}; + export const getRuntimeUrlAuthTokenSync = (): string => { const token = readValidRuntimeUrlAuthTokenSync(); if (!token && (getRuntimeBearerTokenSync() || typeof window !== 'undefined')) { @@ -136,6 +161,14 @@ export const getRuntimeUrlAuthTokenSync = (): string => { return token; }; +export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => { + const token = readValidLocalRuntimeUrlAuthTokenSync(); + if (!token && localOrigin && typeof window !== 'undefined') { + void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); + } + return token; +}; + const getRuntimeAuthCredential = async (): Promise => { const credential = await credentialProvider(); const token = credential?.type === 'bearer' @@ -193,6 +226,37 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise => return runtimeUrlAuthRefreshPromise; }; +const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise => { + if (localRuntimeUrlAuthRefreshPromise) return localRuntimeUrlAuthRefreshPromise; + const refreshPromise = (async () => { + const response = await fetch(buildAuthUrl(localOrigin, '/auth/url-token'), { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + throw new Error(`Failed to mint local runtime URL auth token (${response.status})`); + } + const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null; + const token = typeof payload?.token === 'string' ? payload.token.trim() : ''; + const expiresAt = typeof payload?.expiresAt === 'number' ? payload.expiresAt : 0; + if (!token || !Number.isFinite(expiresAt)) { + throw new Error('Local runtime URL auth token response was invalid'); + } + localRuntimeUrlAuthToken = token; + localRuntimeUrlAuthTokenExpiresAt = expiresAt; + return token; + })(); + const trackedPromise = refreshPromise.finally(() => { + if (localRuntimeUrlAuthRefreshPromise === trackedPromise) { + localRuntimeUrlAuthRefreshPromise = null; + } + }); + localRuntimeUrlAuthRefreshPromise = trackedPromise; + return localRuntimeUrlAuthRefreshPromise; +}; + // Returns a valid token without a network call, minting only when the current // token is missing or already inside the skew window. export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise => { @@ -201,6 +265,12 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr return mintRuntimeUrlAuthToken(apiBaseUrl); }; +export const refreshLocalRuntimeUrlAuthToken = async (localOrigin: string): Promise => { + const existing = readValidLocalRuntimeUrlAuthTokenSync(); + if (existing) return existing; + return mintLocalRuntimeUrlAuthToken(localOrigin); +}; + // ── Proactive URL auth token refresh ────────────────────────────────────── // The url token has a short server TTL. Instead of each consumer minting on its // own timer (and clearing the shared token, which 401s other consumers during diff --git a/packages/ui/src/lib/runtime-url.test.ts b/packages/ui/src/lib/runtime-url.test.ts index 2b30836c..fffceec4 100644 --- a/packages/ui/src/lib/runtime-url.test.ts +++ b/packages/ui/src/lib/runtime-url.test.ts @@ -5,7 +5,7 @@ import { getRuntimeUrlResolver, setRuntimeUrlResolver, } from './runtime-url'; -import { setRuntimeBearerToken, setRuntimeUrlAuthToken } from './runtime-auth'; +import { setLocalRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders, setRuntimeUrlAuthToken } from './runtime-auth'; describe('createRuntimeUrlResolver', () => { const withWindow = (value: unknown, callback: () => T): T => { @@ -76,6 +76,54 @@ describe('createRuntimeUrlResolver', () => { }); }); + test('routes realtime URLs through local desktop proxy when runtime headers are configured', () => { + setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' }); + try { + withWindow({ + location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, + __OPENCHAMBER_API_BASE_URL__: 'https://remote.example', + __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:57123', + }, () => { + const urls = createRuntimeUrlResolver({}); + const sse = new URL(urls.sse('/api/global/event')); + const ws = new URL(urls.websocket('/api/global/event/ws')); + + expect(sse.origin).toBe('http://127.0.0.1:57123'); + expect(sse.pathname).toBe('/api/openchamber/realtime-proxy/sse'); + expect(sse.searchParams.get('url')).toBe('https://remote.example/api/global/event'); + expect(ws.origin).toBe('ws://127.0.0.1:57123'); + expect(ws.pathname).toBe('/api/openchamber/realtime-proxy/ws'); + expect(ws.searchParams.get('url')).toBe('wss://remote.example/api/global/event/ws'); + }); + } finally { + setRuntimeExtraHeaders(null); + } + }); + + test('adds local URL auth token to desktop realtime proxy URL', () => { + setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' }); + setRuntimeUrlAuthToken('remote-url-token', Date.now() + 60_000); + setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000); + try { + withWindow({ + location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, + __OPENCHAMBER_API_BASE_URL__: 'https://remote.example', + __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:57123', + }, () => { + const urls = createRuntimeUrlResolver({}); + const sse = new URL(urls.sse('/api/global/event')); + const target = new URL(sse.searchParams.get('url') || ''); + + expect(sse.searchParams.get('oc_url_token')).toBe('local-url-token'); + expect(target.searchParams.get('oc_url_token')).toBe('remote-url-token'); + }); + } finally { + setRuntimeExtraHeaders(null); + setRuntimeUrlAuthToken(null, null); + setLocalRuntimeUrlAuthToken(null, null); + } + }); + test('reads injected desktop API base URL at call time', () => { withWindow({ location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, diff --git a/packages/ui/src/lib/runtime-url.ts b/packages/ui/src/lib/runtime-url.ts index ba3797c0..c27930b8 100644 --- a/packages/ui/src/lib/runtime-url.ts +++ b/packages/ui/src/lib/runtime-url.ts @@ -1,4 +1,4 @@ -import { getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth'; +import { getLocalRuntimeUrlAuthTokenSync, getRuntimeExtraHeadersSync, getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth'; type QueryValue = string | number | boolean | null | undefined; @@ -39,6 +39,14 @@ const readInjectedApiBaseUrl = (): string => { return normalizeBaseUrl(injected); }; +const readInjectedLocalOrigin = (): string => { + if (typeof window === 'undefined') return ''; + const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__; + return normalizeBaseUrl(injected); +}; + +const hasRuntimeExtraHeaders = (): boolean => Object.keys(getRuntimeExtraHeadersSync()).length > 0; + const currentHref = (config: RuntimeUrlConfig): string => { const configured = config.currentHref?.(); if (configured) return configured; @@ -110,6 +118,25 @@ const toWebSocketUrl = (candidate: string, config: RuntimeUrlConfig): string => return url.toString(); }; +const toRealtimeProxyUrl = (kind: 'sse' | 'ws', targetUrl: string, config: RuntimeUrlConfig): string | null => { + if (!hasRuntimeExtraHeaders()) return null; + const localOrigin = readInjectedLocalOrigin(); + if (!localOrigin) return null; + try { + const proxy = new URL(`/api/openchamber/realtime-proxy/${kind === 'sse' ? 'sse' : 'ws'}`, `${localOrigin}/`); + proxy.searchParams.set('url', targetUrl); + const localToken = getLocalRuntimeUrlAuthTokenSync(localOrigin); + if (localToken) proxy.searchParams.set('oc_url_token', localToken); + if (kind === 'ws') { + proxy.protocol = proxy.protocol === 'https:' ? 'wss:' : 'ws:'; + return toWebSocketUrl(proxy.toString(), config); + } + return proxy.toString(); + } catch { + return null; + } +}; + export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): RuntimeUrlResolver => { const configuredApiBaseUrl = normalizeBaseUrl(config.apiBaseUrl); const configuredRealtimeBaseUrl = normalizeBaseUrl(config.realtimeBaseUrl); @@ -131,8 +158,14 @@ export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): Runtime allowOutsideWorkspace: options?.allowOutsideWorkspace === true ? true : undefined, outsideFileGrant: options?.outsideFileGrant, }), - sse: (path, query) => withUrlAuth(realtime(path, query)), - websocket: (path, query) => toWebSocketUrl(withUrlAuth(realtime(path, query)), config), + sse: (path, query) => { + const target = withUrlAuth(realtime(path, query)); + return toRealtimeProxyUrl('sse', target, config) || target; + }, + websocket: (path, query) => { + const target = toWebSocketUrl(withUrlAuth(realtime(path, query)), config); + return toRealtimeProxyUrl('ws', target, config) || target; + }, }; }; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 4f7339dc..21238b5e 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -84,6 +84,7 @@ import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.j import { createProjectConfigRuntime } from './lib/projects/project-config.js'; import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js'; import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js'; +import { attachRealtimeProxy } from './lib/realtime-proxy.js'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; import webPush from 'web-push'; @@ -135,9 +136,14 @@ const SSE_PATH_PREFIXES = [ '/api/global/event', '/api/notifications/stream', '/api/openchamber/events', + '/api/openchamber/realtime-proxy/sse', ]; function shouldSkipCompression(req, res) { + if (process.env.OPENCHAMBER_RUNTIME === 'desktop') { + return true; + } + if (headerIncludesEventStream(req.headers.accept)) { return true; } @@ -1087,6 +1093,9 @@ async function main(options = {}) { if (typeof options.getIsWindowFocused === 'function') { notificationTriggerRuntime.setGetIsWindowFocused(options.getIsWindowFocused); } + const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function' + ? options.getDesktopRuntimeConfig + : null; console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); @@ -1132,6 +1141,7 @@ async function main(options = {}) { })); expressApp = app; server = http.createServer(app); + let realtimeProxyRuntime = { stop: () => {} }; const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, { process, @@ -1202,6 +1212,13 @@ async function main(options = {}) { setAutoAcceptSession, }); uiAuthController = bootstrapResult.uiAuthController; + realtimeProxyRuntime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig, + getUiAuthController: () => uiAuthController, + isRequestOriginAllowed, + }); const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port); const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext; @@ -1341,8 +1358,10 @@ async function main(options = {}) { port: managed ? openCodePort : null, }; }, - stop: (shutdownOptions = {}) => - gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }) + stop: (shutdownOptions = {}) => { + realtimeProxyRuntime.stop(); + return gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }); + } }; } diff --git a/packages/web/server/lib/realtime-proxy.js b/packages/web/server/lib/realtime-proxy.js new file mode 100644 index 00000000..7bcf49f2 --- /dev/null +++ b/packages/web/server/lib/realtime-proxy.js @@ -0,0 +1,279 @@ +import { WebSocket, WebSocketServer } from 'ws'; + +const PROXY_SSE_PATH = '/api/openchamber/realtime-proxy/sse'; +const PROXY_WS_PATH = '/api/openchamber/realtime-proxy/ws'; + +const isAllowedSsePath = (pathname) => { + return pathname === '/api/event' + || pathname === '/api/global/event' + || pathname === '/api/openchamber/events' + || pathname === '/api/notifications/stream' + || /^\/api\/terminal\/[^/]+\/stream$/.test(pathname); +}; + +const isAllowedWebSocketPath = (pathname) => { + return pathname === '/api/event/ws' + || pathname === '/api/global/event/ws' + || pathname === '/api/terminal/ws'; +}; + +const normalizeBaseUrl = (value) => { + if (typeof value !== 'string') return ''; + return value.trim().replace(/\/+$/, ''); +}; + +const sanitizeHeaders = (headers) => { + if (!headers || typeof headers !== 'object') return {}; + const next = {}; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = typeof rawName === 'string' ? rawName.trim() : ''; + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!name || !value || /[\r\n:]/.test(name) || /[\r\n]/.test(value)) continue; + if (name.toLowerCase() === 'authorization') continue; + next[name] = value; + } + return next; +}; + +const hasHeaders = (headers) => Object.keys(headers).length > 0; + +const getTargetParam = (req) => { + let raw = typeof req.query?.url === 'string' ? req.query.url : ''; + if (!raw) { + try { + raw = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('url') || ''; + } catch { + raw = ''; + } + } + if (!raw) return null; + try { + return new URL(raw); + } catch { + return null; + } +}; + +const urlsMatchRuntime = (target, apiBaseUrl) => { + const base = normalizeBaseUrl(apiBaseUrl); + if (!base) return false; + try { + const baseUrl = new URL(base); + const targetForCompare = new URL(target.toString()); + if (targetForCompare.protocol === 'ws:') targetForCompare.protocol = 'http:'; + if (targetForCompare.protocol === 'wss:') targetForCompare.protocol = 'https:'; + return targetForCompare.origin === baseUrl.origin; + } catch { + return false; + } +}; + +const protocolMatchesProxyType = (target, type) => { + if (type === 'ws') return target.protocol === 'ws:' || target.protocol === 'wss:'; + return target.protocol === 'http:' || target.protocol === 'https:'; +}; + +const pathMatchesProxyType = (target, type) => { + return type === 'ws' ? isAllowedWebSocketPath(target.pathname) : isAllowedSsePath(target.pathname); +}; + +const resolveProxyTarget = (req, getDesktopRuntimeConfig, type) => { + const config = typeof getDesktopRuntimeConfig === 'function' ? getDesktopRuntimeConfig() : null; + const requestHeaders = sanitizeHeaders(config?.requestHeaders); + const apiBaseUrl = normalizeBaseUrl(config?.apiBaseUrl); + const target = getTargetParam(req); + if (!target || !apiBaseUrl || !hasHeaders(requestHeaders)) return null; + if (!protocolMatchesProxyType(target, type)) return null; + if (!pathMatchesProxyType(target, type)) return null; + if (!urlsMatchRuntime(target, apiBaseUrl)) return null; + return { target, requestHeaders }; +}; + +const safeHeader = (headers, name) => { + const value = headers?.[name.toLowerCase()]; + if (Array.isArray(value)) return value.find((item) => typeof item === 'string' && item.trim()) || ''; + return typeof value === 'string' ? value.trim() : ''; +}; + +const buildSseRequestHeaders = (req, requestHeaders) => { + const headers = {}; + const accept = safeHeader(req.headers, 'accept'); + const lastEventId = safeHeader(req.headers, 'last-event-id'); + if (accept) headers.Accept = accept; + if (lastEventId) headers['Last-Event-ID'] = lastEventId; + return { ...headers, ...requestHeaders }; +}; + +const rejectWebSocketUpgrade = (socket, statusCode, message) => { + socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`); + socket.destroy(); +}; + +export const buildRealtimeProxySseUrl = (localOrigin, targetUrl) => { + const url = new URL(PROXY_SSE_PATH, localOrigin); + url.searchParams.set('url', targetUrl); + return url.toString(); +}; + +export const buildRealtimeProxyWsUrl = (localOrigin, targetUrl) => { + const url = new URL(PROXY_WS_PATH, localOrigin); + url.searchParams.set('url', targetUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.toString(); +}; + +export const attachRealtimeProxy = ({ app, server, getDesktopRuntimeConfig, getUiAuthController, isRequestOriginAllowed }) => { + if (!app || !server || typeof getDesktopRuntimeConfig !== 'function') { + return { stop: () => {} }; + } + + const originAllowed = async (req) => { + if (typeof isRequestOriginAllowed !== 'function') return false; + try { + return await isRequestOriginAllowed(req); + } catch { + return false; + } + }; + + const ensureAuthenticated = async (req, res) => { + const controller = typeof getUiAuthController === 'function' ? getUiAuthController() : null; + if (typeof controller?.ensureSessionToken !== 'function') return false; + const response = res || { setHeader: () => {} }; + const token = await controller.ensureSessionToken(req, response); + return Boolean(token); + }; + + app.get(PROXY_SSE_PATH, async (req, res) => { + if (!await ensureAuthenticated(req, res)) { + res.status(401).json({ error: 'UI authentication required' }); + return; + } + if (!await originAllowed(req)) { + res.status(403).json({ error: 'Realtime proxy origin is not allowed' }); + return; + } + const resolved = resolveProxyTarget(req, getDesktopRuntimeConfig, 'sse'); + if (!resolved) { + res.status(404).json({ error: 'Realtime proxy is unavailable' }); + return; + } + + const abort = new AbortController(); + req.on('close', () => abort.abort()); + try { + const response = await fetch(resolved.target.toString(), { + headers: buildSseRequestHeaders(req, resolved.requestHeaders), + signal: abort.signal, + }); + if (!response.ok || !response.body) { + res.status(response.status || 502).end(); + return; + } + + res.status(response.status); + res.setHeader('Content-Type', response.headers.get('content-type') || 'text/event-stream'); + res.setHeader('Cache-Control', response.headers.get('cache-control') || 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + for await (const chunk of response.body) { + if (abort.signal.aborted) break; + res.write(chunk); + } + res.end(); + } catch (error) { + if (!abort.signal.aborted && !res.headersSent) { + res.status(502).json({ error: error instanceof Error ? error.message : 'Realtime proxy failed' }); + } else if (!res.destroyed) { + res.end(); + } + } + }); + + const wsServer = new WebSocketServer({ noServer: true }); + + wsServer.on('connection', (client, request) => { + const resolved = resolveProxyTarget(request, getDesktopRuntimeConfig, 'ws'); + if (!resolved) { + client.close(1008, 'Realtime proxy is unavailable'); + return; + } + + const upstream = new WebSocket(resolved.target.toString(), { + headers: resolved.requestHeaders, + }); + const pending = []; + + const flush = () => { + while (pending.length > 0 && upstream.readyState === WebSocket.OPEN) { + const [data, isBinary] = pending.shift(); + upstream.send(data, { binary: isBinary }); + } + }; + + client.on('message', (data, isBinary) => { + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: isBinary }); + return; + } + if (upstream.readyState === WebSocket.CONNECTING) { + pending.push([data, isBinary]); + } + }); + upstream.on('open', flush); + upstream.on('message', (data, isBinary) => { + if (client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + upstream.on('close', (code, reason) => { + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + client.close(code || 1000, reason); + } + }); + upstream.on('error', () => { + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + client.close(1011, 'Realtime proxy upstream error'); + } + }); + client.on('close', () => { + if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) { + upstream.close(); + } + }); + }); + + const upgradeHandler = (req, socket, head) => { + const pathname = (() => { + try { return new URL(req.url || '/', 'http://127.0.0.1').pathname; } catch { return ''; } + })(); + if (pathname !== PROXY_WS_PATH) return; + void ensureAuthenticated(req, null).then((authenticated) => { + if (!authenticated) { + rejectWebSocketUpgrade(socket, 401, 'Unauthorized'); + return; + } + void originAllowed(req).then((allowed) => { + if (!allowed) { + rejectWebSocketUpgrade(socket, 403, 'Forbidden'); + return; + } + wsServer.handleUpgrade(req, socket, head, (ws) => { + wsServer.emit('connection', ws, req); + }); + }).catch(() => { + rejectWebSocketUpgrade(socket, 403, 'Forbidden'); + }); + }).catch(() => { + rejectWebSocketUpgrade(socket, 401, 'Unauthorized'); + }); + }; + + server.on('upgrade', upgradeHandler); + return { + stop: () => { + server.off('upgrade', upgradeHandler); + wsServer.close(); + }, + }; +}; diff --git a/packages/web/server/lib/realtime-proxy.test.js b/packages/web/server/lib/realtime-proxy.test.js new file mode 100644 index 00000000..ce25992b --- /dev/null +++ b/packages/web/server/lib/realtime-proxy.test.js @@ -0,0 +1,259 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import http from 'node:http'; +import { WebSocket, WebSocketServer } from 'ws'; + +import { attachRealtimeProxy, buildRealtimeProxySseUrl, buildRealtimeProxyWsUrl } from './realtime-proxy.js'; +import { createUiAuth } from './ui-auth/ui-auth.js'; + +const servers = []; + +const listen = async (server) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + return `http://127.0.0.1:${address.port}`; +}; + +const closeServer = async (server) => { + await new Promise((resolve) => server.close(() => resolve())); +}; + +const startProxyServer = async ({ apiBaseUrl, authToken = 'ui-token', originAllowed = true } = {}) => { + const app = express(); + const server = http.createServer(app); + const runtime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl, + requestHeaders: { 'X-Proxy-Auth': 'secret' }, + }), + getUiAuthController: () => ({ + ensureSessionToken: async () => authToken, + }), + isRequestOriginAllowed: async () => originAllowed, + }); + const origin = await listen(server); + return { origin, runtime }; +}; + +const startProxyServerWithAuthController = async ({ apiBaseUrl, uiAuthController, originAllowed = true } = {}) => { + const app = express(); + const server = http.createServer(app); + const runtime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl, + requestHeaders: { 'X-Proxy-Auth': 'secret' }, + }), + getUiAuthController: () => uiAuthController, + isRequestOriginAllowed: async () => originAllowed, + }); + const origin = await listen(server); + return { origin, runtime }; +}; + +const startSseUpstream = async ({ path = '/api/global/event' } = {}) => { + const requests = []; + const server = http.createServer((req, res) => { + requests.push({ url: req.url, headers: req.headers }); + if (new URL(req.url || '/', 'http://127.0.0.1').pathname !== path) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }); + res.write('data: first\n\n'); + res.end('data: second\n\n'); + }); + const origin = await listen(server); + return { origin, requests }; +}; + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + await closeServer(server); + } +}); + +describe('realtime proxy URL builders', () => { + it('builds local SSE proxy URLs with target URL encoded as query data', () => { + const url = new URL(buildRealtimeProxySseUrl('http://127.0.0.1:57123', 'https://remote.example/api/global/event?x=1')); + + expect(url.origin).toBe('http://127.0.0.1:57123'); + expect(url.pathname).toBe('/api/openchamber/realtime-proxy/sse'); + expect(url.searchParams.get('url')).toBe('https://remote.example/api/global/event?x=1'); + }); + + it('builds local WebSocket proxy URLs with ws protocol', () => { + const url = new URL(buildRealtimeProxyWsUrl('https://127.0.0.1:57123', 'wss://remote.example/api/global/event/ws')); + + expect(url.protocol).toBe('wss:'); + expect(url.host).toBe('127.0.0.1:57123'); + expect(url.pathname).toBe('/api/openchamber/realtime-proxy/ws'); + expect(url.searchParams.get('url')).toBe('wss://remote.example/api/global/event/ws'); + }); +}); + +describe('realtime proxy', () => { + it('streams SSE chunks and forwards safe SSE headers with configured runtime headers', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { + Accept: 'text/event-stream', + 'Last-Event-ID': 'evt-42', + Origin: 'openchamber-ui://app', + }, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('data: first\n\ndata: second\n\n'); + expect(upstream.requests).toHaveLength(1); + expect(upstream.requests[0].headers.accept).toBe('text/event-stream'); + expect(upstream.requests[0].headers['last-event-id']).toBe('evt-42'); + expect(upstream.requests[0].headers['x-proxy-auth']).toBe('secret'); + } finally { + runtime.stop(); + } + }); + + it('rejects unauthenticated SSE proxy requests', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin, authToken: null }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(401); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects SSE proxy requests from disallowed origins', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin, originAllowed: false }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'https://evil.example' }, + }); + + expect(response.status).toBe(403); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects targets outside the active runtime origin', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: 'https://different.example' }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(404); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects targets outside the realtime path allowlist', async () => { + const upstream = await startSseUpstream({ path: '/api/config/settings' }); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/config/settings`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(404); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('proxies WebSocket upgrades using query params from the raw upgrade request URL', async () => { + let upstreamRequest = null; + const upstreamServer = http.createServer(); + const upstreamWs = new WebSocketServer({ server: upstreamServer }); + upstreamWs.on('connection', (socket, request) => { + upstreamRequest = request; + socket.on('message', (data, isBinary) => { + socket.send(isBinary ? data : `echo:${data.toString()}`, { binary: isBinary }); + }); + }); + const upstreamOrigin = await listen(upstreamServer); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstreamOrigin }); + + try { + const target = `${upstreamOrigin.replace(/^http:/, 'ws:')}/api/global/event/ws?lastEventId=evt-1`; + const client = new WebSocket(buildRealtimeProxyWsUrl(origin, target), { + headers: { Origin: 'openchamber-ui://app' }, + }); + await new Promise((resolve, reject) => { + client.once('open', resolve); + client.once('error', reject); + }); + + const message = await new Promise((resolve) => { + client.once('message', (data) => resolve(data.toString())); + client.send('ping'); + }); + + expect(message).toBe('echo:ping'); + expect(upstreamRequest?.url).toBe('/api/global/event/ws?lastEventId=evt-1'); + expect(upstreamRequest?.headers['x-proxy-auth']).toBe('secret'); + client.close(); + upstreamWs.close(); + } finally { + runtime.stop(); + } + }); + + it('allows first passwordless WebSocket proxy upgrade without an existing cookie', async () => { + const upstreamServer = http.createServer(); + const upstreamWs = new WebSocketServer({ server: upstreamServer }); + upstreamWs.on('connection', (socket) => { + socket.send('ready'); + }); + const upstreamOrigin = await listen(upstreamServer); + const uiAuthController = createUiAuth({ password: '' }); + const { origin, runtime } = await startProxyServerWithAuthController({ apiBaseUrl: upstreamOrigin, uiAuthController }); + + try { + const target = `${upstreamOrigin.replace(/^http:/, 'ws:')}/api/global/event/ws`; + const client = new WebSocket(buildRealtimeProxyWsUrl(origin, target), { + headers: { Origin: 'openchamber-ui://app' }, + }); + const message = await new Promise((resolve, reject) => { + client.once('message', (data) => resolve(data.toString())); + client.once('error', reject); + }); + + expect(message).toBe('ready'); + client.close(); + upstreamWs.close(); + } finally { + runtime.stop(); + uiAuthController.dispose?.(); + } + }); +}); diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index 926aaae2..4862bd65 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -295,6 +295,7 @@ const isUrlAuthReadableHttpPath = (pathname) => { return pathname === '/api/event' || pathname === '/api/global/event' || pathname === '/api/openchamber/events' + || pathname === '/api/openchamber/realtime-proxy/sse' || pathname === '/api/notifications/stream' || pathname === '/api/fs/raw' || pathname === '/api/fs/serve' @@ -307,6 +308,7 @@ const isUrlAuthReadableHttpPath = (pathname) => { const isUrlAuthWebSocketPath = (pathname) => { return pathname === '/api/event/ws' || pathname === '/api/global/event/ws' + || pathname === '/api/openchamber/realtime-proxy/ws' || pathname === '/api/terminal/ws' || pathname.startsWith('/api/preview/proxy/'); }; diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index ba91ca9b..de16d298 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; +import { refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -44,6 +44,9 @@ export const createConfiguredWebAPIs = () => { setRuntimeBearerToken(clientToken || null); setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); + if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin)) { + void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); + } installRuntimeFetchBridge(); return createWebAPIs({ urls }); };