Skip to content

Commit 17f2234

Browse files
fix: allow custom control UI origins
1 parent c046fbd commit 17f2234

3 files changed

Lines changed: 116 additions & 29 deletions

File tree

src/gateway/origin-check.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,60 @@ describe("checkBrowserOrigin", () => {
109109
},
110110
expected: { ok: true as const, matchedBy: "allowlist" as const },
111111
},
112+
{
113+
name: "accepts allowlisted app custom-scheme origins",
114+
input: {
115+
requestHost: "gateway.example.com:18789",
116+
origin: "app://localhost",
117+
allowedOrigins: ["app://localhost"],
118+
},
119+
expected: { ok: true as const, matchedBy: "allowlist" as const },
120+
},
121+
{
122+
name: "accepts allowlisted electron custom-scheme origins",
123+
input: {
124+
requestHost: "gateway.example.com:18789",
125+
origin: "electron://localhost",
126+
allowedOrigins: ["electron://localhost"],
127+
},
128+
expected: { ok: true as const, matchedBy: "allowlist" as const },
129+
},
130+
{
131+
name: "accepts allowlisted tauri custom-scheme origins",
132+
input: {
133+
requestHost: "gateway.example.com:18789",
134+
origin: "tauri://LOCALHOST",
135+
allowedOrigins: ["tauri://localhost"],
136+
},
137+
expected: { ok: true as const, matchedBy: "allowlist" as const },
138+
},
139+
{
140+
name: "rejects unlisted custom-scheme origins",
141+
input: {
142+
requestHost: "gateway.example.com:18789",
143+
origin: "electron://localhost",
144+
allowedOrigins: ["tauri://localhost"],
145+
},
146+
expected: { ok: false as const, reason: "origin not allowed" },
147+
},
148+
{
149+
name: "rejects hostless file opaque origins",
150+
input: {
151+
requestHost: "gateway.example.com:18789",
152+
origin: "file:///tmp/openclaw.html",
153+
allowedOrigins: ["file://"],
154+
},
155+
expected: { ok: false as const, reason: "origin missing or invalid" },
156+
},
157+
{
158+
name: "rejects hostless data opaque origins",
159+
input: {
160+
requestHost: "gateway.example.com:18789",
161+
origin: "data:text/plain,hello",
162+
allowedOrigins: ["data:text/plain,hello"],
163+
},
164+
expected: { ok: false as const, reason: "origin missing or invalid" },
165+
},
112166
{
113167
name: "rejects missing origin",
114168
input: {
@@ -133,6 +187,14 @@ describe("checkBrowserOrigin", () => {
133187
},
134188
expected: { ok: false as const, reason: "origin missing or invalid" },
135189
},
190+
{
191+
name: "rejects malformed standard origin URLs",
192+
input: {
193+
requestHost: "gateway.example.com:18789",
194+
origin: "http://localhost:abc",
195+
},
196+
expected: { ok: false as const, reason: "origin missing or invalid" },
197+
},
136198
{
137199
name: "rejects mismatched origins",
138200
input: {

src/gateway/origin-check.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,19 @@ function parseOrigin(
2323
}
2424
try {
2525
const url = new URL(trimmed);
26+
const host = normalizeLowercaseStringOrEmpty(url.host);
27+
const origin =
28+
url.origin && url.origin !== "null"
29+
? normalizeLowercaseStringOrEmpty(url.origin)
30+
: url.protocol && host
31+
? normalizeLowercaseStringOrEmpty(`${url.protocol}//${host}`)
32+
: "";
33+
if (!origin) {
34+
return null;
35+
}
2636
return {
27-
origin: normalizeLowercaseStringOrEmpty(url.origin),
28-
host: normalizeLowercaseStringOrEmpty(url.host),
37+
origin,
38+
host,
2939
hostname: normalizeLowercaseStringOrEmpty(url.hostname),
3040
};
3141
} catch {

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

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -277,36 +277,51 @@ describe("gateway auth browser hardening", () => {
277277
origin: ALLOWED_BROWSER_ORIGIN,
278278
ok: true,
279279
},
280-
])("keeps non-proxy browser-origin behavior unchanged: $name", async ({ origin, ok }) => {
281-
const { writeConfigFile } = await import("../config/config.js");
282-
testState.gatewayAuth = { mode: "token", token: "secret" };
283-
await writeConfigFile({
284-
gateway: {
285-
controlUi: {
286-
allowedOrigins: [ALLOWED_BROWSER_ORIGIN],
280+
{
281+
name: "accepts allowlisted custom-scheme origins",
282+
origin: "tauri://localhost",
283+
allowedOrigins: ["tauri://localhost"],
284+
ok: true,
285+
},
286+
{
287+
name: "rejects unlisted non-loopback custom-scheme origins",
288+
origin: "electron://attacker.example",
289+
allowedOrigins: ["tauri://localhost"],
290+
ok: false,
291+
},
292+
])(
293+
"keeps non-proxy browser-origin behavior unchanged: $name",
294+
async ({ origin, allowedOrigins, ok }) => {
295+
const { writeConfigFile } = await import("../config/config.js");
296+
testState.gatewayAuth = { mode: "token", token: "secret" };
297+
await writeConfigFile({
298+
gateway: {
299+
controlUi: {
300+
allowedOrigins: allowedOrigins ?? [ALLOWED_BROWSER_ORIGIN],
301+
},
287302
},
288-
},
289-
});
303+
});
290304

291-
await withGatewayServer(async ({ port }) => {
292-
const ws = await openWs(port, { origin });
293-
try {
294-
const res = await connectReq(ws, {
295-
token: "secret",
296-
client: TEST_OPERATOR_CLIENT,
297-
device: null,
298-
});
299-
expect(res.ok).toBe(ok);
300-
if (ok) {
301-
expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok");
302-
} else {
303-
expectOriginNotAllowed(res);
305+
await withGatewayServer(async ({ port }) => {
306+
const ws = await openWs(port, { origin });
307+
try {
308+
const res = await connectReq(ws, {
309+
token: "secret",
310+
client: TEST_OPERATOR_CLIENT,
311+
device: null,
312+
});
313+
expect(res.ok).toBe(ok);
314+
if (ok) {
315+
expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok");
316+
} else {
317+
expectOriginNotAllowed(res);
318+
}
319+
} finally {
320+
ws.close();
304321
}
305-
} finally {
306-
ws.close();
307-
}
308-
});
309-
});
322+
});
323+
},
324+
);
310325

311326
test("rejects non-local browser origins for non-control-ui clients", async () => {
312327
await expectBrowserOriginConnectRejected({});

0 commit comments

Comments
 (0)