Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion src/main/services/Go2rtcBinaryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export class Go2rtcBinaryManager {
private async generateConfig(): Promise<string> {
const config: Go2rtcConfig = {
api: {
listen: `:${this.apiPort}`,
listen: `127.0.0.1:${this.apiPort}`,
},
webrtc: {
listen: `:${this.webrtcPort}/tcp`,
Expand Down
115 changes: 115 additions & 0 deletions src/main/webui/server/WebSocketManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<WebSocket, ClientInfo> = new Map();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Comment on lines +688 to +691

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the shutdown method, existing client connections on cameraProxyWss are not being closed. The WebSocketServer.close() method only prevents new connections but doesn't terminate existing ones. This can lead to resource leaks as connections may remain open after server shutdown. You should iterate over this.cameraProxyWss.clients and explicitly close each connection, similar to how connections for the main wss server are handled.

      for (const client of this.cameraProxyWss.clients) {
        client.close(1000, 'Server shutting down');
      }
      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;
}
Comment on lines +712 to +720

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The camera proxy WebSocket endpoint accepts an arbitrary src parameter from the query string and passes it directly to the internal go2rtc service. Since go2rtc supports various source types including exec:, which allows execution of arbitrary shell commands, an attacker can exploit this to achieve Remote Code Execution (RCE) on the server. Even though authentication is required by default, this allows an authenticated user to execute commands with the privileges of the application, and it becomes an unauthenticated RCE if the user disables WebUI authentication in the settings.

Suggested change
// 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;
}
// Extract query parameters
const url = new URL(req.url || '', 'http://' + req.headers.host);
const src = url.searchParams.get('src');
if (!src || !/^printer_[a-zA-Z0-9_-]+$/.test(src)) {
console.error('[CameraProxy] Invalid or missing src parameter');
ws.close(1008, 'Invalid 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);
}
Comment on lines +752 to +755

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Array.prototype.shift() in a loop to flush the message buffer can be inefficient, as it has a time complexity of O(n) for each element removed. For a potentially large buffer of WebSocket messages, this could introduce performance overhead. A more performant approach is to iterate over the array with a for...of loop and then clear it by setting its length to 0.

        for (const data of messageBuffer) {
          upstreamWs.send(data);
        }
        messageBuffer.length = 0;


// 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
*/
Expand Down
16 changes: 12 additions & 4 deletions src/main/webui/server/routes/camera-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,18 @@ export function registerCameraRoutes(router: Router, deps: RouteDependencies): v
return sendErrorResponse<StandardAPIResponse>(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}`;
}
Comment on lines +115 to +118

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The session token is appended as a query parameter to the WebSocket URL. Sensitive information in query parameters can be leaked through web server logs, reverse proxy logs, and browser history. It is recommended to use a more secure method for passing authentication tokens, such as the Sec-WebSocket-Protocol header or a short-lived one-time token.


const response = {
success: true,
Expand Down