Skip to content

Guard every URL decode behind decodedOrNull - #174

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-6434-decode-hardening
Aug 21, 2026
Merged

TheGreatAxios merged 2 commits into
mainfrom
cl-6434-decode-hardening

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • PR Slug detail routes for agents, skills, plugins, routines #155 centralized decodedOrNull in apps/web/src/path-ids.ts but flagged remaining unguarded decodeURIComponent call sites in workbench-path.ts and insights-page.tsx. This sweeps all of apps/web and packages/ (and, per review, apps/hub) for the rest.
  • New leaf package @corbits/url-path (no deps) holds the one guarded decodedOrNull helper — apps/web, packages/artifact-ui, packages/chat, and apps/hub all needed it, and none of the latter three can depend on apps/web, so it couldn't stay local to path-ids.ts.
  • Guarded call sites:
    • apps/web/src/workbench-path.ts (workbenchIdFromPath, workbenchSettingsSectionFromPath, 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 have rendered a bench-scoped dashboard mislabeled "All workbenches" with permanently-empty panels instead of the plain landing view. A render test mounts InsightsPage at both malformed paths and asserts the landing dashboard actually renders.
    • 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).
    • apps/hub/src/index.ts's createStaticHandler — the highest-value fix in the sweep: this SPA static-file fallback sits ahead of every non-/api route and sees any unauthenticated, attacker-controlled path. GET /%zz threw a URIError → 500 on any non-/api request before this; now it falls through to the platform's own 404, same as any other unresolvable path. Exported for a new test.
    • workbenchSettingsSectionFromPath also now validates the decoded section id against WorkbenchSettingsSectionId's own values via a new isWorkbenchSettingsSectionId guard (packages/chat-ui), replacing an unchecked as cast on untrusted input (AGENTS.md: never as T untrusted input).
  • Left alone: packages/connections/src/oauth-routes.ts's decode was already try/caught.
  • Out of scope: apps/hub/src/index.ts's dbConfigFromUrl decodes a database URL's own user/password at boot — operator-supplied config, never a client-reachable URL. Its shape is mirrored by six test doubles across apps/hub, packages/insights, packages/inbox, and packages/approvals (none of them production code either); noted here rather than guarded.

Review fixes applied

  • Dropped both incidental commits/hunks entirely. The branch had picked up an unrelated manager-tools-scenario.test.ts fake-hub hunk (a 404 handler for POST /api/workflow-chat/participants/messages, added before the position where main's own 9299405b later added its postedCards-recording handler for the same path) and an unrelated connections-tools version-pin repin (already landed on main as 1a19da8b). Rebased onto current origin/main and removed both hunks — the branch now carries only decode-hardening work. Verified: git diff origin/main HEAD -- workflows/assistant/test/manager-tools-scenario.test.ts and -- workflows/assistant/src/index.ts are both empty, and git merge-base HEAD origin/main equals origin/main's own tip, so this branch is a clean fast-forward — no merge, textual or semantic, is possible. manager-tools-scenario.test.ts's postedCards assertions run unmodified and pass.
  • Guarded apps/hub/src/index.ts's createStaticHandler (item 1 above) with a new apps/hub/test/static-handler.test.ts.
  • Fixed the insights parser to fall back to {mode: "landing"} on a failed decode, and added a real DOM render test (insights-page-render.test.tsx) asserting the landing dashboard renders, not just that the parse result looks right.
  • Replaced workbench-path.ts's as-cast on decoded section input with validation against the known WorkbenchSettingsSectionId union.
  • Moved parseInsightsPath out of insights-page.tsx into its own module (apps/web/src/insights-path.ts), since it was exported from the page purely for test reach.

Test plan

  • Unit tests on @corbits/url-path's decodedOrNull.
  • apps/hub/test/static-handler.test.ts: GET /%zz 404s instead of 500ing; normal SPA routes and /api paths still behave.
  • insights-path.test.ts: malformed escape on /insights/workbench/:id and /insights/runs/:id parses to {mode: "landing"}.
  • insights-page-render.test.tsx: InsightsPage mounted at both malformed paths actually renders the landing dashboard (DOM assertion, not just the parse result).
  • workbench-path.test.ts: malformed escape and unrecognized section id both read as no selection/no section.
  • kind-filter.test.ts: malformed escape on /files/a/:id reads as no selection.
  • packages/chat/test/routes.test.ts: malformed address on the participant-removal route 400s instead of throwing.
  • chat-ui's model.test.ts: isWorkbenchSettingsSectionId covers every known id plus a non-member string.
  • bun run check green; trial merge against origin/main confirmed clean and semantically correct (fast-forward; main's postedCards assertions untouched and passing).

https://linear.app/abklabs/issue/CL-6434

https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti

@TheGreatAxios
TheGreatAxios force-pushed the cl-6434-decode-hardening branch from b0c23ff to 150629e Compare August 20, 2026 23:31
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
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
@TheGreatAxios
TheGreatAxios force-pushed the cl-6434-decode-hardening branch from 150629e to 817ea5b Compare August 21, 2026 00:48
@TheGreatAxios
TheGreatAxios merged commit d0b3ab3 into main Aug 21, 2026
0 of 2 checks passed
@TheGreatAxios
TheGreatAxios deleted the cl-6434-decode-hardening branch August 21, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant