From 9bd7395c4d810fffeb97af3afcccabf118bb9c8a Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:26:24 +0530 Subject: [PATCH] fix: malformed stream path escape returns routing, not a 500 --- CHANGELOG.md | 4 ++++ server/src/computer/stream-path.ts | 18 ++++++++++++++++++ server/src/index.ts | 10 +--------- server/tests/computer-stream-path.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 server/src/computer/stream-path.ts create mode 100644 server/tests/computer-stream-path.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 296cb10ed..ab4019489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ refused in every mode, including `allow_all`, and the browser's WebRTC traffic n filter instead of around it. A computer run without an API server can set `EGRESS_POLICY_REQUIRED=0` to keep the old behaviour. +### A malformed `%` in a stream URL no longer returns a 500 + +A request to `/api/computers//stream` whose id held a broken percent-escape, such as `%zz`, made the server throw and answer 500. It is now treated as not matching the stream route and goes through normal routing. Valid ids behave as before. + ### `start.sh` names the port to change on macOS When the API server's or the app's port was held by another process, `start.sh` was meant to say diff --git a/server/src/computer/stream-path.ts b/server/src/computer/stream-path.ts new file mode 100644 index 000000000..db22e4d99 --- /dev/null +++ b/server/src/computer/stream-path.ts @@ -0,0 +1,18 @@ +/** + * Which Bot's screen. The Bot is named in the path and its computer is located the same way every + * other call locates it, so the live stream cannot point at a different Bot's browser. + * + * Its own module so it can be tested without starting a server: `index.ts` calls `serve()` at + * module scope, so importing it to reach one pure function binds a port. + */ +export function streamPathBotId(pathname: string): string | null { + const match = pathname.match(/^\/api\/computers\/([^/]+)\/stream$/); + if (!match?.[1]) return null; + try { + return decodeURIComponent(match[1]); + } catch { + // A malformed escape is not a Bot id. Decoding it used to throw URIError out of the fetch + // handler, turning one bad URL into a 500; let normal routing answer it instead. + return null; + } +} diff --git a/server/src/index.ts b/server/src/index.ts index e40751b76..42db137d3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -73,6 +73,7 @@ import { parseApprovalContinuation, parseApprovalResult, } from "./approvals/types"; +import { streamPathBotId } from "./computer/stream-path"; import { type AuditInitiator, createAuditReader, @@ -3057,15 +3058,6 @@ const toStreamUrl = (baseUrl: string, botId: string) => // be reachable without it. `${baseUrl.replace(/^http/, "ws").replace(/\/$/, "")}/stream?bot=${encodeURIComponent(botId)}&token=${encodeURIComponent(config.computer?.token ?? "")}`; -/** - * Which Bot's screen. The Bot is named in the path and its computer is located the same way every - * other call locates it, so the live stream cannot point at a different Bot's browser. - */ -const streamPathBotId = (pathname: string): string | null => { - const match = pathname.match(/^\/api\/computers\/([^/]+)\/stream$/); - return match?.[1] ? decodeURIComponent(match[1]) : null; -}; - /** What each proxied socket carries: where to connect inward, and the socket once opened. */ type StreamData = { upstream: string; diff --git a/server/tests/computer-stream-path.test.ts b/server/tests/computer-stream-path.test.ts new file mode 100644 index 000000000..f69ceb35a --- /dev/null +++ b/server/tests/computer-stream-path.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { streamPathBotId } from "../src/computer/stream-path"; + +describe("which Bot a stream path names", () => { + test("a plain id reads as itself", () => { + expect(streamPathBotId("/api/computers/my-bot/stream")).toBe("my-bot"); + }); + + test("a percent-encoded id decodes", () => { + expect(streamPathBotId("/api/computers/my%20bot/stream")).toBe("my bot"); + }); + + test("a malformed escape is not a Bot id, not a 500", () => { + expect(streamPathBotId("/api/computers/%zz/stream")).toBeNull(); + expect(streamPathBotId("/api/computers/%E2%28/stream")).toBeNull(); + }); + + test("other paths name no Bot", () => { + expect(streamPathBotId("/api/computers/my-bot/files")).toBeNull(); + expect(streamPathBotId("/api/computers/stream")).toBeNull(); + expect(streamPathBotId("/")).toBeNull(); + }); +});