From 79659b2b140b90756fd0182d5f24b1fef2bbc56f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 14:37:33 +0000 Subject: [PATCH] fix(browser): land PR #11880 decodeURIComponent guardrails Guard malformed percent-encoding in relay target routes and browser dispatcher params, add regression tests, and update changelog. Landed from contributor @Yida-Dev (PR #11880). Co-authored-by: Yida-Dev --- CHANGELOG.md | 1 + src/browser/extension-relay.test.ts | 12 +++++++++++ src/browser/extension-relay.ts | 9 +++++++- src/browser/routes/dispatcher.abort.test.ts | 24 +++++++++++++++++++++ src/browser/routes/dispatcher.ts | 9 +++++++- 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fcaf7bafcd..4e22a7e0ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng. - Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553) - Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688) +- Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted `%` paths return `400` instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev. - Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker. - Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282) - iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman. diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 45d17b6695b..75b0309235c 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -208,6 +208,18 @@ describe("chrome extension relay server", () => { expect(err.message).toContain("401"); }); + it("returns 400 for malformed percent-encoding in target action routes", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const res = await fetch(`${cdpUrl}/json/activate/%E0%A4%A`, { + headers: relayAuthHeaders(cdpUrl), + }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("invalid targetId encoding"); + }); + it("deduplicates concurrent relay starts for the same requested port", async () => { const port = await getFreePort(); cdpUrl = `http://127.0.0.1:${port}`; diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index aa51a115af1..0e430c2b255 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -476,7 +476,14 @@ export async function ensureChromeExtensionRelayServer(opts: { if (!match || (req.method !== "GET" && req.method !== "PUT")) { return false; } - const targetId = decodeURIComponent(match[1] ?? "").trim(); + let targetId = ""; + try { + targetId = decodeURIComponent(match[1] ?? "").trim(); + } catch { + res.writeHead(400); + res.end("invalid targetId encoding"); + return true; + } if (!targetId) { res.writeHead(400); res.end("targetId required"); diff --git a/src/browser/routes/dispatcher.abort.test.ts b/src/browser/routes/dispatcher.abort.test.ts index 642953ae55c..7ff62f5f703 100644 --- a/src/browser/routes/dispatcher.abort.test.ts +++ b/src/browser/routes/dispatcher.abort.test.ts @@ -23,6 +23,15 @@ vi.mock("./index.js", () => { res.json({ ok: true }); }, ); + app.get( + "/echo/:id", + async ( + req: { params?: Record }, + res: { json: (body: unknown) => void }, + ) => { + res.json({ id: req.params?.id ?? null }); + }, + ); }, }; }); @@ -46,4 +55,19 @@ describe("browser route dispatcher (abort)", () => { body: { error: expect.stringContaining("timed out") }, }); }); + + it("returns 400 for malformed percent-encoding in route params", async () => { + const { createBrowserRouteDispatcher } = await import("./dispatcher.js"); + const dispatcher = createBrowserRouteDispatcher({} as BrowserRouteContext); + + await expect( + dispatcher.dispatch({ + method: "GET", + path: "/echo/%E0%A4%A", + }), + ).resolves.toMatchObject({ + status: 400, + body: { error: expect.stringContaining("invalid path parameter encoding") }, + }); + }); }); diff --git a/src/browser/routes/dispatcher.ts b/src/browser/routes/dispatcher.ts index b21f6991dfe..3fe24e11041 100644 --- a/src/browser/routes/dispatcher.ts +++ b/src/browser/routes/dispatcher.ts @@ -87,7 +87,14 @@ export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) { for (const [idx, name] of match.paramNames.entries()) { const value = exec[idx + 1]; if (typeof value === "string") { - params[name] = decodeURIComponent(value); + try { + params[name] = decodeURIComponent(value); + } catch { + return { + status: 400, + body: { error: `invalid path parameter encoding: ${name}` }, + }; + } } } }