From 98ea4ec33635eeab2d11cfd8b7d46e2cc9c37ac2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 05:19:04 +0000 Subject: [PATCH] Fix insecure go2rtc API binding and implement secure proxy Co-authored-by: GhostTypes <106415648+GhostTypes@users.noreply.github.com> --- .jules/sentinel.md | 5 + src/main/services/Go2rtcBinaryManager.ts | 2 +- src/main/webui/server/WebSocketManager.ts | 115 ++++++++++++++++++ src/main/webui/server/routes/camera-routes.ts | 16 ++- 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 2ecf301..2c443de 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -44,3 +44,8 @@ **Vulnerability:** The application was casting `req.body` directly to a discriminated union type (`ThemeProfileOperationRequestBody`) without validation. This allows attackers to send malformed data (e.g., missing fields, invalid types) that matches the TypeScript type structure only superficially, potentially causing backend crashes or logic errors. **Learning:** TypeScript types disappear at runtime. Trusting that incoming JSON matches a TS interface is a common source of bugs and vulnerabilities. Discriminated unions (like `operation: 'add' | 'update'`) are particularly prone to this if not validated, as the logic flow depends entirely on the discriminator field. **Prevention:** Always use a runtime validation library like Zod to parse and validate payloads, especially for complex structures like discriminated unions. Use `z.discriminatedUnion` to strictly enforce the relationship between the discriminator and the data shape. + +## 2026-03-05 - Insecure Sidecar Binding +**Vulnerability:** The `go2rtc` sidecar service was configured to listen on all interfaces (`0.0.0.0`), exposing its unauthenticated API and camera streams to the local network. This allowed any device on the network to view camera feeds or control streams without authentication. +**Learning:** Sidecar processes often default to insecure bindings. Relying on obscurity or network trust for helper services negates the security of the main application. +**Prevention:** Always explicitly bind internal/helper services to `127.0.0.1`. If external access is required, proxy the traffic through the main authenticated application to ensure access controls are enforced. diff --git a/src/main/services/Go2rtcBinaryManager.ts b/src/main/services/Go2rtcBinaryManager.ts index c93c7e2..6fd3b5b 100644 --- a/src/main/services/Go2rtcBinaryManager.ts +++ b/src/main/services/Go2rtcBinaryManager.ts @@ -146,7 +146,7 @@ export class Go2rtcBinaryManager { private async generateConfig(): Promise { const config: Go2rtcConfig = { api: { - listen: `:${this.apiPort}`, + listen: `127.0.0.1:${this.apiPort}`, }, webrtc: { listen: `:${this.webrtcPort}/tcp`, diff --git a/src/main/webui/server/WebSocketManager.ts b/src/main/webui/server/WebSocketManager.ts index d690925..dd748b9 100644 --- a/src/main/webui/server/WebSocketManager.ts +++ b/src/main/webui/server/WebSocketManager.ts @@ -23,6 +23,7 @@ import { PrinterStatusData, WebSocketCommand, WebSocketMessage } from '@shared/t import { EventEmitter } from 'events'; import * as http from 'http'; import { RawData, WebSocket, WebSocketServer } from 'ws'; +import { getGo2rtcService } from '../../services/Go2rtcService.js'; import { getPrinterBackendManager } from '../../managers/PrinterBackendManager.js'; import { getPrinterContextManager } from '../../managers/PrinterContextManager.js'; import type { SpoolmanChangedEvent } from '../../services/SpoolmanIntegrationService.js'; @@ -69,6 +70,7 @@ export class WebSocketManager extends EventEmitter { // WebSocket server private wss: WebSocketServer | null = null; + private cameraProxyWss: WebSocketServer | null = null; // Client tracking private readonly clients: Map = new Map(); @@ -113,6 +115,15 @@ export class WebSocketManager extends EventEmitter { // Setup event handlers this.wss.on('connection', this.handleConnection.bind(this)); + // Create Camera Proxy WebSocket server + this.cameraProxyWss = new WebSocketServer({ + server: httpServer, + path: '/api/camera/stream', + verifyClient: this.verifyClient.bind(this), + }); + + this.cameraProxyWss.on('connection', this.handleCameraProxyConnection.bind(this)); + // Setup Spoolman integration event listener try { const spoolmanService = getSpoolmanIntegrationService(); @@ -673,10 +684,114 @@ export class WebSocketManager extends EventEmitter { console.log('WebSocket server shut down'); }); + if (this.cameraProxyWss) { + this.cameraProxyWss.close(() => { + console.log('Camera Proxy WebSocket server shut down'); + }); + this.cameraProxyWss = null; + } + this.wss = null; this.isRunning = false; } + /** + * Handle new Camera Proxy WebSocket connection + */ + private handleCameraProxyConnection(ws: WebSocket, req: http.IncomingMessage): void { + const extendedReq = req as ExtendedIncomingMessage; + const token = extendedReq.wsToken; + + if (this.authManager.isAuthenticationRequired() && !token) { + console.error('[CameraProxy] Connection without token'); + ws.close(1008, 'Token required'); + return; + } + + try { + // Extract query parameters + const url = new URL(req.url || '', `http://${req.headers.host}`); + const src = url.searchParams.get('src'); + + if (!src) { + console.error('[CameraProxy] Missing src parameter'); + ws.close(1008, 'Missing src parameter'); + return; + } + + // Get go2rtc API port + const go2rtcService = getGo2rtcService(); + if (!go2rtcService.isRunning()) { + console.error('[CameraProxy] go2rtc service not running'); + ws.close(1011, 'Camera service unavailable'); + return; + } + + const apiPort = go2rtcService.getApiPort(); + const targetUrl = `ws://127.0.0.1:${apiPort}/api/ws?src=${encodeURIComponent(src)}`; + + console.log(`[CameraProxy] Proxying connection for ${src} to ${targetUrl}`); + + // Create upstream connection + const upstreamWs = new WebSocket(targetUrl); + const messageBuffer: RawData[] = []; + + // Handle client messages immediately to prevent data loss during connection + ws.on('message', (data) => { + if (upstreamWs.readyState === WebSocket.OPEN) { + upstreamWs.send(data); + } else if (upstreamWs.readyState === WebSocket.CONNECTING) { + // Buffer messages while upstream is connecting + messageBuffer.push(data); + } + }); + + // Setup proxy pipe + upstreamWs.on('open', () => { + // Flush buffered messages + while (messageBuffer.length > 0) { + const data = messageBuffer.shift(); + if (data) upstreamWs.send(data); + } + + // Forward messages from upstream to client + upstreamWs.on('message', (data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }); + }); + + // Handle close events + ws.on('close', (code, reason) => { + if (upstreamWs.readyState === WebSocket.OPEN || upstreamWs.readyState === WebSocket.CONNECTING) { + upstreamWs.close(code, reason); + } + }); + + upstreamWs.on('close', (code, reason) => { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(code, reason); + } + }); + + // Handle errors + ws.on('error', (error) => { + console.error('[CameraProxy] Client WebSocket error:', error); + upstreamWs.terminate(); + }); + + upstreamWs.on('error', (error) => { + console.error('[CameraProxy] Upstream WebSocket error:', error); + ws.terminate(); + }); + + } catch (error) { + console.error('[CameraProxy] Failed to setup proxy:', error); + ws.close(1011, 'Internal server error'); + } + } + /** * Dispose and cleanup */ diff --git a/src/main/webui/server/routes/camera-routes.ts b/src/main/webui/server/routes/camera-routes.ts index 2a02e62..ff32ab0 100644 --- a/src/main/webui/server/routes/camera-routes.ts +++ b/src/main/webui/server/routes/camera-routes.ts @@ -104,10 +104,18 @@ export function registerCameraRoutes(router: Router, deps: RouteDependencies): v return sendErrorResponse(res, 503, 'Camera stream not available'); } - // Build WebSocket URL for WebUI client - // WebUI needs to connect to go2rtc on the server's hostname, not localhost - const host = req.hostname || 'localhost'; - const wsUrl = `ws://${host}:${streamConfig.apiPort}/api/ws?src=${encodeURIComponent(streamConfig.streamName)}`; + // Build WebSocket URL for WebUI client to connect via proxy + // Connect to the WebUI server itself, which proxies to the local go2rtc instance + // Use headers.host to respect the port the client used (handles reverse proxies correctly) + const host = req.headers.host || 'localhost'; + const token = req.auth?.token; + + let wsUrl = `ws://${host}/api/camera/stream?src=${encodeURIComponent(streamConfig.streamName)}`; + + // Append token for authentication + if (token) { + wsUrl += `&token=${token}`; + } const response = { success: true,