diff --git a/src/browser/client-fetch.loopback-auth.test.ts b/src/browser/client-fetch.loopback-auth.test.ts index 4a0f79ddab6..3dc17e72730 100644 --- a/src/browser/client-fetch.loopback-auth.test.ts +++ b/src/browser/client-fetch.loopback-auth.test.ts @@ -104,4 +104,14 @@ describe("fetchBrowserJson loopback auth", () => { const headers = new Headers(init?.headers); expect(headers.get("authorization")).toBe("Bearer loopback-token"); }); + + it("injects auth for IPv4-mapped IPv6 loopback URLs", async () => { + const fetchMock = stubJsonFetchOk(); + + await fetchBrowserJson<{ ok: boolean }>("http://[::ffff:127.0.0.1]:18888/"); + + const init = fetchMock.mock.calls[0]?.[1]; + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer loopback-token"); + }); }); diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index 2fc0bacf396..a349cf22a67 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -1,5 +1,6 @@ import { formatCliCommand } from "../cli/command-format.js"; import { loadConfig } from "../config/config.js"; +import { isLoopbackHost } from "../gateway/net.js"; import { getBridgeAuthForPort } from "./bridge-auth-registry.js"; import { resolveBrowserControlAuth } from "./control-auth.js"; import { @@ -20,12 +21,7 @@ function isAbsoluteHttp(url: string): boolean { function isLoopbackHttpUrl(url: string): boolean { try { - const host = new URL(url).hostname.trim().toLowerCase(); - // URL hostnames may keep IPv6 brackets (for example "[::1]"); normalize before checks. - const normalizedHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - return ( - normalizedHost === "127.0.0.1" || normalizedHost === "localhost" || normalizedHost === "::1" - ); + return isLoopbackHost(new URL(url).hostname); } catch { return false; } diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 2c6492164c5..0c402678ea2 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -12,9 +12,9 @@ import { type RateLimitCheckResult, } from "./auth-rate-limit.js"; import { + isLocalishHost, isLoopbackAddress, isTrustedProxyAddress, - resolveHostName, resolveClientIp, } from "./net.js"; @@ -133,10 +133,6 @@ export function isLocalDirectRequest( return false; } - const host = resolveHostName(req.headers?.host); - const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; - const hostIsTailscaleServe = host.endsWith(".ts.net"); - const hasForwarded = Boolean( req.headers?.["x-forwarded-for"] || req.headers?.["x-real-ip"] || @@ -144,7 +140,7 @@ export function isLocalDirectRequest( ); const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies); - return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy); + return isLocalishHost(req.headers?.host) && (!hasForwarded || remoteIsTrustedProxy); } function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null { diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index ff97fb8355d..cb2741154a3 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -1,6 +1,7 @@ import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + isLocalishHost, isPrivateOrLoopbackAddress, isSecureWebSocketUrl, isTrustedProxyAddress, @@ -24,6 +25,28 @@ describe("resolveHostName", () => { }); }); +describe("isLocalishHost", () => { + it("accepts loopback and tailscale serve/funnel host headers", () => { + const accepted = [ + "localhost", + "127.0.0.1:18789", + "[::1]:18789", + "[::ffff:127.0.0.1]:18789", + "gateway.tailnet.ts.net", + ]; + for (const host of accepted) { + expect(isLocalishHost(host), host).toBe(true); + } + }); + + it("rejects non-local hosts", () => { + const rejected = ["example.com", "192.168.1.10", "203.0.113.5:18789"]; + for (const host of rejected) { + expect(isLocalishHost(host), host).toBe(false); + } + }); +}); + describe("isTrustedProxyAddress", () => { describe("exact IP matching", () => { it("returns true when IP matches exactly", () => { diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 77f870c41c3..0d731eba7ca 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -334,6 +334,19 @@ export function isLoopbackHost(host: string): boolean { return isLoopbackAddress(unbracket); } +/** + * Local-facing host check for inbound requests: + * - loopback hosts (localhost/127.x/::1 and mapped forms) + * - Tailscale Serve/Funnel hostnames (*.ts.net) + */ +export function isLocalishHost(hostHeader?: string): boolean { + const host = resolveHostName(hostHeader); + if (!host) { + return false; + } + return isLoopbackHost(host) || host.endsWith(".ts.net"); +} + /** * Security check for WebSocket URLs (CWE-319: Cleartext Transmission of Sensitive Information). * diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 19b87097570..b659d0635f7 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -41,8 +41,12 @@ import { mintCanvasCapabilityToken, } from "../../canvas-capability.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLoopbackAddress, isTrustedProxyAddress, resolveClientIp } from "../../net.js"; -import { resolveHostName } from "../../net.js"; +import { + isLocalishHost, + isLoopbackAddress, + isTrustedProxyAddress, + resolveClientIp, +} from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { checkBrowserOrigin } from "../../origin-check.js"; import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js"; @@ -164,10 +168,7 @@ export function attachGatewayWsMessageHandler(params: { const hasProxyHeaders = Boolean(forwardedFor || realIp); const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies); const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy; - const hostName = resolveHostName(requestHost); - const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1"; - const hostIsTailscaleServe = hostName.endsWith(".ts.net"); - const hostIsLocalish = hostIsLocal || hostIsTailscaleServe; + const hostIsLocalish = isLocalishHost(requestHost); const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback); const reportedClientIp = isLocalClient || hasUntrustedProxyHeaders diff --git a/src/infra/tailnet.ts b/src/infra/tailnet.ts index ed2384cfeb0..704663d79ba 100644 --- a/src/infra/tailnet.ts +++ b/src/infra/tailnet.ts @@ -1,31 +1,24 @@ import os from "node:os"; +import { isIpInCidr } from "../shared/net/ip.js"; export type TailnetAddresses = { ipv4: string[]; ipv6: string[]; }; -export function isTailnetIPv4(address: string): boolean { - const parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const octets = parts.map((p) => Number.parseInt(p, 10)); - if (octets.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) { - return false; - } +const TAILNET_IPV4_CIDR = "100.64.0.0/10"; +const TAILNET_IPV6_CIDR = "fd7a:115c:a1e0::/48"; +export function isTailnetIPv4(address: string): boolean { // Tailscale IPv4 range: 100.64.0.0/10 // https://tailscale.com/kb/1015/100.x-addresses - const [a, b] = octets; - return a === 100 && b >= 64 && b <= 127; + return isIpInCidr(address, TAILNET_IPV4_CIDR); } function isTailnetIPv6(address: string): boolean { // Tailscale IPv6 ULA prefix: fd7a:115c:a1e0::/48 // (stable across tailnets; nodes get per-device suffixes) - const normalized = address.trim().toLowerCase(); - return normalized.startsWith("fd7a:115c:a1e0:"); + return isIpInCidr(address, TAILNET_IPV6_CIDR); } export function listTailnetAddresses(): TailnetAddresses {