From e64ff720fb8514cb78aedd1660223d8b948284da Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Sat, 18 Apr 2026 00:25:25 +0100 Subject: [PATCH] fix: unify host classification and close SSRF gaps across packages (#9226) --- .changeset/swift-hosts-classify.md | 31 ++ .cspell/tech-terms.txt | 7 + .../vanilla-node/e2e/dynamic-base-url.spec.ts | 11 +- packages/better-auth/src/context/helpers.ts | 3 +- packages/better-auth/src/utils/url.ts | 40 +- packages/core/src/utils/host.test.ts | 485 ++++++++++++++++++ packages/core/src/utils/host.ts | 401 +++++++++++++++ packages/electron/src/user.ts | 27 +- packages/oauth-provider/src/authorize.ts | 12 +- packages/oauth-provider/src/types/zod.ts | 26 +- 10 files changed, 974 insertions(+), 69 deletions(-) create mode 100644 .changeset/swift-hosts-classify.md create mode 100644 packages/core/src/utils/host.test.ts create mode 100644 packages/core/src/utils/host.ts diff --git a/.changeset/swift-hosts-classify.md b/.changeset/swift-hosts-classify.md new file mode 100644 index 0000000000..13e7053ac1 --- /dev/null +++ b/.changeset/swift-hosts-classify.md @@ -0,0 +1,31 @@ +--- +"@better-auth/core": patch +"better-auth": patch +"@better-auth/electron": patch +"@better-auth/oauth-provider": patch +--- + +Consolidate host/IP classification behind `@better-auth/core/utils/host` and close several loopback/SSRF bypasses that the previous per-package regex checks missed. + +**Electron user-image proxy: SSRF bypasses closed (`@better-auth/electron`).** `fetchUserImage` previously gated outbound requests with a bespoke IPv4/IPv6 regex that missed multiple vectors. All of the following were reachable in production and are now blocked: + +- `http://tenant.localhost/` and other `*.localhost` names (RFC 6761 reserves the entire TLD for loopback). +- `http://[::ffff:169.254.169.254]/` (IPv4-mapped IPv6 to AWS IMDS, the classic SSRF bypass). +- `http://metadata.google.internal/`, `http://metadata.goog/` (GCP instance metadata). +- `http://instance-data/`, `http://instance-data.ec2.internal/` (AWS IMDS alternate FQDNs). +- `http://100.100.100.200/` (Alibaba Cloud IMDS; lives in RFC 6598 shared address space `100.64/10`, which the old regex did not cover). +- `http://0.0.0.0:PORT/` (the Linux/macOS kernel routes the unspecified address to loopback: Oligo's "0.0.0.0 Day"). +- `http://[fc00::...]/`, `http://[fd00::...]/` (IPv6 ULA per RFC 4193) and IPv6 link-local `fe80::/10`, neither of which the regex recognized. + +Documentation ranges (RFC 5737 / RFC 3849), benchmarking (`198.18/15`), multicast, and broadcast are also now rejected. + +**`better-auth`: `0.0.0.0` is no longer treated as loopback.** The previous `isLoopbackHost` implementation in `packages/better-auth/src/utils/url.ts` classified `0.0.0.0` alongside `127.0.0.1` / `::1` / `localhost`. `0.0.0.0` is the unspecified address, not loopback; treating it as such lets browser-origin requests reach localhost-bound dev services (Oligo's "0.0.0.0 Day"). The helper now accepts the full `127.0.0.0/8` range and any `*.localhost` name, and rejects `0.0.0.0`. + +**`better-auth`: trusted-origin substring hardening.** `getTrustedOrigins` previously used `host.includes("localhost") || host.includes("127.0.0.1")` when deciding whether to add an `http://` variant for a dynamic `baseURL.allowedHosts` entry. Misconfigurations like `evil-localhost.com` or `127.0.0.1.nip.io` would incorrectly gain an HTTP origin in the trust list. The check now uses the shared classifier, so only real loopback hosts get the HTTP variant. + +**`@better-auth/oauth-provider`: RFC 8252 compliance.** + +- §7.3 redirect URI matching now accepts the full `127.0.0.0/8` range (not just `127.0.0.1`) plus `[::1]`, with port-flexible comparison. Port-flexible matching is limited to IP literals; DNS names such as `localhost` continue to use exact-string matching per §8.3 ("NOT RECOMMENDED" for loopback). +- `validateIssuerUrl` uses the shared loopback check rather than a two-hostname literal comparison. + +**New module: `@better-auth/core/utils/host`.** Exposes `classifyHost`, `isLoopbackIP`, `isLoopbackHost`, and `isPublicRoutableHost`. One RFC 6890 / RFC 6761 / RFC 8252 implementation that handles IPv4, IPv6 (including bracketed literals, zone IDs, IPv4-mapped addresses, and 6to4 / NAT64 / Teredo tunnel forms with embedded-IPv4 recursion), and FQDNs, with a curated cloud-metadata FQDN set. All bespoke loopback/private/link-local checks across the monorepo now route through it. diff --git a/.cspell/tech-terms.txt b/.cspell/tech-terms.txt index e26d7ea352..c9f7bd0f52 100644 --- a/.cspell/tech-terms.txt +++ b/.cspell/tech-terms.txt @@ -58,3 +58,10 @@ GHEOF denoland zizmor zizmorcore +IMDS +WHATWG +CIMD +Oligo +febf +fdff +fffe diff --git a/e2e/integration/vanilla-node/e2e/dynamic-base-url.spec.ts b/e2e/integration/vanilla-node/e2e/dynamic-base-url.spec.ts index dbf8c0d925..1dbbe4608d 100644 --- a/e2e/integration/vanilla-node/e2e/dynamic-base-url.spec.ts +++ b/e2e/integration/vanilla-node/e2e/dynamic-base-url.spec.ts @@ -51,10 +51,13 @@ test.describe("dynamic baseURL (HTTP)", () => { const { port, stop } = await setupServer( { baseURL: { + // Non-loopback hosts: `validateIssuerUrl` must upgrade the + // advertised issuer to https per RFC 9207, even though the + // configured protocol is http and the wire request is HTTP. // `:*` makes the ephemeral test port match the pattern. - allowedHosts: ["tenant-a.localhost:*", "tenant-b.localhost:*"], + allowedHosts: ["tenant-a.example.com:*", "tenant-b.example.com:*"], protocol: "http", - fallback: "http://fallback.localhost", + fallback: "http://fallback.example.com", }, }, { oauthProvider: true, disableTestUser: true }, @@ -62,7 +65,7 @@ test.describe("dynamic baseURL (HTTP)", () => { try { for (const subdomain of ["tenant-a", "tenant-b"]) { - const host = `${subdomain}.localhost:${port}`; + const host = `${subdomain}.example.com:${port}`; const res = await httpGet( port, "/.well-known/oauth-authorization-server", @@ -70,7 +73,7 @@ test.describe("dynamic baseURL (HTTP)", () => { ); expect(res.status).toBe(200); const body = JSON.parse(res.body) as { issuer?: string }; - // `validateIssuerUrl` forces https for non-localhost hostnames (RFC 9207). + // `validateIssuerUrl` forces https for non-loopback hostnames (RFC 9207). expect(body.issuer).toBe(`https://${host}/api/auth`); } } finally { diff --git a/packages/better-auth/src/context/helpers.ts b/packages/better-auth/src/context/helpers.ts index dc3b8a44c3..67be826453 100644 --- a/packages/better-auth/src/context/helpers.ts +++ b/packages/better-auth/src/context/helpers.ts @@ -6,6 +6,7 @@ import type { } from "@better-auth/core"; import { env } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; +import { isLoopbackHost } from "@better-auth/core/utils/host"; import type { EndpointContext, InputContext } from "better-call"; import { defu } from "defu"; import { createCookieGetter, getCookies } from "../cookies"; @@ -115,7 +116,7 @@ export async function getTrustedOrigins( for (const host of allowedHosts) { if (!host.includes("://")) { trustedOrigins.push(`https://${host}`); - if (host.includes("localhost") || host.includes("127.0.0.1")) { + if (isLoopbackHost(host)) { trustedOrigins.push(`http://${host}`); } } else { diff --git a/packages/better-auth/src/utils/url.ts b/packages/better-auth/src/utils/url.ts index 7533bdc5b9..5cb190172a 100644 --- a/packages/better-auth/src/utils/url.ts +++ b/packages/better-auth/src/utils/url.ts @@ -3,6 +3,30 @@ import { env } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; import { wildcardMatch } from "./wildcard"; +/** + * Minimal loopback check for dev scheme inference only. Reachable from + * `client/config.ts` via `getBaseURL`, so we MUST NOT import the full + * `@better-auth/core/utils/host` classifier here: its `utils/ip` dependency + * on zod would leak into the client bundle (see `e2e/smoke/test/vite.spec.ts`). + * + * Server-side SSRF/loopback checks (oauth redirect matching, trusted-origin + * resolution, electron fetch gate) continue to use the authoritative + * `isLoopbackHost` from `@better-auth/core/utils/host`. This helper's only + * job is picking `http` vs `https` for dev ergonomics. + */ +function isLoopbackForDevScheme(host: string): boolean { + const hostname = host + .replace(/:\d+$/, "") + .replace(/^\[|\]$/g, "") + .toLowerCase(); + return ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "::1" || + hostname.startsWith("127.") + ); +} + function checkHasPath(url: string): boolean { try { const parsedUrl = new URL(url); @@ -301,27 +325,13 @@ export function getProtocolFromSource( // Local dev: prefer `http` for loopback hosts so the headers-only path // doesn't diverge from the HTTP handler's URL-derived scheme. const host = getHostFromSource(source, trustedProxyHeaders); - if (host && isLoopbackHost(host)) { + if (host && isLoopbackForDevScheme(host)) { return "http"; } return "https"; } -function isLoopbackHost(host: string): boolean { - const h = host.toLowerCase(); - return ( - h === "localhost" || - h.startsWith("localhost:") || - h === "127.0.0.1" || - h.startsWith("127.0.0.1:") || - h === "[::1]" || - h.startsWith("[::1]:") || - h === "0.0.0.0" || - h.startsWith("0.0.0.0:") - ); -} - /** * Matches a hostname against a host pattern. * Supports wildcard patterns like `*.vercel.app` or `preview-*.myapp.com`. diff --git a/packages/core/src/utils/host.test.ts b/packages/core/src/utils/host.test.ts new file mode 100644 index 0000000000..6c65599ad1 --- /dev/null +++ b/packages/core/src/utils/host.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it } from "vitest"; +import { + classifyHost, + isLoopbackHost, + isLoopbackIP, + isPublicRoutableHost, +} from "./host"; + +describe("Host Classification", () => { + describe("Input Normalization", () => { + it("should strip brackets from IPv6 literals", () => { + expect(classifyHost("[::1]").literal).toBe("ipv6"); + expect(classifyHost("[::1]").kind).toBe("loopback"); + }); + + it("should strip port from IPv4", () => { + expect(classifyHost("127.0.0.1:3000").canonical).toBe("127.0.0.1"); + expect(classifyHost("127.0.0.1:3000").kind).toBe("loopback"); + }); + + it("should strip port from bracketed IPv6", () => { + expect(classifyHost("[::1]:8080").kind).toBe("loopback"); + expect(classifyHost("[fe80::1]:443").kind).toBe("linkLocal"); + }); + + it("should strip port from FQDN", () => { + expect(classifyHost("localhost:3000").canonical).toBe("localhost"); + expect(classifyHost("example.com:443").canonical).toBe("example.com"); + }); + + it("should NOT strip trailing segment from bare IPv6", () => { + // Multiple colons means no port — don't mangle the address + expect(classifyHost("::1").kind).toBe("loopback"); + expect(classifyHost("fe80::1").kind).toBe("linkLocal"); + }); + + it("should strip IPv6 zone identifier", () => { + expect(classifyHost("fe80::1%eth0").kind).toBe("linkLocal"); + expect(classifyHost("[fe80::1%en0]:443").kind).toBe("linkLocal"); + }); + + it("should strip trailing dot (RFC 1034 absolute DNS form)", () => { + expect(classifyHost("localhost.").kind).toBe("localhost"); + expect(classifyHost("tenant.localhost.").kind).toBe("localhost"); + expect(classifyHost("metadata.google.internal.").kind).toBe( + "cloudMetadata", + ); + expect(classifyHost("instance-data.ec2.internal.").kind).toBe( + "cloudMetadata", + ); + expect(classifyHost("127.0.0.1.").kind).toBe("loopback"); + expect(classifyHost("example.com.").canonical).toBe("example.com"); + }); + + it("should be case-insensitive", () => { + expect(classifyHost("LOCALHOST").kind).toBe("localhost"); + expect(classifyHost("Example.COM").canonical).toBe("example.com"); + expect(classifyHost("2001:DB8::1").kind).toBe("documentation"); + }); + + it("should trim whitespace", () => { + expect(classifyHost(" 127.0.0.1 ").kind).toBe("loopback"); + }); + + it("should classify empty/whitespace input as non-public", () => { + // Defense-in-depth: isPublicRoutableHost must not return true for + // structurally invalid input, even though callers should validate upstream. + expect(classifyHost("").kind).toBe("reserved"); + expect(classifyHost(" ").kind).toBe("reserved"); + expect(isPublicRoutableHost("")).toBe(false); + expect(isLoopbackHost("")).toBe(false); + expect(isLoopbackIP("")).toBe(false); + }); + }); + + describe("IPv4 Classification", () => { + it("should identify loopback range 127.0.0.0/8", () => { + expect(classifyHost("127.0.0.1").kind).toBe("loopback"); + expect(classifyHost("127.0.0.0").kind).toBe("loopback"); + expect(classifyHost("127.255.255.255").kind).toBe("loopback"); + expect(classifyHost("127.42.42.42").kind).toBe("loopback"); + }); + + it("should identify unspecified 0.0.0.0", () => { + expect(classifyHost("0.0.0.0").kind).toBe("unspecified"); + }); + + it("should identify broadcast 255.255.255.255", () => { + expect(classifyHost("255.255.255.255").kind).toBe("broadcast"); + }); + + it("should identify RFC 1918 private ranges", () => { + expect(classifyHost("10.0.0.1").kind).toBe("private"); + expect(classifyHost("10.255.255.255").kind).toBe("private"); + expect(classifyHost("172.16.0.1").kind).toBe("private"); + expect(classifyHost("172.31.255.255").kind).toBe("private"); + expect(classifyHost("192.168.0.1").kind).toBe("private"); + expect(classifyHost("192.168.255.255").kind).toBe("private"); + }); + + it("should NOT flag boundary-adjacent addresses as private", () => { + expect(classifyHost("9.255.255.255").kind).toBe("public"); + expect(classifyHost("11.0.0.0").kind).toBe("public"); + expect(classifyHost("172.15.255.255").kind).toBe("public"); + expect(classifyHost("172.32.0.0").kind).toBe("public"); + expect(classifyHost("192.167.255.255").kind).toBe("public"); + expect(classifyHost("192.169.0.0").kind).toBe("public"); + }); + + it("should identify link-local 169.254.0.0/16", () => { + expect(classifyHost("169.254.0.1").kind).toBe("linkLocal"); + expect(classifyHost("169.254.169.254").kind).toBe("linkLocal"); + expect(classifyHost("169.254.255.255").kind).toBe("linkLocal"); + }); + + it("should identify shared address space 100.64.0.0/10", () => { + expect(classifyHost("100.64.0.1").kind).toBe("sharedAddressSpace"); + expect(classifyHost("100.127.255.255").kind).toBe("sharedAddressSpace"); + }); + + it("should NOT flag public addresses adjacent to 100.64/10 as shared", () => { + expect(classifyHost("100.63.255.255").kind).toBe("public"); + expect(classifyHost("100.128.0.0").kind).toBe("public"); + }); + + it("should identify documentation ranges (RFC 5737)", () => { + expect(classifyHost("192.0.2.1").kind).toBe("documentation"); + expect(classifyHost("198.51.100.42").kind).toBe("documentation"); + expect(classifyHost("203.0.113.99").kind).toBe("documentation"); + }); + + it("should identify benchmarking 198.18.0.0/15", () => { + expect(classifyHost("198.18.0.1").kind).toBe("benchmarking"); + expect(classifyHost("198.19.255.255").kind).toBe("benchmarking"); + }); + + it("should identify multicast 224.0.0.0/4", () => { + expect(classifyHost("224.0.0.1").kind).toBe("multicast"); + expect(classifyHost("239.255.255.255").kind).toBe("multicast"); + }); + + it("should identify reserved ranges", () => { + expect(classifyHost("0.0.0.1").kind).toBe("reserved"); + expect(classifyHost("240.0.0.1").kind).toBe("reserved"); + expect(classifyHost("254.255.255.254").kind).toBe("reserved"); + }); + + it("should identify public addresses", () => { + expect(classifyHost("8.8.8.8").kind).toBe("public"); + expect(classifyHost("1.1.1.1").kind).toBe("public"); + expect(classifyHost("142.250.80.46").kind).toBe("public"); + }); + }); + + describe("IPv6 Classification", () => { + it("should identify loopback ::1", () => { + expect(classifyHost("::1").kind).toBe("loopback"); + expect(classifyHost("0:0:0:0:0:0:0:1").kind).toBe("loopback"); + expect(classifyHost("0000:0000:0000:0000:0000:0000:0000:0001").kind).toBe( + "loopback", + ); + }); + + it("should identify unspecified ::", () => { + expect(classifyHost("::").kind).toBe("unspecified"); + expect(classifyHost("0:0:0:0:0:0:0:0").kind).toBe("unspecified"); + }); + + it("should identify link-local fe80::/10", () => { + expect(classifyHost("fe80::1").kind).toBe("linkLocal"); + expect(classifyHost("febf::1").kind).toBe("linkLocal"); + }); + + it("should NOT flag fec0::/10 as link-local (deprecated site-local)", () => { + // fec0::/10 is deprecated site-local, not link-local + expect(classifyHost("fec0::1").kind).not.toBe("linkLocal"); + }); + + it("should identify unique local fc00::/7 as private", () => { + expect(classifyHost("fc00::1").kind).toBe("private"); + expect(classifyHost("fd00::1").kind).toBe("private"); + expect(classifyHost("fdff::1").kind).toBe("private"); + }); + + it("should identify multicast ff00::/8", () => { + expect(classifyHost("ff00::1").kind).toBe("multicast"); + expect(classifyHost("ff02::1").kind).toBe("multicast"); + }); + + it("should identify documentation 2001:db8::/32", () => { + expect(classifyHost("2001:db8::1").kind).toBe("documentation"); + expect(classifyHost("2001:0db8:abcd::1").kind).toBe("documentation"); + }); + + it("should identify public IPv6", () => { + expect(classifyHost("2606:4700:4700::1111").kind).toBe("public"); + expect(classifyHost("2a00:1450:4001:828::200e").kind).toBe("public"); + }); + + it("should expand IPv6 in canonical form", () => { + expect(classifyHost("::1").canonical).toBe( + "0000:0000:0000:0000:0000:0000:0000:0001", + ); + expect(classifyHost("2001:db8::1").canonical).toBe( + "2001:0db8:0000:0000:0000:0000:0000:0001", + ); + }); + + describe("Tunnel / Translation Forms", () => { + it("should flag 6to4 (2002::/16) encoding a non-public IPv4", () => { + // 6to4 embeds the IPv4 destination in bytes 2-5 of the address + expect(classifyHost("2002:7f00:0001::").kind).toBe("reserved"); // 127.0.0.1 + expect(classifyHost("2002:a9fe:a9fe::").kind).toBe("reserved"); // 169.254.169.254 + expect(classifyHost("2002:0a00:0001::").kind).toBe("reserved"); // 10.0.0.1 + }); + + it("should pass through 6to4 encoding a public IPv4", () => { + // 2002:2606:4700:: encodes 38.6.71.0 (public) + expect(classifyHost("2002:2606:4700::").kind).toBe("public"); + }); + + it("should flag NAT64 (64:ff9b::/96) encoding a non-public IPv4", () => { + expect(classifyHost("64:ff9b::7f00:1").kind).toBe("reserved"); // 127.0.0.1 + expect(classifyHost("64:ff9b::a9fe:a9fe").kind).toBe("reserved"); // IMDS + expect(classifyHost("64:ff9b::0a00:1").kind).toBe("reserved"); // 10.0.0.1 + expect(classifyHost("64:ff9b::").kind).toBe("reserved"); // prefix itself + }); + + it("should flag Teredo (2001::/32) encoding a non-public client IPv4", () => { + // Teredo XORs the client IPv4 with 0xFFFFFFFF, so 127.0.0.1 → 80ff:fffe + expect(classifyHost("2001:0:0:0:0:0:80ff:fffe").kind).toBe("reserved"); + // 169.254.169.254 XOR 0xFFFFFFFF → 5601:5601 + expect(classifyHost("2001:0:0:0:0:0:5601:5601").kind).toBe("reserved"); + }); + }); + }); + + describe("IPv4-Mapped IPv6 Handling", () => { + it("should unmap ::ffff:IPv4 and classify by IPv4 rules", () => { + const mapped = classifyHost("::ffff:127.0.0.1"); + expect(mapped.literal).toBe("ipv4"); + expect(mapped.kind).toBe("loopback"); + expect(mapped.canonical).toBe("127.0.0.1"); + }); + + it("should unmap hex-encoded IPv4-mapped IPv6", () => { + // ::ffff:c000:0201 === ::ffff:192.0.2.1 + const mapped = classifyHost("::ffff:c000:0201"); + expect(mapped.literal).toBe("ipv4"); + expect(mapped.canonical).toBe("192.0.2.1"); + expect(mapped.kind).toBe("documentation"); + }); + + it("should unmap full-form IPv4-mapped IPv6", () => { + const mapped = classifyHost("0:0:0:0:0:ffff:192.0.2.1"); + expect(mapped.literal).toBe("ipv4"); + expect(mapped.canonical).toBe("192.0.2.1"); + }); + + it("should classify mapped AWS metadata IP as linkLocal", () => { + expect(classifyHost("::ffff:169.254.169.254").kind).toBe("linkLocal"); + }); + }); + + describe("FQDN Classification", () => { + it("should identify exact localhost", () => { + expect(classifyHost("localhost").kind).toBe("localhost"); + expect(classifyHost("localhost").literal).toBe("fqdn"); + }); + + it("should identify RFC 6761 .localhost subdomains", () => { + expect(classifyHost("tenant.localhost").kind).toBe("localhost"); + expect(classifyHost("app.foo.localhost").kind).toBe("localhost"); + expect(classifyHost("my-app.localhost").kind).toBe("localhost"); + }); + + it("should NOT match localhost as a substring of unrelated hosts", () => { + expect(classifyHost("localhostattacker.com").kind).toBe("public"); + expect(classifyHost("notlocalhost").kind).toBe("public"); + expect(classifyHost("localhost.evil.com").kind).toBe("public"); + }); + + it("should identify cloud metadata FQDNs", () => { + expect(classifyHost("metadata.google.internal").kind).toBe( + "cloudMetadata", + ); + expect(classifyHost("metadata.goog").kind).toBe("cloudMetadata"); + expect(classifyHost("metadata").kind).toBe("cloudMetadata"); + expect(classifyHost("instance-data").kind).toBe("cloudMetadata"); + expect(classifyHost("instance-data.ec2.internal").kind).toBe( + "cloudMetadata", + ); + }); + + it("should default unknown FQDNs to public", () => { + expect(classifyHost("example.com").kind).toBe("public"); + expect(classifyHost("api.example.com").kind).toBe("public"); + }); + }); + + describe("isLoopbackIP (strict, RFC 8252 §7.3)", () => { + it("should return true for IPv4 loopback", () => { + expect(isLoopbackIP("127.0.0.1")).toBe(true); + expect(isLoopbackIP("127.5.42.1")).toBe(true); + expect(isLoopbackIP("127.0.0.1:3000")).toBe(true); + }); + + it("should return true for IPv6 loopback", () => { + expect(isLoopbackIP("::1")).toBe(true); + expect(isLoopbackIP("[::1]")).toBe(true); + expect(isLoopbackIP("[::1]:8080")).toBe(true); + }); + + it("should return false for localhost DNS name", () => { + // RFC 8252 §8.3: localhost is NOT RECOMMENDED for native OAuth + expect(isLoopbackIP("localhost")).toBe(false); + expect(isLoopbackIP("tenant.localhost")).toBe(false); + }); + + it("should return false for 0.0.0.0", () => { + expect(isLoopbackIP("0.0.0.0")).toBe(false); + }); + + it("should return false for private and link-local", () => { + expect(isLoopbackIP("10.0.0.1")).toBe(false); + expect(isLoopbackIP("192.168.1.1")).toBe(false); + expect(isLoopbackIP("169.254.169.254")).toBe(false); + expect(isLoopbackIP("fe80::1")).toBe(false); + }); + }); + + describe("isLoopbackHost (permissive)", () => { + it("should return true for IP loopback and localhost names", () => { + expect(isLoopbackHost("127.0.0.1")).toBe(true); + expect(isLoopbackHost("::1")).toBe(true); + expect(isLoopbackHost("localhost")).toBe(true); + expect(isLoopbackHost("tenant.localhost")).toBe(true); + expect(isLoopbackHost("tenant-a.localhost:3000")).toBe(true); + }); + + it("should return false for 0.0.0.0 (security fix)", () => { + // Oligo's "0.0.0.0 Day" — 0.0.0.0 is unspecified, not loopback + expect(isLoopbackHost("0.0.0.0")).toBe(false); + }); + + it("should return false for private and public hosts", () => { + expect(isLoopbackHost("10.0.0.1")).toBe(false); + expect(isLoopbackHost("example.com")).toBe(false); + expect(isLoopbackHost("localhostattacker.com")).toBe(false); + }); + }); + + describe("isPublicRoutableHost (SSRF gate)", () => { + it("should return true for ordinary public hosts", () => { + expect(isPublicRoutableHost("example.com")).toBe(true); + expect(isPublicRoutableHost("api.example.com")).toBe(true); + expect(isPublicRoutableHost("8.8.8.8")).toBe(true); + expect(isPublicRoutableHost("2606:4700:4700::1111")).toBe(true); + }); + + it("should reject all loopback variants", () => { + expect(isPublicRoutableHost("127.0.0.1")).toBe(false); + expect(isPublicRoutableHost("::1")).toBe(false); + expect(isPublicRoutableHost("localhost")).toBe(false); + expect(isPublicRoutableHost("tenant.localhost")).toBe(false); + expect(isPublicRoutableHost("::ffff:127.0.0.1")).toBe(false); + }); + + it("should reject unspecified addresses", () => { + expect(isPublicRoutableHost("0.0.0.0")).toBe(false); + expect(isPublicRoutableHost("::")).toBe(false); + }); + + it("should reject private ranges", () => { + expect(isPublicRoutableHost("10.0.0.1")).toBe(false); + expect(isPublicRoutableHost("172.16.0.1")).toBe(false); + expect(isPublicRoutableHost("192.168.1.1")).toBe(false); + expect(isPublicRoutableHost("fc00::1")).toBe(false); + expect(isPublicRoutableHost("fd00::1")).toBe(false); + }); + + it("should reject link-local (AWS IMDS)", () => { + expect(isPublicRoutableHost("169.254.169.254")).toBe(false); + expect(isPublicRoutableHost("::ffff:169.254.169.254")).toBe(false); + expect(isPublicRoutableHost("fe80::1")).toBe(false); + }); + + it("should reject cloud metadata FQDNs", () => { + expect(isPublicRoutableHost("metadata.google.internal")).toBe(false); + expect(isPublicRoutableHost("metadata.goog")).toBe(false); + expect(isPublicRoutableHost("instance-data.ec2.internal")).toBe(false); + }); + + it("should reject documentation and benchmarking ranges", () => { + expect(isPublicRoutableHost("192.0.2.1")).toBe(false); + expect(isPublicRoutableHost("198.51.100.1")).toBe(false); + expect(isPublicRoutableHost("2001:db8::1")).toBe(false); + expect(isPublicRoutableHost("198.18.0.1")).toBe(false); + }); + + it("should reject broadcast and multicast", () => { + expect(isPublicRoutableHost("255.255.255.255")).toBe(false); + expect(isPublicRoutableHost("224.0.0.1")).toBe(false); + expect(isPublicRoutableHost("ff00::1")).toBe(false); + }); + }); + + describe("Security: Bypass Prevention", () => { + it("should prevent IPv4-mapped IPv6 SSRF bypass", () => { + // Classic attack: obscure 169.254.169.254 (AWS IMDS) via IPv6 mapping + const representations = [ + "169.254.169.254", + "::ffff:169.254.169.254", + "::FFFF:169.254.169.254", + "0:0:0:0:0:ffff:169.254.169.254", + ]; + for (const host of representations) { + expect(isPublicRoutableHost(host)).toBe(false); + } + }); + + it("should prevent 0.0.0.0 loopback confusion (Oligo 0.0.0.0 Day)", () => { + // 0.0.0.0 binds to all interfaces — treating it as loopback allows + // unauthenticated localhost access from browser-origin requests + expect(isLoopbackHost("0.0.0.0")).toBe(false); + expect(isLoopbackIP("0.0.0.0")).toBe(false); + expect(classifyHost("0.0.0.0").kind).toBe("unspecified"); + }); + + it("should prevent .localhost substring bypass", () => { + // Naive `host.includes("localhost")` matches attacker-controlled domains + expect(classifyHost("evil-localhost.com").kind).toBe("public"); + expect(classifyHost("localhost.attacker.com").kind).toBe("public"); + expect(isPublicRoutableHost("localhost.attacker.com")).toBe(true); + }); + + it("should prevent hex-encoded IPv4-mapped bypass", () => { + // ::ffff:a9fe:a9fe = ::ffff:169.254.169.254 = AWS IMDS + expect(isPublicRoutableHost("::ffff:a9fe:a9fe")).toBe(false); + }); + + it("should prevent zone-id smuggling", () => { + // Naive parsers might treat fe80::1%evil.com as FQDN "evil.com" + expect(classifyHost("fe80::1%evil.com").kind).toBe("linkLocal"); + }); + + it("should prevent absolute-DNS-form SSRF bypass", () => { + // WHATWG URL parsing preserves trailing dots in `.hostname`, so + // `metadata.google.internal.` would default to `public` without + // normalization — a working cloud-metadata bypass. + expect(isPublicRoutableHost("metadata.google.internal.")).toBe(false); + expect(isPublicRoutableHost("instance-data.ec2.internal.")).toBe(false); + expect(isLoopbackHost("localhost.")).toBe(true); + expect(isLoopbackHost("tenant.localhost.")).toBe(true); + expect(isLoopbackIP("127.0.0.1.")).toBe(true); + }); + + it("should prevent IPv6 tunnel-form SSRF (6to4, NAT64, Teredo)", () => { + // Attackers can wrap a private IPv4 in a syntactically-public IPv6 + // literal. The classifier must recurse into the embedded address. + expect(isPublicRoutableHost("2002:7f00:0001::")).toBe(false); // 6to4 → 127.0.0.1 + expect(isPublicRoutableHost("2002:a9fe:a9fe::")).toBe(false); // 6to4 → IMDS + expect(isPublicRoutableHost("64:ff9b::7f00:1")).toBe(false); // NAT64 → 127.0.0.1 + expect(isPublicRoutableHost("64:ff9b::a9fe:a9fe")).toBe(false); // NAT64 → IMDS + expect(isPublicRoutableHost("2001:0:0:0:0:0:80ff:fffe")).toBe(false); // Teredo → 127.0.0.1 + // Must NOT advertise tunnel forms as RFC 8252 loopback literals + expect(isLoopbackIP("2002:7f00:0001::")).toBe(false); + expect(isLoopbackIP("64:ff9b::7f00:1")).toBe(false); + }); + + it("should canonicalize to defeat representation attacks", () => { + // All of these are the same address; canonical form must match + const representations = [ + "2001:db8::1", + "2001:DB8::1", + "2001:0db8::1", + "2001:db8:0::1", + "[2001:db8::1]", + "[2001:db8::1]:443", + ]; + const canonicals = representations.map((r) => classifyHost(r).canonical); + expect(new Set(canonicals).size).toBe(1); + }); + }); +}); diff --git a/packages/core/src/utils/host.ts b/packages/core/src/utils/host.ts new file mode 100644 index 0000000000..6f6c6c3082 --- /dev/null +++ b/packages/core/src/utils/host.ts @@ -0,0 +1,401 @@ +import { isValidIP, normalizeIP } from "./ip"; + +/** + * Host classification per RFC 6890 (Special-Purpose IP Address Registries), + * RFC 6761 (Special-Use Domain Names), and RFC 8252 §7.3 (loopback redirect URIs). + * + * This module is the single source of truth for "is this host public? private? + * loopback? link-local?" in the codebase. Consumers MUST prefer these predicates + * over bespoke regexes or substring matches; divergent checks are how bypass + * vulnerabilities get introduced (e.g. Oligo's "0.0.0.0 Day" 2024). + * + * Four user-facing primitives: + * + * - `classifyHost(host)` — the workhorse. Returns a {@link HostClassification} + * with `kind`, `literal`, and `canonical` fields. + * - `isLoopbackIP(host)` — strict: IPv4 `127.0.0.0/8` or IPv6 `::1` only. + * Use this for RFC 8252 §7.3 loopback redirect URI matching where IP + * literals are REQUIRED. + * - `isLoopbackHost(host)` — permissive: also accepts `localhost` and RFC 6761 + * `.localhost` subdomains. Use this for developer ergonomics (CORS, cookie + * secure bypass, dev-mode HTTP allow-list). + * - `isPublicRoutableHost(host)` — SSRF gate. Returns false for every + * non-`public` kind. Use this before server-side fetches to user-controlled + * URLs. + */ + +/** + * The semantic kind of a host, derived from RFC 6890 special-purpose registries + * plus a few domain-name categories (localhost, cloud metadata FQDNs). + */ +export type HostKind = + /** IPv4 `127.0.0.0/8` or IPv6 `::1`. */ + | "loopback" + /** DNS name `localhost` or RFC 6761 `.localhost` TLD. */ + | "localhost" + /** IPv4 `0.0.0.0` or IPv6 `::` — "this host on this network", not loopback. */ + | "unspecified" + /** RFC 1918 `10/8`, `172.16/12`, `192.168/16`, or IPv6 ULA `fc00::/7`. */ + | "private" + /** IPv4 `169.254/16` or IPv6 `fe80::/10`. Includes AWS IMDS `169.254.169.254`. */ + | "linkLocal" + /** RFC 6598 carrier-grade NAT `100.64.0.0/10`. */ + | "sharedAddressSpace" + /** RFC 5737 `192.0.2/24`, `198.51.100/24`, `203.0.113/24`, or RFC 3849 `2001:db8::/32`. */ + | "documentation" + /** RFC 2544 `198.18.0.0/15`. */ + | "benchmarking" + /** IPv4 `224.0.0.0/4` or IPv6 `ff00::/8`. */ + | "multicast" + /** IPv4 limited broadcast `255.255.255.255`. */ + | "broadcast" + /** Other RFC 6890 special-purpose ranges (0/8, 192.0.0/24, 240/4, 2001::/32, etc.). */ + | "reserved" + /** Cloud metadata service FQDN (e.g. `metadata.google.internal`). */ + | "cloudMetadata" + /** Any host not matching a special-purpose range above. */ + | "public"; + +/** + * The syntactic form of the input host: an IPv4 literal, an IPv6 literal, or + * a domain name. IPv4-mapped IPv6 (`::ffff:192.0.2.1`) is reported as `ipv4` + * because it's unmapped during canonicalization. + */ +export type HostLiteral = "ipv4" | "ipv6" | "fqdn"; + +/** + * Result of {@link classifyHost}. All fields are readonly. + * + * @property kind - Semantic classification per RFC 6890 + RFC 6761. + * @property literal - Syntactic form of the input (IPv4, IPv6, or FQDN). + * @property canonical - Lowercase, port-stripped, bracket-stripped, zone-id-stripped + * form suitable for equality comparison. IPv6 is expanded to full form. + * IPv4-mapped IPv6 is collapsed to the underlying IPv4. + */ +export interface HostClassification { + readonly kind: HostKind; + readonly literal: HostLiteral; + readonly canonical: string; +} + +/** + * Cloud provider instance metadata service FQDNs. These resolve to link-local + * IPs (usually `169.254.169.254`) inside their respective clouds and are + * prime SSRF targets. + * + * The IPs themselves are already caught by the `linkLocal` kind; this set + * only exists for the FQDN form that a naive server-side fetch might resolve + * via its own resolver. + */ +const CLOUD_METADATA_HOSTS: ReadonlySet = new Set([ + "metadata.google.internal", + "metadata.goog", + "metadata", + "instance-data", + "instance-data.ec2.internal", +]); + +/** Strip `[...]` if the entire input is bracketed (IPv6 literal form). */ +function stripBrackets(host: string): string { + if (host.length >= 2 && host.startsWith("[") && host.endsWith("]")) { + return host.slice(1, -1); + } + return host; +} + +/** + * Strip trailing `:port` from host-with-port strings. + * + * - Bracketed IPv6 with port: `[::1]:8080` → `[::1]` + * - IPv4/FQDN with port: `127.0.0.1:3000` / `example.com:443` → base form + * - Bare IPv6: `::1` / `fe80::1` → unchanged (multiple colons means no port) + */ +function stripPort(host: string): string { + if (host.startsWith("[")) { + const end = host.indexOf("]"); + if (end === -1) return host; + return host.slice(0, end + 1); + } + const firstColon = host.indexOf(":"); + if (firstColon === -1) return host; + if (host.indexOf(":", firstColon + 1) !== -1) return host; + return host.slice(0, firstColon); +} + +/** Strip IPv6 zone identifier: `fe80::1%eth0` → `fe80::1`. */ +function stripZoneId(host: string): string { + const zone = host.indexOf("%"); + if (zone === -1) return host; + return host.slice(0, zone); +} + +/** + * Strip trailing dots (RFC 1034 absolute DNS form): `localhost.` → `localhost`. + * Without this, `metadata.google.internal.` would fall through to `public` and + * bypass the cloud-metadata / `.localhost` checks, since WHATWG URL parsing + * preserves the trailing dot in `url.hostname`. + */ +function stripTrailingDot(host: string): string { + return host.replace(/\.+$/, ""); +} + +/** Fast dotted-decimal shape check. Does NOT validate octet bounds. */ +function looksLikeIPv4(host: string): boolean { + return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); +} + +/** Pack a validated dotted-decimal IPv4 into a 32-bit unsigned integer. */ +function ipv4ToUint32(ip: string): number { + const parts = ip.split("."); + return ( + ((Number(parts[0]) << 24) | + (Number(parts[1]) << 16) | + (Number(parts[2]) << 8) | + Number(parts[3])) >>> + 0 + ); +} + +/** Check whether a 32-bit value matches `prefix/length` (both unsigned). */ +function inIPv4Range(value: number, prefix: number, length: number): boolean { + if (length === 0) return true; + const mask = length === 32 ? 0xffffffff : (~0 << (32 - length)) >>> 0; + return (value & mask) === (prefix & mask); +} + +function classifyIPv4(ip: string): HostKind { + if (ip === "0.0.0.0") return "unspecified"; + if (ip === "255.255.255.255") return "broadcast"; + + const n = ipv4ToUint32(ip); + + if (inIPv4Range(n, ipv4ToUint32("127.0.0.0"), 8)) return "loopback"; + if (inIPv4Range(n, ipv4ToUint32("10.0.0.0"), 8)) return "private"; + if (inIPv4Range(n, ipv4ToUint32("172.16.0.0"), 12)) return "private"; + if (inIPv4Range(n, ipv4ToUint32("192.168.0.0"), 16)) return "private"; + if (inIPv4Range(n, ipv4ToUint32("169.254.0.0"), 16)) return "linkLocal"; + if (inIPv4Range(n, ipv4ToUint32("100.64.0.0"), 10)) + return "sharedAddressSpace"; + if (inIPv4Range(n, ipv4ToUint32("192.0.2.0"), 24)) return "documentation"; + if (inIPv4Range(n, ipv4ToUint32("198.51.100.0"), 24)) return "documentation"; + if (inIPv4Range(n, ipv4ToUint32("203.0.113.0"), 24)) return "documentation"; + if (inIPv4Range(n, ipv4ToUint32("198.18.0.0"), 15)) return "benchmarking"; + if (inIPv4Range(n, ipv4ToUint32("224.0.0.0"), 4)) return "multicast"; + if (inIPv4Range(n, ipv4ToUint32("0.0.0.0"), 8)) return "reserved"; + if (inIPv4Range(n, ipv4ToUint32("192.0.0.0"), 24)) return "reserved"; + if (inIPv4Range(n, ipv4ToUint32("240.0.0.0"), 4)) return "reserved"; + + return "public"; +} + +/** + * Extract an IPv4 address embedded in an expanded IPv6 literal. + * + * Used to recurse into tunnel/translation forms (6to4, NAT64, Teredo) so a + * private destination cannot be smuggled behind a syntactically-public IPv6 + * literal. `startGroup` is the index of the first of two 16-bit groups in the + * expanded form (`0000:0000:...`). With `xor: true`, the 32-bit value is XORed + * with `0xffffffff` before decoding (Teredo obfuscates the client IPv4 this + * way). + */ +function extractEmbeddedIPv4( + expanded: string, + startGroup: number, + options: { xor?: boolean } = {}, +): string | null { + const offset = startGroup * 5; + const g1 = Number.parseInt(expanded.slice(offset, offset + 4), 16); + const g2 = Number.parseInt(expanded.slice(offset + 5, offset + 9), 16); + if (!Number.isFinite(g1) || !Number.isFinite(g2)) return null; + let combined = ((g1 << 16) | g2) >>> 0; + if (options.xor) combined = (combined ^ 0xffffffff) >>> 0; + return `${(combined >>> 24) & 0xff}.${(combined >>> 16) & 0xff}.${(combined >>> 8) & 0xff}.${combined & 0xff}`; +} + +/** + * Classify an expanded, full-form, lowercase IPv6 address (no IPv4-mapped + * input — those are unmapped to IPv4 before reaching here). + * + * 6to4 (`2002::/16`), NAT64 (`64:ff9b::/96`) and Teredo (`2001:0000::/32`) + * embed an IPv4 that can route to private/loopback space. If the embedded + * IPv4 classifies as non-`public`, return `reserved` — blocks SSRF without + * advertising the address as a loopback literal for RFC 8252 §7.3 matching. + */ +function classifyIPv6(expanded: string): HostKind { + if (expanded === "0000:0000:0000:0000:0000:0000:0000:0000") + return "unspecified"; + if (expanded === "0000:0000:0000:0000:0000:0000:0000:0001") return "loopback"; + + const firstByte = Number.parseInt(expanded.slice(0, 2), 16); + const secondByte = Number.parseInt(expanded.slice(2, 4), 16); + + if (firstByte === 0xff) return "multicast"; + if (firstByte === 0xfe && (secondByte & 0xc0) === 0x80) return "linkLocal"; + if ((firstByte & 0xfe) === 0xfc) return "private"; + + if (expanded.startsWith("2001:0db8:")) return "documentation"; + + if (expanded.startsWith("2002:")) { + const embedded = extractEmbeddedIPv4(expanded, 1); + if (embedded && classifyIPv4(embedded) !== "public") return "reserved"; + return "public"; + } + + if (expanded.startsWith("0064:ff9b:0000:0000:0000:0000:")) { + const embedded = extractEmbeddedIPv4(expanded, 6); + if (embedded && classifyIPv4(embedded) !== "public") return "reserved"; + return "reserved"; + } + + if (expanded.startsWith("2001:0000:")) { + const embedded = extractEmbeddedIPv4(expanded, 6, { xor: true }); + if (embedded && classifyIPv4(embedded) !== "public") return "reserved"; + return "reserved"; + } + + if (expanded.startsWith("0100:0000:0000:0000:")) return "reserved"; + + return "public"; +} + +/** + * Classify a host string according to RFC 6890 / RFC 6761. + * + * Accepts inputs in any of these shapes and normalizes before classifying: + * + * - Bare IPv4: `127.0.0.1` + * - Bare IPv6: `::1`, `fe80::1%eth0` + * - Bracketed IPv6: `[::1]` + * - Host with port: `localhost:3000`, `127.0.0.1:443`, `[::1]:8080` + * - FQDN: `example.com`, `tenant.localhost` + * - IPv4-mapped IPv6: `::ffff:192.0.2.1` (reported as `literal: "ipv4"`) + * + * Invalid or non-resolvable FQDNs are returned as `{ kind: "public", literal: "fqdn" }` + * — this function never throws. Callers that need structural validation must + * combine this with a URL/hostname validator upstream. + * + * @example + * classifyHost("127.0.0.1") + * // { kind: "loopback", literal: "ipv4", canonical: "127.0.0.1" } + * + * @example + * classifyHost("[::1]:8080") + * // { kind: "loopback", literal: "ipv6", canonical: "0000:0000:...:0001" } + * + * @example + * classifyHost("::ffff:192.0.2.1") + * // { kind: "documentation", literal: "ipv4", canonical: "192.0.2.1" } + * + * @example + * classifyHost("tenant-a.localhost") + * // { kind: "localhost", literal: "fqdn", canonical: "tenant-a.localhost" } + */ +export function classifyHost(host: string): HostClassification { + const stripped = stripTrailingDot( + stripZoneId(stripBrackets(stripPort(host.trim()))), + ); + const lowered = stripped.toLowerCase(); + + if (lowered === "") { + return { kind: "reserved", literal: "fqdn", canonical: "" }; + } + + if (!isValidIP(lowered)) { + if (lowered === "localhost" || lowered.endsWith(".localhost")) { + return { kind: "localhost", literal: "fqdn", canonical: lowered }; + } + if (CLOUD_METADATA_HOSTS.has(lowered)) { + return { kind: "cloudMetadata", literal: "fqdn", canonical: lowered }; + } + return { kind: "public", literal: "fqdn", canonical: lowered }; + } + + if (looksLikeIPv4(lowered)) { + return { kind: classifyIPv4(lowered), literal: "ipv4", canonical: lowered }; + } + + const canonical = normalizeIP(lowered, { ipv6Subnet: 128 }); + + if (looksLikeIPv4(canonical)) { + return { + kind: classifyIPv4(canonical), + literal: "ipv4", + canonical, + }; + } + + return { kind: classifyIPv6(canonical), literal: "ipv6", canonical }; +} + +/** + * Strict loopback-IP-literal check per RFC 8252 §7.3. + * + * Returns true ONLY for IPv4 `127.0.0.0/8` or IPv6 `::1`. The DNS name + * `localhost` returns false — RFC 8252 §8.3 explicitly recommends against + * relying on name resolution for loopback redirect URIs. + * + * Use this for OAuth redirect URI matching. + * + * @example + * isLoopbackIP("127.0.0.1") // true + * isLoopbackIP("::1") // true + * isLoopbackIP("[::1]:8080") // true + * isLoopbackIP("localhost") // false (use isLoopbackHost for DNS names) + * isLoopbackIP("0.0.0.0") // false (unspecified, not loopback) + */ +export function isLoopbackIP(host: string): boolean { + return classifyHost(host).kind === "loopback"; +} + +/** + * Permissive loopback check for developer-ergonomics code paths. + * + * Returns true for IPv4 `127.0.0.0/8`, IPv6 `::1`, the literal name `localhost`, + * and any RFC 6761 `.localhost` subdomain (`tenant.localhost`, `app.localhost`). + * + * Use this for things like: allowing HTTP for dev servers, skipping Secure + * cookie requirements, browser-trust heuristics. Do NOT use this for OAuth + * redirect URI matching — use {@link isLoopbackIP} there. + * + * @example + * isLoopbackHost("localhost") // true + * isLoopbackHost("tenant.localhost") // true (RFC 6761) + * isLoopbackHost("127.0.0.1") // true + * isLoopbackHost("0.0.0.0") // false (unspecified, NOT loopback) + */ +export function isLoopbackHost(host: string): boolean { + const kind = classifyHost(host).kind; + return kind === "loopback" || kind === "localhost"; +} + +/** + * First-line SSRF gate: returns true ONLY for hosts that classify as `public`. + * + * Every RFC 6890 special-purpose range (loopback, private, link-local, + * unspecified, documentation, multicast, broadcast, reserved, shared address + * space, benchmarking) and cloud-metadata FQDN returns false. + * + * Use this BEFORE issuing a server-side fetch to a user-supplied URL, e.g. + * OAuth introspection endpoints, webhook targets, or metadata-document + * fetches (CIMD). + * + * Limitations (this is a syntactic check, not a complete SSRF mitigation): + * - No DNS resolution: a public-looking FQDN that resolves to a private IP + * passes this check. Re-verify the resolved address before connecting, or + * pin the socket to the resolved IP. + * - No DNS-rebinding defense: attackers can return a public IP on the first + * lookup and a private IP on the second. Resolve once and reuse the IP. + * - No redirect following: HTTP 3xx responses can redirect to private hosts. + * Re-run this check on every redirect target, or disable auto-follow. + * + * @example + * isPublicRoutableHost("example.com") // true + * isPublicRoutableHost("127.0.0.1") // false (loopback) + * isPublicRoutableHost("169.254.169.254") // false (linkLocal / AWS IMDS) + * isPublicRoutableHost("metadata.google.internal") // false (cloudMetadata) + * isPublicRoutableHost("10.0.0.1") // false (private) + * isPublicRoutableHost("::ffff:127.0.0.1") // false (mapped loopback) + */ +export function isPublicRoutableHost(host: string): boolean { + return classifyHost(host).kind === "public"; +} diff --git a/packages/electron/src/user.ts b/packages/electron/src/user.ts index ffd03ebefb..f888dec662 100644 --- a/packages/electron/src/user.ts +++ b/packages/electron/src/user.ts @@ -1,5 +1,6 @@ import type { User } from "@better-auth/core/db"; import { isDevelopment } from "@better-auth/core/env"; +import { isPublicRoutableHost } from "@better-auth/core/utils/host"; import { base64 } from "@better-auth/utils/base64"; import electron from "electron"; import type { ElectronClientOptions } from "./client"; @@ -47,7 +48,7 @@ export async function fetchUserImage( if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return null; } - if (!isDevelopment() && isLocalOrigin(parsed)) return null; + if (!isDevelopment() && !isPublicRoutableHost(parsed.hostname)) return null; resolvedUrl = parsed.href; } catch { return null; @@ -160,30 +161,6 @@ async function decodeDataImageUrl( } } -function isLocalOrigin(parsed: URL): boolean { - const hostname = parsed.hostname.toLowerCase(); - if (hostname === "localhost") return true; - // IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 - const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; - const m = hostname.match(ipv4); - if (m) { - const a = Number(m[1]); - const b = Number(m[2]); - if (a === 127) return true; - if (a === 10) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 192 && b === 168) return true; - if (a === 169 && b === 254) return true; - return false; - } - // IPv6: ::1 (loopback), fe80::/10 (link-local) - const h = hostname.replace(/^\[|\]$/g, ""); - if (h === "::1" || /^(0:){7}1$/.test(h) || h === "0:0:0:0:0:0:0:1") - return true; - if (/^fe[89ab][0-9a-f]/i.test(h)) return true; - return false; -} - type SupportedImageType = | "image/png" | "image/jpg" diff --git a/packages/oauth-provider/src/authorize.ts b/packages/oauth-provider/src/authorize.ts index 3b244f792b..7a167d7d43 100644 --- a/packages/oauth-provider/src/authorize.ts +++ b/packages/oauth-provider/src/authorize.ts @@ -1,5 +1,6 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata"; +import { isLoopbackHost, isLoopbackIP } from "@better-auth/core/utils/host"; import { getSessionFromCtx } from "better-auth/api"; import { generateRandomString, makeSignature } from "better-auth/crypto"; import type { Verification } from "better-auth/db"; @@ -94,9 +95,7 @@ export function validateIssuerUrl(issuer: string): string { try { const url = new URL(issuer); - const isLocalhost = - url.hostname === "localhost" || url.hostname === "127.0.0.1"; - if (url.protocol !== "https:" && !isLocalhost) { + if (url.protocol !== "https:" && !isLoopbackHost(url.host)) { url.protocol = "https:"; } @@ -265,10 +264,11 @@ export async function authorizeEndpoint( try { const registered = new URL(url); const requested = new URL(query.redirect_uri); - // RFC 8252 §7.3: loopback IPs match on scheme+host+path+query, ignoring port + // RFC 8252 §7.3: loopback IP literal URIs (127.0.0.0/8, ::1) match on + // scheme+host+path+query, ignoring port. §8.3 excludes DNS names like + // "localhost" — `isLoopbackIP` enforces IP-literal-only matching. if ( - (registered.hostname === "127.0.0.1" || - registered.hostname === "[::1]") && + isLoopbackIP(registered.hostname) && registered.hostname === requested.hostname && registered.pathname === requested.pathname && registered.protocol === requested.protocol && diff --git a/packages/oauth-provider/src/types/zod.ts b/packages/oauth-provider/src/types/zod.ts index f55e9e269b..5d2a8992e6 100644 --- a/packages/oauth-provider/src/types/zod.ts +++ b/packages/oauth-provider/src/types/zod.ts @@ -1,16 +1,8 @@ +import { isLoopbackHost } from "@better-auth/core/utils/host"; import * as z from "zod"; const DANGEROUS_SCHEMES = ["javascript:", "data:", "vbscript:"]; -function isLocalhost(hostname: string): boolean { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "[::1]" || - hostname.endsWith(".localhost") - ); -} - /** * Runtime schema for OAuthAuthorizationQuery. * Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.) @@ -55,7 +47,7 @@ export const verificationValueSchema = z /** * Reusable URL validation for OAuth redirect URIs. * - Blocks dangerous schemes (javascript:, data:, vbscript:) - * - For http/https: requires HTTPS (HTTP allowed only for localhost) + * - For http/https: requires HTTPS (HTTP allowed only for loopback hosts: 127.0.0.0/8, [::1], *.localhost per RFC 6761) * - Allows custom schemes for mobile apps (e.g., myapp://callback) */ export const SafeUrlSchema = z.url().superRefine((val, ctx) => { @@ -78,13 +70,11 @@ export const SafeUrlSchema = z.url().superRefine((val, ctx) => { return; } - if (u.protocol === "http:" || u.protocol === "https:") { - if (u.protocol === "http:" && !isLocalhost(u.hostname)) { - ctx.addIssue({ - code: "custom", - message: - "Redirect URI must use HTTPS (HTTP allowed only for localhost)", - }); - } + if (u.protocol === "http:" && !isLoopbackHost(u.host)) { + ctx.addIssue({ + code: "custom", + message: + "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)", + }); } });