fix: unify host classification and close SSRF gaps across packages (#9226)

This commit is contained in:
Gustavo Valverde
2026-04-18 00:25:25 +01:00
committed by GitHub
parent e37167aaf5
commit e64ff720fb
10 changed files with 974 additions and 69 deletions

View File

@@ -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.

View File

@@ -58,3 +58,10 @@ GHEOF
denoland
zizmor
zizmorcore
IMDS
WHATWG
CIMD
Oligo
febf
fdff
fffe

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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`.

View File

@@ -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);
});
});
});

View File

@@ -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<string> = 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";
}

View File

@@ -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"

View File

@@ -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 &&

View File

@@ -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)",
});
}
});