mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-22 14:21:55 -05:00
fix: unify host classification and close SSRF gaps across packages (#9226)
This commit is contained in:
31
.changeset/swift-hosts-classify.md
Normal file
31
.changeset/swift-hosts-classify.md
Normal 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.
|
||||
@@ -58,3 +58,10 @@ GHEOF
|
||||
denoland
|
||||
zizmor
|
||||
zizmorcore
|
||||
IMDS
|
||||
WHATWG
|
||||
CIMD
|
||||
Oligo
|
||||
febf
|
||||
fdff
|
||||
fffe
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
485
packages/core/src/utils/host.test.ts
Normal file
485
packages/core/src/utils/host.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
401
packages/core/src/utils/host.ts
Normal file
401
packages/core/src/utils/host.ts
Normal 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";
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user