From a40002f94f687009a63a9c866185cae6d682415c Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 12 Mar 2026 15:31:16 -0500 Subject: [PATCH] fix: support non-standard scheme origins (tauri://, capacitor://) in Control UI allowlist The URL parser returns origin="null" for non-standard schemes like tauri:// and capacitor://, causing allowlist entries like "tauri://localhost" to never match. This broke Tauri iOS apps connecting to the gateway via Tailscale. Fall back to raw lowercased string comparison when URL.origin is "null", so non-standard scheme origins match their allowlist entries correctly. Adds test coverage for tauri://, capacitor://, case-insensitive matching, and rejection of unlisted non-standard origins. --- src/gateway/origin-check.test.ts | 42 ++++++++++++++++++++++++++++++++ src/gateway/origin-check.ts | 10 ++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/gateway/origin-check.test.ts b/src/gateway/origin-check.test.ts index 50c031e927d..aee605375f6 100644 --- a/src/gateway/origin-check.test.ts +++ b/src/gateway/origin-check.test.ts @@ -100,4 +100,46 @@ describe("checkBrowserOrigin", () => { }); expect(result.ok).toBe(true); }); + + it("accepts non-standard scheme origins (tauri://) via raw string fallback", () => { + const result = checkBrowserOrigin({ + requestHost: "gateway.tailnet.ts.net", + origin: "tauri://localhost", + allowedOrigins: ["tauri://localhost"], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.matchedBy).toBe("allowlist"); + } + }); + + it("accepts non-standard scheme origins (capacitor://) via raw string fallback", () => { + const result = checkBrowserOrigin({ + requestHost: "gateway.tailnet.ts.net", + origin: "capacitor://localhost", + allowedOrigins: ["capacitor://localhost"], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.matchedBy).toBe("allowlist"); + } + }); + + it("rejects non-standard scheme origins not in allowlist", () => { + const result = checkBrowserOrigin({ + requestHost: "gateway.tailnet.ts.net", + origin: "tauri://localhost", + allowedOrigins: ["https://control.example.com"], + }); + expect(result.ok).toBe(false); + }); + + it("matches non-standard scheme origins case-insensitively", () => { + const result = checkBrowserOrigin({ + requestHost: "gateway.tailnet.ts.net", + origin: "Tauri://Localhost", + allowedOrigins: ["tauri://localhost"], + }); + expect(result.ok).toBe(true); + }); }); diff --git a/src/gateway/origin-check.ts b/src/gateway/origin-check.ts index d6795a7b64e..386f32b0432 100644 --- a/src/gateway/origin-check.ts +++ b/src/gateway/origin-check.ts @@ -9,17 +9,23 @@ type OriginCheckResult = function parseOrigin( originRaw?: string, -): { origin: string; host: string; hostname: string } | null { +): { origin: string; host: string; hostname: string; raw: string } | null { const trimmed = (originRaw ?? "").trim(); if (!trimmed || trimmed === "null") { return null; } try { const url = new URL(trimmed); + const raw = trimmed.toLowerCase(); + // Non-standard schemes (e.g. tauri://, capacitor://) produce a "null" origin + // from the URL parser. Preserve the raw input for allowlist matching so + // configured entries like "tauri://localhost" can still match. + const origin = url.origin === "null" ? raw : url.origin.toLowerCase(); return { - origin: url.origin.toLowerCase(), + origin, host: url.host.toLowerCase(), hostname: url.hostname.toLowerCase(), + raw, }; } catch { return null;