Skip to content

Commit c273389

Browse files
committed
fix(gateway): support custom URL schemes in origin allowlist matching
The parseOrigin function used the standard URL constructor which assigns an opaque origin ("null") to custom schemes like tauri:// and electron://. This prevented those origins from being matched against the configured allowedOrigins allowlist, forcing Tauri/Electron Control UI clients to use the wildcard "*" workaround. Add a parseCustomSchemeOrigin fallback that manually extracts scheme, host, and hostname from custom scheme origins. The fallback is triggered both when URL ctor throws and when it returns an opaque "null" origin. Closes #46520
1 parent 8c7ac9b commit c273389

2 files changed

Lines changed: 107 additions & 0 deletions

File tree

src/gateway/origin-check.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,51 @@ describe("checkBrowserOrigin", () => {
141141
},
142142
expected: { ok: false as const, reason: "origin not allowed" },
143143
},
144+
{
145+
name: "accepts custom-scheme origin in allowlist (tauri://)",
146+
input: {
147+
requestHost: "gateway.example.com:18789",
148+
origin: "tauri://localhost",
149+
allowedOrigins: ["tauri://localhost"],
150+
},
151+
expected: { ok: true as const, matchedBy: "allowlist" as const },
152+
},
153+
{
154+
name: "accepts custom-scheme origin with port in allowlist (electron://)",
155+
input: {
156+
requestHost: "gateway.example.com:18789",
157+
origin: "electron://localhost:5173",
158+
allowedOrigins: ["electron://localhost:5173"],
159+
},
160+
expected: { ok: true as const, matchedBy: "allowlist" as const },
161+
},
162+
{
163+
name: "rejects custom-scheme origin not in allowlist",
164+
input: {
165+
requestHost: "gateway.example.com:18789",
166+
origin: "tauri://localhost",
167+
allowedOrigins: ["https://control.example.com"],
168+
},
169+
expected: { ok: false as const, reason: "origin not allowed" },
170+
},
171+
{
172+
name: "accepts custom-scheme origin via wildcard",
173+
input: {
174+
requestHost: "gateway.example.com:18789",
175+
origin: "tauri://localhost",
176+
allowedOrigins: ["*"],
177+
},
178+
expected: { ok: true as const, matchedBy: "allowlist" as const },
179+
},
180+
{
181+
name: "handles custom-scheme origin with local-loopback dev fallback",
182+
input: {
183+
requestHost: "gateway.example.com:18789",
184+
origin: "tauri://localhost",
185+
isLocalClient: true,
186+
},
187+
expected: { ok: true as const, matchedBy: "local-loopback" as const },
188+
},
144189
])("$name", ({ input, expected }) => {
145190
expect(checkBrowserOrigin(input)).toEqual(expected);
146191
});

src/gateway/origin-check.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,76 @@ function parseOrigin(
2323
}
2424
try {
2525
const url = new URL(trimmed);
26+
// Opaque origins ("null") signal a non-standard scheme (e.g. tauri://,
27+
// electron://) that the URL parser cannot represent. Fall back to manual
28+
// parsing so custom-scheme origins can be matched against the allowlist.
29+
if (url.origin === "null") {
30+
return parseCustomSchemeOrigin(trimmed);
31+
}
2632
return {
2733
origin: normalizeLowercaseStringOrEmpty(url.origin),
2834
host: normalizeLowercaseStringOrEmpty(url.host),
2935
hostname: normalizeLowercaseStringOrEmpty(url.hostname),
3036
};
3137
} catch {
38+
return parseCustomSchemeOrigin(trimmed);
39+
}
40+
}
41+
42+
/**
43+
* Parse a custom-scheme origin (<scheme>://<host>[:<port>]) that the standard
44+
* URL constructor cannot handle. Does not validate the scheme beyond basic
45+
* character checks. Returns null when the string cannot be parsed as an origin.
46+
*/
47+
function parseCustomSchemeOrigin(
48+
raw: string,
49+
): { origin: string; host: string; hostname: string } | null {
50+
// Match <scheme>://<host-part> where host-part is everything before /, ?, or #
51+
const match = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]+)/i.exec(raw);
52+
if (!match) {
3253
return null;
3354
}
55+
const scheme = normalizeLowercaseStringOrEmpty(match[1]);
56+
const hostPort = match[2];
57+
if (!scheme || !hostPort) {
58+
return null;
59+
}
60+
61+
let hostname: string;
62+
let port = "";
63+
if (hostPort.startsWith("[")) {
64+
const bracketEnd = hostPort.indexOf("]");
65+
if (bracketEnd === -1) return null;
66+
hostname = hostPort.slice(1, bracketEnd);
67+
if (hostPort.length > bracketEnd + 1) {
68+
if (hostPort[bracketEnd + 1] === ":") {
69+
port = hostPort.slice(bracketEnd + 2);
70+
} else {
71+
return null;
72+
}
73+
}
74+
} else {
75+
const colonIdx = hostPort.lastIndexOf(":");
76+
if (colonIdx > 0) {
77+
hostname = hostPort.slice(0, colonIdx);
78+
port = hostPort.slice(colonIdx + 1);
79+
} else {
80+
hostname = hostPort;
81+
}
82+
}
83+
84+
const normalizedHostname = normalizeLowercaseStringOrEmpty(hostname);
85+
if (!normalizedHostname) {
86+
return null;
87+
}
88+
const host = normalizeLowercaseStringOrEmpty(
89+
port ? `${normalizedHostname}:${port}` : normalizedHostname,
90+
);
91+
const origin = normalizeLowercaseStringOrEmpty(
92+
port ? `${scheme}://${normalizedHostname}:${port}` : `${scheme}://${normalizedHostname}`,
93+
);
94+
95+
return { origin, host, hostname: normalizedHostname };
3496
}
3597

3698
/** Validate a browser Origin against explicit allowlist, same-host, and local dev rules. */

0 commit comments

Comments
 (0)