Skip to content

Commit fa0349a

Browse files
brunowowksteipete
andauthored
fix(gateway): validate exact custom browser origins (#38290)
Co-authored-by: Peter Steinberger <[email protected]>
1 parent 5497662 commit fa0349a

3 files changed

Lines changed: 62 additions & 1 deletion

File tree

src/gateway/origin-check.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,33 @@ describe("checkBrowserOrigin", () => {
144144
])("$name", ({ input, expected }) => {
145145
expect(checkBrowserOrigin(input)).toEqual(expected);
146146
});
147+
148+
it.each([
149+
"chrome-extension://abcdefghijklmnop",
150+
"tauri://localhost",
151+
"electron://localhost",
152+
"app://desktop",
153+
])("accepts an exactly allowlisted hosted app origin: %s", (origin) => {
154+
expect(checkBrowserOrigin({ origin, allowedOrigins: [origin] })).toEqual({
155+
ok: true,
156+
matchedBy: "allowlist",
157+
});
158+
});
159+
160+
it.each([
161+
"tauri://localhost/path",
162+
"tauri://localhost/admin/..",
163+
"tauri://localhost/%2e",
164+
"https://control.example.com\\admin",
165+
"tauri://localhost?mode=admin",
166+
"tauri://localhost#admin",
167+
"tauri://user@localhost",
168+
"file:///tmp/openclaw.html",
169+
"data:text/plain,hello",
170+
])("rejects a non-origin URL value: %s", (origin) => {
171+
expect(checkBrowserOrigin({ origin, allowedOrigins: [origin] })).toEqual({
172+
ok: false,
173+
reason: "origin missing or invalid",
174+
});
175+
});
147176
});

src/gateway/origin-check.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,20 @@ function parseOrigin(
2121
if (!trimmed || trimmed === "null") {
2222
return null;
2323
}
24+
// URL parsing collapses dot segments. Reject non-origin suffixes before
25+
// canonicalization so a path cannot inherit its authority's grant.
26+
if (!/^[a-z][a-z0-9+.-]*:\/\/[^/?#\\]+\/?$/i.test(trimmed)) {
27+
return null;
28+
}
2429
try {
2530
const url = new URL(trimmed);
31+
if (url.username || url.password || !url.protocol || !url.host) {
32+
return null;
33+
}
34+
// Hosted app schemes have an opaque URL.origin but a stable authority.
35+
const origin = url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin;
2636
return {
27-
origin: normalizeLowercaseStringOrEmpty(url.origin),
37+
origin: normalizeLowercaseStringOrEmpty(origin),
2838
host: normalizeLowercaseStringOrEmpty(url.host),
2939
hostname: normalizeLowercaseStringOrEmpty(url.hostname),
3040
};

src/gateway/server.auth.browser-hardening.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,28 @@ describe("gateway auth browser hardening", () => {
308308
});
309309
});
310310

311+
test("accepts an exactly allowlisted Tauri origin", async () => {
312+
const { writeConfigFile } = await import("../config/config.js");
313+
const origin = "tauri://localhost";
314+
testState.gatewayAuth = { mode: "token", token: "secret" };
315+
await writeConfigFile({ gateway: { controlUi: { allowedOrigins: [origin] } } });
316+
317+
await withGatewayServer(async ({ port }) => {
318+
const ws = await openWs(port, { origin });
319+
try {
320+
const res = await connectReq(ws, {
321+
token: "secret",
322+
client: TEST_OPERATOR_CLIENT,
323+
device: null,
324+
});
325+
expect(res.ok).toBe(true);
326+
expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok");
327+
} finally {
328+
ws.close();
329+
}
330+
});
331+
});
332+
311333
test("rejects non-local browser origins for non-control-ui clients", async () => {
312334
await expectBrowserOriginConnectRejected({});
313335
});

0 commit comments

Comments
 (0)