From fc55eaf12b0714d35a84e0781b67ae8abca8b513 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Apr 2026 09:28:49 +0700 Subject: [PATCH] fix: prevent base64 misinterpretation of numeric workspace IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numeric string IDs like "164" are valid base64 that decodes to garbage (Hebrew character "׮"), causing silent workspace lookup failures. Skip base64 encoding/decoding for numeric IDs and only use it for legacy path-based workspace identifiers. --- packages/app/src/utils/host-routes.test.ts | 41 +++++++++++++++++----- packages/app/src/utils/host-routes.ts | 14 +++++++- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 4a49076e1..82f115837 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -24,7 +24,12 @@ describe("parseHostAgentRouteFromPathname", () => { }); describe("workspace route parsing", () => { - it("encodes workspace IDs as base64url (no padding)", () => { + it("encodes numeric workspace IDs without base64", () => { + expect(encodeWorkspaceIdForPathSegment("164")).toBe("164"); + expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164"); + }); + + it("encodes path-based workspace IDs as base64url (legacy)", () => { expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("L3RtcC9yZXBv"); expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo"); }); @@ -41,7 +46,14 @@ describe("workspace route parsing", () => { expect(decodeFilePathFromPathSegment(encoded)).toBe("src/index.ts"); }); - it("parses workspace route", () => { + it("parses workspace route with numeric ID", () => { + expect(parseHostWorkspaceRouteFromPathname("/h/local/workspace/164")).toEqual({ + serverId: "local", + workspaceId: "164", + }); + }); + + it("parses workspace route with legacy base64 path", () => { expect(parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")).toEqual({ serverId: "local", workspaceId: "/tmp/repo", @@ -54,7 +66,11 @@ describe("workspace route parsing", () => { ).toBeNull(); }); - it("builds base64url workspace routes", () => { + it("builds numeric workspace routes without base64", () => { + expect(buildHostWorkspaceRoute("local", "164")).toBe("/h/local/workspace/164"); + }); + + it("builds base64url workspace routes for legacy paths", () => { expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv"); }); @@ -65,7 +81,7 @@ describe("workspace route parsing", () => { it("parses workspace open intent from pathname query", () => { expect( parseHostWorkspaceOpenIntentFromPathname( - "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1", + "/h/local/workspace/164?open=agent%3Aagent-1", ), ).toEqual({ kind: "agent", @@ -90,14 +106,23 @@ describe("workspace route parsing", () => { }); it("uses the plain workspace route when workspace context is provided", () => { - expect(buildHostAgentDetailRoute("local", "agent-1", "/tmp/repo")).toBe( - "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1", + expect(buildHostAgentDetailRoute("local", "agent-1", "164")).toBe( + "/h/local/workspace/164?open=agent%3Aagent-1", ); }); it("builds workspace routes with a one-shot open intent", () => { - expect(buildHostWorkspaceOpenRoute("local", "/tmp/repo", "draft:new")).toBe( - "/h/local/workspace/L3RtcC9yZXBv?open=draft%3Anew", + expect(buildHostWorkspaceOpenRoute("local", "164", "draft:new")).toBe( + "/h/local/workspace/164?open=draft%3Anew", ); }); + + it("round-trips numeric IDs through encode/decode", () => { + const ids = ["1", "40", "164", "9999"]; + for (const id of ids) { + const encoded = encodeWorkspaceIdForPathSegment(id); + const decoded = decodeWorkspaceIdFromPathSegment(encoded); + expect(decoded).toBe(id); + } + }); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 89d61e047..9b2c640a6 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -163,7 +163,13 @@ export function encodeWorkspaceIdForPathSegment(workspaceId: string): string { if (!normalized) { return ""; } - return toBase64UrlNoPad(normalizeWorkspaceId(normalized)); + // Numeric string IDs are URL-safe and don't need encoding. + // Legacy path-based IDs still get base64-encoded for safety. + const id = normalizeWorkspaceId(normalized); + if (isPathLikeWorkspaceIdentity(id)) { + return toBase64UrlNoPad(id); + } + return encodeURIComponent(id); } export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null { @@ -183,6 +189,12 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st return normalizeWorkspaceId(decoded); } + // If the segment looks like a plain numeric ID, return it directly. + // Do NOT attempt base64 decode on short alphanumeric strings. + if (/^\d+$/.test(decoded)) { + return decoded; + } + const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded); if (base64Decoded) { return normalizeWorkspaceId(base64Decoded);