Skip to content

Commit d0b3ab3

Browse files
Guard every URL decode behind decodedOrNull (#174)
* Add tests for malformed URL escapes CL-6434: sweeps apps/web and packages/ for every unguarded decodeURIComponent call reachable from a URL and adds the red half of each fix first: - @corbits/url-path's decodedOrNull (the shared helper every guarded call site below routes through). - workbench-path.ts's id/section/entity parsers, including an unrecognized (never mind malformed) section id. - insights-path.ts's route parser: a malformed workbench/run id now reads as the landing default, not a detail mode with no entity — and a render test proves InsightsPage actually renders that landing dashboard for both malformed paths, not just that the parse result looks right. - kind-filter.ts's Files deep-link id parser. - chat/routes.ts's participant-removal route: a malformed address 400s instead of 500ing. - apps/hub's createStaticHandler: the SPA fallback sees every unauthenticated, attacker-controlled request path ahead of the platform's own routes, and decodeURIComponent's throw there was a 500 on any non-/api request with a malformed escape (e.g. `GET /%zz`) — the highest-value call site in this sweep. - isWorkbenchSettingsSectionId, validating a decoded section id against WorkbenchSettingsSectionId's own values instead of the unchecked `as` cast that was there before. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti * Guard every URL decode behind decodedOrNull Routes every URL-reachable decodeURIComponent call in apps/web and packages/ through one shared, guarded helper instead of leaving each call site to throw on a malformed percent-escape: - New leaf package @corbits/url-path (no deps, safe in the browser bundle and hub code alike) holding decodedOrNull — packages/artifact-ui, packages/chat, and apps/hub can't depend on apps/web, so the helper can't stay local to path-ids.ts. - apps/web/src/path-ids.ts imports it instead of keeping its own copy. - apps/web/src/workbench-path.ts: workbenchIdFromPath, workbenchSettingsSectionFromPath (now also validated against WorkbenchSettingsSectionId via the new isWorkbenchSettingsSectionId, replacing the unchecked `as` cast — packages/chat-ui exports both the guard and its backing WORKBENCH_SETTINGS_SECTION_IDS list), workbenchSettingsEntityIdFromPath. - apps/web/src/insights-path.ts (moved out of the 1700-line insights-page.tsx, which now just imports it): parseInsightsPath's workbenchId/runId. A malformed escape now falls all the way back to {mode: "landing"} — returning {mode: "workbench", workbenchId: null} would render a bench-scoped dashboard mislabeled "All workbenches" with permanently-empty panels instead of the plain landing view every other unresolvable path already gets. - packages/artifact-ui/src/kind-filter.ts: libraryArtifactIdFromPath. - packages/chat/src/routes.ts: DELETE .../participants/:address now 400s on a malformed address instead of throwing mid-request. - apps/hub/src/index.ts's createStaticHandler (exported for the new test): the SPA static-file fallback ahead of every non-/api route, reachable unauthenticated — GET /%zz threw a 500 before this; now falls through to the platform's own 404 like any other unresolvable path. packages/connections/src/oauth-routes.ts's decode was already try/caught and is left as-is. Not in scope: apps/hub's dbConfigFromUrl decodes a database URL's own user/password at boot — operator-supplied config, never a client-reachable URL, so it's noted here rather than guarded (its shape is mirrored by six test doubles across apps/hub, packages/insights, packages/inbox, and packages/approvals, none of them production code either). Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
1 parent 71a801e commit d0b3ab3

30 files changed

Lines changed: 668 additions & 74 deletions

apps/hub/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@corbits/tasks": "workspace:*",
4545
"@corbits/tool-registry-publish": "workspace:*",
4646
"@corbits/turn-artifacts": "workspace:*",
47+
"@corbits/url-path": "workspace:*",
4748
"@corbits/webhook-triggers": "workspace:*",
4849
"@corbits/workflow-catalog": "workspace:*",
4950
"@corbits/workflow-source": "workspace:*",

apps/hub/src/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import {
9595
} from "@corbits/chat";
9696
import type { RelaunchNoticePort } from "@corbits/chat";
9797
import type { FinalizedTurnToolCall } from "@corbits/turn-artifacts";
98+
import { decodedOrNull } from "@corbits/url-path";
9899
import {
99100
createCryptoProviderCache,
100101
createTopLevelRunRoutes,
@@ -413,12 +414,12 @@ function dbConfigFromUrl(databaseUrl: string) {
413414
// Serves the single-page application from the hub origin: a real file
414415
// when one exists, index.html otherwise so client-side routes deep-link,
415416
// and never anything under /api, which stays with the platform routes.
416-
function createStaticHandler(staticDir: string) {
417+
export function createStaticHandler(staticDir: string) {
417418
return async (c: Context<AppEnv>, next: Next) => {
418419
if (c.req.path === "/api" || c.req.path.startsWith("/api/")) return next();
419-
const rel = path
420-
.normalize(decodeURIComponent(c.req.path))
421-
.replace(/^[/\\]+/, "");
420+
const decodedPath = decodedOrNull(c.req.path);
421+
if (decodedPath === null) return next();
422+
const rel = path.normalize(decodedPath).replace(/^[/\\]+/, "");
422423
if (rel === ".." || rel.startsWith(`..${path.sep}`)) return next();
423424
const asset = Bun.file(path.join(staticDir, rel));
424425
if (await asset.exists()) return new Response(asset);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// `createStaticHandler` serves the SPA from the hub origin and sits ahead
2+
// of every non-/api route, so it sees any attacker-controlled path a
3+
// client cares to send — including one with a malformed percent-escape
4+
// (`GET /%zz`). `decodeURIComponent` throws on that, and until this was
5+
// guarded the throw escaped the handler entirely: a 500 on any
6+
// non-/api request, not the 404/index.html fallback every other
7+
// unresolvable path gets.
8+
9+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11+
import { tmpdir } from "node:os";
12+
import path from "node:path";
13+
import { Hono } from "hono";
14+
import type { Context } from "hono";
15+
16+
import { createStaticHandler } from "../src/index.ts";
17+
18+
let staticDir: string;
19+
20+
beforeAll(() => {
21+
staticDir = mkdtempSync(path.join(tmpdir(), "hub-static-"));
22+
writeFileSync(path.join(staticDir, "index.html"), "<html>spa</html>");
23+
});
24+
25+
afterAll(() => {
26+
rmSync(staticDir, { recursive: true, force: true });
27+
});
28+
29+
function buildApp() {
30+
const app = new Hono();
31+
app.get("/*", createStaticHandler(staticDir) as never);
32+
app.notFound((c: Context) => c.text("not found", 404));
33+
return app;
34+
}
35+
36+
describe("createStaticHandler", () => {
37+
test("serves index.html for a normal SPA route", async () => {
38+
const app = buildApp();
39+
const response = await app.request("/workbenches/ch_1");
40+
expect(response.status).toBe(200);
41+
expect(await response.text()).toBe("<html>spa</html>");
42+
});
43+
44+
test("a malformed percent-escape 404s instead of throwing", async () => {
45+
const app = buildApp();
46+
const response = await app.request("/%zz");
47+
expect(response.status).toBe(404);
48+
});
49+
50+
test("/api paths are left for the platform routes regardless", async () => {
51+
const app = buildApp();
52+
const response = await app.request("/api/%zz");
53+
expect(response.status).toBe(404);
54+
});
55+
});

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@corbits/shell-layout": "workspace:*",
3838
"@corbits/slug": "workspace:*",
3939
"@corbits/tasks-ui": "workspace:*",
40+
"@corbits/url-path": "workspace:*",
4041
"@corbits/workflow-catalog": "workspace:*",
4142
"@corbits/icons": "workspace:*",
4243
"@intx/types": "0.3.0",

apps/web/src/bench-context.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ export type BenchState = {
4545
readonly onBenchCreated: (tenantId: string) => void;
4646
};
4747

48-
const BenchContext = createContext<BenchState | null>(null);
48+
/** Exported only so a render test can inject a fixed `BenchState` without
49+
* standing up `BenchProvider`'s own `/api/me/principals` fetch — every
50+
* real caller still goes through `useBench`/`BenchProvider`. */
51+
export const BenchContext = createContext<BenchState | null>(null);
4952

5053
/** The membership this context currently treats as selected: the stored
5154
* choice if it still names a bench the account belongs to *and* still

apps/web/src/insights-path.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { parseInsightsPath } from "./insights-path";
4+
5+
describe("parseInsightsPath", () => {
6+
test("resolves a workbench deep link", () => {
7+
expect(parseInsightsPath("/insights/workbench/tnt_1")).toEqual({
8+
mode: "workbench",
9+
runId: null,
10+
workbenchId: "tnt_1",
11+
});
12+
});
13+
14+
test("resolves a run deep link", () => {
15+
expect(parseInsightsPath("/insights/runs/run_1")).toEqual({
16+
mode: "run",
17+
runId: "run_1",
18+
workbenchId: null,
19+
});
20+
});
21+
22+
test("resolves the landing and runs-history paths", () => {
23+
expect(parseInsightsPath("/insights")).toEqual({
24+
mode: "landing",
25+
runId: null,
26+
workbenchId: null,
27+
});
28+
expect(parseInsightsPath("/insights/runs")).toEqual({
29+
mode: "runs",
30+
runId: null,
31+
workbenchId: null,
32+
});
33+
});
34+
35+
test("a malformed escape on a workbench deep link falls back to landing, not a throw", () => {
36+
expect(() =>
37+
parseInsightsPath("/insights/workbench/%E0%A4%A"),
38+
).not.toThrow();
39+
expect(parseInsightsPath("/insights/workbench/%E0%A4%A")).toEqual({
40+
mode: "landing",
41+
runId: null,
42+
workbenchId: null,
43+
});
44+
});
45+
46+
test("a malformed escape on a run deep link falls back to landing, not a throw", () => {
47+
expect(() => parseInsightsPath("/insights/runs/%")).not.toThrow();
48+
expect(parseInsightsPath("/insights/runs/%")).toEqual({
49+
mode: "landing",
50+
runId: null,
51+
workbenchId: null,
52+
});
53+
});
54+
});

apps/web/src/insights-path.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Insights' own route parser — its own module (not inlined in
2+
// insights-page.tsx) so it can be exercised directly without dragging in
3+
// that page's bench/query-client wiring.
4+
5+
import { decodedOrNull } from "@corbits/url-path";
6+
7+
import { INSIGHTS_PATH_PREFIX, INSIGHTS_RUNS_PATH } from "./path-ids";
8+
9+
/**
10+
* `/insights/workbench/:workbenchId` (CL-5879) is its own dedicated route — a
11+
* conversation's own scoped view, resolved by `InsightsWorkbenchPage`, never
12+
* a sub-mode of the landing. Every other path stays the cross-workbench
13+
* default landing: no per-mode branch needed there, since scoping happens
14+
* in InsightsRoute (which tenantId every query below targets), not here.
15+
* A stale `/insights/workbench/:tenantId` link (that route is retired,
16+
* hard cut) falls through to the plain landing default below rather than
17+
* matching anything.
18+
*
19+
* A malformed percent-escape in the id segment reads as the plain landing
20+
* default too, never as a detail mode with no entity to show — `mode:
21+
* "workbench"` (or `"run"`) with a `null` id would otherwise render that
22+
* mode's own scoped, permanently-empty dashboard instead of falling back
23+
* to the landing view any other unresolvable path already gets.
24+
*/
25+
export function parseInsightsPath(path: string): {
26+
mode: "landing" | "runs" | "run" | "workbench";
27+
runId: string | null;
28+
workbenchId: string | null;
29+
} {
30+
const workbenchMatch = /^\/insights\/workbench\/([^/]+)\/?$/.exec(path);
31+
if (workbenchMatch !== null && workbenchMatch[1] !== undefined) {
32+
const workbenchId = decodedOrNull(workbenchMatch[1]);
33+
if (workbenchId !== null) {
34+
return { mode: "workbench", runId: null, workbenchId };
35+
}
36+
}
37+
if (path === INSIGHTS_PATH_PREFIX || path === `${INSIGHTS_PATH_PREFIX}/`) {
38+
return { mode: "landing", runId: null, workbenchId: null };
39+
}
40+
if (path === INSIGHTS_RUNS_PATH || path === `${INSIGHTS_RUNS_PATH}/`) {
41+
return { mode: "runs", runId: null, workbenchId: null };
42+
}
43+
const match = /^\/insights\/runs\/([^/]+)\/?$/.exec(path);
44+
if (match !== null && match[1] !== undefined) {
45+
const runId = decodedOrNull(match[1]);
46+
if (runId !== null) {
47+
return { mode: "run", runId, workbenchId: null };
48+
}
49+
}
50+
return { mode: "landing", runId: null, workbenchId: null };
51+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// A malformed percent-escape on an Insights deep link
2+
// (`/insights/workbench/%E0%A4%A`, `/insights/runs/%`) must render the
3+
// same landing dashboard any other unrecognized Insights path gets — never
4+
// a blank page, and never `InsightsWorkbenchPage`/the run-detail route with
5+
// no entity to show (see `insights-path.ts`'s `parseInsightsPath`, which
6+
// InsightsPage calls with the exact same `path` prop this test passes).
7+
8+
import { afterEach, describe, expect, test } from "bun:test";
9+
import { act } from "react";
10+
import { createRoot } from "react-dom/client";
11+
import type { Root } from "react-dom/client";
12+
13+
import type { APIQuery } from "@corbits/api-query";
14+
import { EMPTY_OVERALL_USAGE } from "@corbits/insights/client";
15+
16+
import { InsightsPage, useInsightsWindow } from "./insights-page";
17+
import { BenchContext } from "../bench-context";
18+
import type { BenchState } from "../bench-context";
19+
import { NavigationProvider } from "../navigation";
20+
21+
let container: HTMLDivElement | null = null;
22+
let root: Root | null = null;
23+
24+
afterEach(() => {
25+
if (root !== null) {
26+
act(() => root?.unmount());
27+
root = null;
28+
}
29+
if (container !== null) {
30+
container.remove();
31+
container = null;
32+
}
33+
});
34+
35+
const readyEmpty = <T,>(data: T): APIQuery<T> => ({ kind: "ready", data });
36+
37+
const benchState: BenchState = {
38+
memberships: { kind: "ready", data: { data: [], nextCursor: null } },
39+
selectedTenantId: "tnt_bench_a",
40+
selectedPrincipalId: "prn_bench_a",
41+
selectTenant: () => {},
42+
onBenchCreated: () => {},
43+
};
44+
45+
function InsightsPageAtPath({ path }: { readonly path: string }) {
46+
const range = useInsightsWindow();
47+
return (
48+
<NavigationProvider navigate={() => {}}>
49+
<BenchContext.Provider value={benchState}>
50+
<InsightsPage
51+
path={path}
52+
summary={readyEmpty(EMPTY_OVERALL_USAGE)}
53+
activity={readyEmpty([])}
54+
byTool={readyEmpty([])}
55+
runs={readyEmpty({ data: [], nextCursor: null })}
56+
routines={readyEmpty([])}
57+
workbenches={readyEmpty({ items: [] })}
58+
latency={{ kind: "loading" }}
59+
range={range}
60+
scope={null}
61+
resolveWorkbenchIdForTenant={() => null}
62+
scopeLabel="All workbenches"
63+
/>
64+
</BenchContext.Provider>
65+
</NavigationProvider>
66+
);
67+
}
68+
69+
function render(path: string) {
70+
container = document.createElement("div");
71+
document.body.appendChild(container);
72+
root = createRoot(container);
73+
act(() => {
74+
root?.render(<InsightsPageAtPath path={path} />);
75+
});
76+
return container;
77+
}
78+
79+
describe("InsightsPage with a malformed URL escape", () => {
80+
test("a malformed workbench deep link still renders the landing dashboard", () => {
81+
const el = render("/insights/workbench/%E0%A4%A");
82+
expect(el.textContent).not.toBe("");
83+
expect(el.textContent).toContain("Insights");
84+
expect(el.textContent).toContain("All workbenches");
85+
});
86+
87+
test("a malformed run deep link still renders the landing dashboard, not run detail", () => {
88+
const el = render("/insights/runs/%");
89+
expect(el.textContent).not.toBe("");
90+
expect(el.textContent).toContain("Insights");
91+
expect(el.textContent).toContain("All workbenches");
92+
});
93+
});

apps/web/src/pages/insights-page.tsx

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
type WorkbenchInsightsResolution,
6262
} from "../insights-workbench-scope";
6363
import { workbenchInsightsPath } from "../insights-deeplinks";
64+
import { parseInsightsPath } from "../insights-path";
6465
import {
6566
ActivityResponseSchema,
6667
InsightsScopeSchema,
@@ -1077,46 +1078,6 @@ export function InsightsRunDetail({
10771078
);
10781079
}
10791080

1080-
/**
1081-
* `/insights/workbench/:workbenchId` (CL-5879) is its own dedicated route — a
1082-
* conversation's own scoped view, resolved by `InsightsWorkbenchPage`, never
1083-
* a sub-mode of the landing. Every other path stays the cross-workbench
1084-
* default landing: no per-mode branch needed there, since scoping happens
1085-
* in InsightsRoute (which tenantId every query below targets), not here.
1086-
* A stale `/insights/workbench/:tenantId` link (that route is retired,
1087-
* hard cut) falls through to the plain landing default below rather than
1088-
* matching anything.
1089-
*/
1090-
function parseInsightsPath(path: string): {
1091-
mode: "landing" | "runs" | "run" | "workbench";
1092-
runId: string | null;
1093-
workbenchId: string | null;
1094-
} {
1095-
const workbenchMatch = /^\/insights\/workbench\/([^/]+)\/?$/.exec(path);
1096-
if (workbenchMatch !== null && workbenchMatch[1] !== undefined) {
1097-
return {
1098-
mode: "workbench",
1099-
runId: null,
1100-
workbenchId: decodeURIComponent(workbenchMatch[1]),
1101-
};
1102-
}
1103-
if (path === INSIGHTS_PATH_PREFIX || path === `${INSIGHTS_PATH_PREFIX}/`) {
1104-
return { mode: "landing", runId: null, workbenchId: null };
1105-
}
1106-
if (path === INSIGHTS_RUNS_PATH || path === `${INSIGHTS_RUNS_PATH}/`) {
1107-
return { mode: "runs", runId: null, workbenchId: null };
1108-
}
1109-
const match = /^\/insights\/runs\/([^/]+)\/?$/.exec(path);
1110-
if (match !== null && match[1] !== undefined) {
1111-
return {
1112-
mode: "run",
1113-
runId: decodeURIComponent(match[1]),
1114-
workbenchId: null,
1115-
};
1116-
}
1117-
return { mode: "landing", runId: null, workbenchId: null };
1118-
}
1119-
11201081
/**
11211082
* The landing view's default scope, and every non-landing mode's scope,
11221083
* as one pure decision so it can be unit-tested without mounting the

apps/web/src/path-ids.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// below.
99

1010
import { isValidSlug, type Slug } from "@corbits/slug";
11+
import { decodedOrNull } from "@corbits/url-path";
1112

1213
export const SETTINGS_PATH_PREFIX = "/settings";
1314
export const AGENTS_PATH_PREFIX = "/agents";
@@ -43,19 +44,6 @@ function rawSegmentFromTopLevelPath(
4344
return rest === "" ? null : rest;
4445
}
4546

46-
/** A URL segment carries percent-escapes an id needs decoded, and a
47-
* hand-typed or truncated URL can carry a malformed one — which
48-
* `decodeURIComponent` answers with a throw. A path that cannot be decoded
49-
* names no entity, so it reads as no selection at all rather than taking
50-
* the render down with it. */
51-
function decodedOrNull(segment: string): string | null {
52-
try {
53-
return decodeURIComponent(segment);
54-
} catch {
55-
return null;
56-
}
57-
}
58-
5947
/** Extract a sub-selection from a flat top-level route (`/agents/:id`,
6048
* `/skills/:id`) — `null` for the bare prefix or a path outside it. */
6149
function entityIdFromTopLevelPath(path: string, prefix: string): string | null {

0 commit comments

Comments
 (0)