Skip to content

Commit e252c8a

Browse files
committed
Gateway: allow extension origins in browser allowlist
1 parent 9741e91 commit e252c8a

3 files changed

Lines changed: 66 additions & 4 deletions

File tree

src/gateway/origin-check.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ describe("checkBrowserOrigin", () => {
4949
expect(result.ok).toBe(true);
5050
});
5151

52+
it("accepts allowlisted chrome extension origins", () => {
53+
const result = checkBrowserOrigin({
54+
requestHost: "127.0.0.1:18789",
55+
origin: "chrome-extension://abcdefghijklmnop",
56+
allowedOrigins: ["chrome-extension://abcdefghijklmnop"],
57+
});
58+
expect(result.ok).toBe(true);
59+
});
60+
61+
it("rejects mismatched chrome extension origins", () => {
62+
const result = checkBrowserOrigin({
63+
requestHost: "127.0.0.1:18789",
64+
origin: "chrome-extension://abcdefghijklmnop",
65+
allowedOrigins: ["chrome-extension://qrstuvwxyzabcdef"],
66+
});
67+
expect(result.ok).toBe(false);
68+
});
69+
5270
it("accepts wildcard allowedOrigins", () => {
5371
const result = checkBrowserOrigin({
5472
requestHost: "gateway.example.com:18789",

src/gateway/origin-check.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,27 @@ type OriginCheckResult =
77
}
88
| { ok: false; reason: string };
99

10+
function normalizeOriginForMatch(url: URL): string {
11+
const normalizedOrigin = url.origin.toLowerCase();
12+
if (normalizedOrigin !== "null") {
13+
return normalizedOrigin;
14+
}
15+
// Non-standard schemes like chrome-extension://<id> stringify to origin === "null",
16+
// but still have a stable scheme + host that operators can allowlist exactly.
17+
return `${url.protocol}//${url.host}`.toLowerCase();
18+
}
19+
1020
function parseOrigin(
1121
originRaw?: string,
12-
): { origin: string; host: string; hostname: string } | null {
22+
): { matchOrigin: string; host: string; hostname: string } | null {
1323
const trimmed = (originRaw ?? "").trim();
1424
if (!trimmed || trimmed === "null") {
1525
return null;
1626
}
1727
try {
1828
const url = new URL(trimmed);
1929
return {
20-
origin: url.origin.toLowerCase(),
30+
matchOrigin: normalizeOriginForMatch(url),
2131
host: url.host.toLowerCase(),
2232
hostname: url.hostname.toLowerCase(),
2333
};
@@ -26,6 +36,17 @@ function parseOrigin(
2636
}
2737
}
2838

39+
function normalizeAllowedOrigin(originRaw: string): string | null {
40+
const trimmed = originRaw.trim();
41+
if (!trimmed) {
42+
return null;
43+
}
44+
if (trimmed === "*") {
45+
return "*";
46+
}
47+
return parseOrigin(trimmed)?.matchOrigin ?? null;
48+
}
49+
2950
export function checkBrowserOrigin(params: {
3051
requestHost?: string;
3152
origin?: string;
@@ -39,9 +60,9 @@ export function checkBrowserOrigin(params: {
3960
}
4061

4162
const allowlist = new Set(
42-
(params.allowedOrigins ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean),
63+
(params.allowedOrigins ?? []).map(normalizeAllowedOrigin).filter(Boolean),
4364
);
44-
if (allowlist.has("*") || allowlist.has(parsedOrigin.origin)) {
65+
if (allowlist.has("*") || allowlist.has(parsedOrigin.matchOrigin)) {
4566
return { ok: true, matchedBy: "allowlist" };
4667
}
4768

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,29 @@ async function createSignedDevice(params: {
7373
}
7474

7575
describe("gateway auth browser hardening", () => {
76+
test("accepts allowlisted chrome extension origins for browser ws clients", async () => {
77+
testState.gatewayAuth = { mode: "token", token: "secret" };
78+
testState.gatewayControlUi = {
79+
allowedOrigins: ["chrome-extension://abcdefghijklmnop"],
80+
};
81+
await withGatewayServer(async ({ port }) => {
82+
const ws = await openWs(port, {
83+
origin: "chrome-extension://abcdefghijklmnop",
84+
});
85+
try {
86+
const res = await connectReq(ws, {
87+
token: "secret",
88+
client: TEST_OPERATOR_CLIENT,
89+
device: null,
90+
});
91+
expect(res.ok).toBe(true);
92+
expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok");
93+
} finally {
94+
ws.close();
95+
}
96+
});
97+
});
98+
7699
test("rejects non-local browser origins for non-control-ui clients", async () => {
77100
testState.gatewayAuth = { mode: "token", token: "secret" };
78101
await withGatewayServer(async ({ port }) => {

0 commit comments

Comments
 (0)