@better-auth/core 1.6.4 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  const symbol = Symbol.for("better-auth:global");
3
3
  let bind = null;
4
4
  const __context = {};
5
- const __betterAuthVersion = "1.6.4";
5
+ const __betterAuthVersion = "1.6.6";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -0,0 +1,42 @@
1
+ //#region src/instrumentation/api.ts
2
+ function createNoopSpan() {
3
+ return {
4
+ end() {},
5
+ setAttribute(_key, _value) {},
6
+ setStatus(_status) {},
7
+ recordException(_exception) {}
8
+ };
9
+ }
10
+ function createNoopTracer(noopSpanInstance) {
11
+ function startActiveSpan(_name, _options, fn) {
12
+ return fn(noopSpanInstance);
13
+ }
14
+ return { startActiveSpan };
15
+ }
16
+ function createNoopTraceAPI() {
17
+ const noopTracer = createNoopTracer(createNoopSpan());
18
+ return { getTracer(_name, _version) {
19
+ return noopTracer;
20
+ } };
21
+ }
22
+ function createNoopOpenTelemetryAPI() {
23
+ return {
24
+ SpanStatusCode: {
25
+ UNSET: 0,
26
+ OK: 1,
27
+ ERROR: 2
28
+ },
29
+ trace: createNoopTraceAPI()
30
+ };
31
+ }
32
+ const noopOpenTelemetryAPI = createNoopOpenTelemetryAPI();
33
+ let openTelemetryAPIPromise;
34
+ let openTelemetryAPI;
35
+ function getOpenTelemetryAPI() {
36
+ if (!openTelemetryAPIPromise) openTelemetryAPIPromise = import("@opentelemetry/api").then((mod) => {
37
+ openTelemetryAPI = mod;
38
+ }).catch(() => void 0);
39
+ return openTelemetryAPI ?? noopOpenTelemetryAPI;
40
+ }
41
+ //#endregion
42
+ export { getOpenTelemetryAPI };
@@ -1,7 +1,8 @@
1
1
  import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";
2
- import { SpanStatusCode, trace } from "@opentelemetry/api";
2
+ import { getOpenTelemetryAPI } from "./api.mjs";
3
3
  //#region src/instrumentation/tracer.ts
4
- const tracer = trace.getTracer("better-auth", "1.6.4");
4
+ const INSTRUMENTATION_SCOPE = "better-auth";
5
+ const INSTRUMENTATION_VERSION = "1.6.6";
5
6
  /**
6
7
  * Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
7
8
  * callbacks). These are APIErrors with 3xx status codes and should not be
@@ -15,6 +16,7 @@ function isRedirectError(err) {
15
16
  return false;
16
17
  }
17
18
  function endSpanWithError(span, err) {
19
+ const { SpanStatusCode } = getOpenTelemetryAPI();
18
20
  if (isRedirectError(err)) {
19
21
  span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, err.statusCode);
20
22
  span.setStatus({ code: SpanStatusCode.OK });
@@ -28,7 +30,8 @@ function endSpanWithError(span, err) {
28
30
  span.end();
29
31
  }
30
32
  function withSpan(name, attributes, fn) {
31
- return tracer.startActiveSpan(name, { attributes }, (span) => {
33
+ const { trace } = getOpenTelemetryAPI();
34
+ return trace.getTracer(INSTRUMENTATION_SCOPE, INSTRUMENTATION_VERSION).startActiveSpan(name, { attributes }, (span) => {
32
35
  try {
33
36
  const result = fn();
34
37
  if (result instanceof Promise) return result.then((value) => {
@@ -0,0 +1,22 @@
1
+ import { Awaitable } from "../types/helper.mjs";
2
+
3
+ //#region src/utils/async.d.ts
4
+ interface MapConcurrentOptions {
5
+ /**
6
+ * Max in-flight mappers. Non-integer values are floored, then clamped
7
+ * to the range `[1, items.length]`. `NaN` falls back to 1.
8
+ */
9
+ concurrency: number;
10
+ /**
11
+ * Rejects with `signal.reason` when aborted. In-flight mappers keep
12
+ * running but their results are not returned.
13
+ */
14
+ signal?: AbortSignal;
15
+ }
16
+ /**
17
+ * Run an async mapper over items with bounded concurrency.
18
+ * Preserves input order in the result. Fails fast on the first rejection.
19
+ */
20
+ declare function mapConcurrent<T, R>(items: readonly T[], fn: (item: T, index: number) => Awaitable<R>, options: MapConcurrentOptions): Promise<R[]>;
21
+ //#endregion
22
+ export { MapConcurrentOptions, mapConcurrent };
@@ -0,0 +1,32 @@
1
+ //#region src/utils/async.ts
2
+ /**
3
+ * Run an async mapper over items with bounded concurrency.
4
+ * Preserves input order in the result. Fails fast on the first rejection.
5
+ */
6
+ async function mapConcurrent(items, fn, options) {
7
+ const n = items.length;
8
+ if (n === 0) return [];
9
+ const { signal } = options;
10
+ if (signal?.aborted) throw signal.reason;
11
+ const raw = Math.floor(options.concurrency);
12
+ const width = Math.min(n, raw >= 1 ? raw : 1);
13
+ const results = new Array(n);
14
+ let idx = 0;
15
+ let failed = false;
16
+ const worker = async () => {
17
+ while (!failed && idx < n) {
18
+ if (signal?.aborted) throw signal.reason;
19
+ const i = idx++;
20
+ try {
21
+ results[i] = await fn(items[i], i);
22
+ } catch (error) {
23
+ failed = true;
24
+ throw error;
25
+ }
26
+ }
27
+ };
28
+ await Promise.all(Array.from({ length: width }, worker));
29
+ return results;
30
+ }
31
+ //#endregion
32
+ export { mapConcurrent };
@@ -0,0 +1,147 @@
1
+ //#region src/utils/host.d.ts
2
+ /**
3
+ * Host classification per RFC 6890 (Special-Purpose IP Address Registries),
4
+ * RFC 6761 (Special-Use Domain Names), and RFC 8252 §7.3 (loopback redirect URIs).
5
+ *
6
+ * This module is the single source of truth for "is this host public? private?
7
+ * loopback? link-local?" in the codebase. Consumers MUST prefer these predicates
8
+ * over bespoke regexes or substring matches; divergent checks are how bypass
9
+ * vulnerabilities get introduced (e.g. Oligo's "0.0.0.0 Day" 2024).
10
+ *
11
+ * Four user-facing primitives:
12
+ *
13
+ * - `classifyHost(host)` — the workhorse. Returns a {@link HostClassification}
14
+ * with `kind`, `literal`, and `canonical` fields.
15
+ * - `isLoopbackIP(host)` — strict: IPv4 `127.0.0.0/8` or IPv6 `::1` only.
16
+ * Use this for RFC 8252 §7.3 loopback redirect URI matching where IP
17
+ * literals are REQUIRED.
18
+ * - `isLoopbackHost(host)` — permissive: also accepts `localhost` and RFC 6761
19
+ * `.localhost` subdomains. Use this for developer ergonomics (CORS, cookie
20
+ * secure bypass, dev-mode HTTP allow-list).
21
+ * - `isPublicRoutableHost(host)` — SSRF gate. Returns false for every
22
+ * non-`public` kind. Use this before server-side fetches to user-controlled
23
+ * URLs.
24
+ */
25
+ /**
26
+ * The semantic kind of a host, derived from RFC 6890 special-purpose registries
27
+ * plus a few domain-name categories (localhost, cloud metadata FQDNs).
28
+ */
29
+ 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";
30
+ /**
31
+ * The syntactic form of the input host: an IPv4 literal, an IPv6 literal, or
32
+ * a domain name. IPv4-mapped IPv6 (`::ffff:192.0.2.1`) is reported as `ipv4`
33
+ * because it's unmapped during canonicalization.
34
+ */
35
+ type HostLiteral = "ipv4" | "ipv6" | "fqdn";
36
+ /**
37
+ * Result of {@link classifyHost}. All fields are readonly.
38
+ *
39
+ * @property kind - Semantic classification per RFC 6890 + RFC 6761.
40
+ * @property literal - Syntactic form of the input (IPv4, IPv6, or FQDN).
41
+ * @property canonical - Lowercase, port-stripped, bracket-stripped, zone-id-stripped
42
+ * form suitable for equality comparison. IPv6 is expanded to full form.
43
+ * IPv4-mapped IPv6 is collapsed to the underlying IPv4.
44
+ */
45
+ interface HostClassification {
46
+ readonly kind: HostKind;
47
+ readonly literal: HostLiteral;
48
+ readonly canonical: string;
49
+ }
50
+ /**
51
+ * Classify a host string according to RFC 6890 / RFC 6761.
52
+ *
53
+ * Accepts inputs in any of these shapes and normalizes before classifying:
54
+ *
55
+ * - Bare IPv4: `127.0.0.1`
56
+ * - Bare IPv6: `::1`, `fe80::1%eth0`
57
+ * - Bracketed IPv6: `[::1]`
58
+ * - Host with port: `localhost:3000`, `127.0.0.1:443`, `[::1]:8080`
59
+ * - FQDN: `example.com`, `tenant.localhost`
60
+ * - IPv4-mapped IPv6: `::ffff:192.0.2.1` (reported as `literal: "ipv4"`)
61
+ *
62
+ * Invalid or non-resolvable FQDNs are returned as `{ kind: "public", literal: "fqdn" }`
63
+ * — this function never throws. Callers that need structural validation must
64
+ * combine this with a URL/hostname validator upstream.
65
+ *
66
+ * @example
67
+ * classifyHost("127.0.0.1")
68
+ * // { kind: "loopback", literal: "ipv4", canonical: "127.0.0.1" }
69
+ *
70
+ * @example
71
+ * classifyHost("[::1]:8080")
72
+ * // { kind: "loopback", literal: "ipv6", canonical: "0000:0000:...:0001" }
73
+ *
74
+ * @example
75
+ * classifyHost("::ffff:192.0.2.1")
76
+ * // { kind: "documentation", literal: "ipv4", canonical: "192.0.2.1" }
77
+ *
78
+ * @example
79
+ * classifyHost("tenant-a.localhost")
80
+ * // { kind: "localhost", literal: "fqdn", canonical: "tenant-a.localhost" }
81
+ */
82
+ declare function classifyHost(host: string): HostClassification;
83
+ /**
84
+ * Strict loopback-IP-literal check per RFC 8252 §7.3.
85
+ *
86
+ * Returns true ONLY for IPv4 `127.0.0.0/8` or IPv6 `::1`. The DNS name
87
+ * `localhost` returns false — RFC 8252 §8.3 explicitly recommends against
88
+ * relying on name resolution for loopback redirect URIs.
89
+ *
90
+ * Use this for OAuth redirect URI matching.
91
+ *
92
+ * @example
93
+ * isLoopbackIP("127.0.0.1") // true
94
+ * isLoopbackIP("::1") // true
95
+ * isLoopbackIP("[::1]:8080") // true
96
+ * isLoopbackIP("localhost") // false (use isLoopbackHost for DNS names)
97
+ * isLoopbackIP("0.0.0.0") // false (unspecified, not loopback)
98
+ */
99
+ declare function isLoopbackIP(host: string): boolean;
100
+ /**
101
+ * Permissive loopback check for developer-ergonomics code paths.
102
+ *
103
+ * Returns true for IPv4 `127.0.0.0/8`, IPv6 `::1`, the literal name `localhost`,
104
+ * and any RFC 6761 `.localhost` subdomain (`tenant.localhost`, `app.localhost`).
105
+ *
106
+ * Use this for things like: allowing HTTP for dev servers, skipping Secure
107
+ * cookie requirements, browser-trust heuristics. Do NOT use this for OAuth
108
+ * redirect URI matching — use {@link isLoopbackIP} there.
109
+ *
110
+ * @example
111
+ * isLoopbackHost("localhost") // true
112
+ * isLoopbackHost("tenant.localhost") // true (RFC 6761)
113
+ * isLoopbackHost("127.0.0.1") // true
114
+ * isLoopbackHost("0.0.0.0") // false (unspecified, NOT loopback)
115
+ */
116
+ declare function isLoopbackHost(host: string): boolean;
117
+ /**
118
+ * First-line SSRF gate: returns true ONLY for hosts that classify as `public`.
119
+ *
120
+ * Every RFC 6890 special-purpose range (loopback, private, link-local,
121
+ * unspecified, documentation, multicast, broadcast, reserved, shared address
122
+ * space, benchmarking) and cloud-metadata FQDN returns false.
123
+ *
124
+ * Use this BEFORE issuing a server-side fetch to a user-supplied URL, e.g.
125
+ * OAuth introspection endpoints, webhook targets, or metadata-document
126
+ * fetches (CIMD).
127
+ *
128
+ * Limitations (this is a syntactic check, not a complete SSRF mitigation):
129
+ * - No DNS resolution: a public-looking FQDN that resolves to a private IP
130
+ * passes this check. Re-verify the resolved address before connecting, or
131
+ * pin the socket to the resolved IP.
132
+ * - No DNS-rebinding defense: attackers can return a public IP on the first
133
+ * lookup and a private IP on the second. Resolve once and reuse the IP.
134
+ * - No redirect following: HTTP 3xx responses can redirect to private hosts.
135
+ * Re-run this check on every redirect target, or disable auto-follow.
136
+ *
137
+ * @example
138
+ * isPublicRoutableHost("example.com") // true
139
+ * isPublicRoutableHost("127.0.0.1") // false (loopback)
140
+ * isPublicRoutableHost("169.254.169.254") // false (linkLocal / AWS IMDS)
141
+ * isPublicRoutableHost("metadata.google.internal") // false (cloudMetadata)
142
+ * isPublicRoutableHost("10.0.0.1") // false (private)
143
+ * isPublicRoutableHost("::ffff:127.0.0.1") // false (mapped loopback)
144
+ */
145
+ declare function isPublicRoutableHost(host: string): boolean;
146
+ //#endregion
147
+ export { HostClassification, HostKind, HostLiteral, classifyHost, isLoopbackHost, isLoopbackIP, isPublicRoutableHost };
@@ -0,0 +1,291 @@
1
+ import { isValidIP, normalizeIP } from "./ip.mjs";
2
+ //#region src/utils/host.ts
3
+ /**
4
+ * Cloud provider instance metadata service FQDNs. These resolve to link-local
5
+ * IPs (usually `169.254.169.254`) inside their respective clouds and are
6
+ * prime SSRF targets.
7
+ *
8
+ * The IPs themselves are already caught by the `linkLocal` kind; this set
9
+ * only exists for the FQDN form that a naive server-side fetch might resolve
10
+ * via its own resolver.
11
+ */
12
+ const CLOUD_METADATA_HOSTS = new Set([
13
+ "metadata.google.internal",
14
+ "metadata.goog",
15
+ "metadata",
16
+ "instance-data",
17
+ "instance-data.ec2.internal"
18
+ ]);
19
+ /** Strip `[...]` if the entire input is bracketed (IPv6 literal form). */
20
+ function stripBrackets(host) {
21
+ if (host.length >= 2 && host.startsWith("[") && host.endsWith("]")) return host.slice(1, -1);
22
+ return host;
23
+ }
24
+ /**
25
+ * Strip trailing `:port` from host-with-port strings.
26
+ *
27
+ * - Bracketed IPv6 with port: `[::1]:8080` → `[::1]`
28
+ * - IPv4/FQDN with port: `127.0.0.1:3000` / `example.com:443` → base form
29
+ * - Bare IPv6: `::1` / `fe80::1` → unchanged (multiple colons means no port)
30
+ */
31
+ function stripPort(host) {
32
+ if (host.startsWith("[")) {
33
+ const end = host.indexOf("]");
34
+ if (end === -1) return host;
35
+ return host.slice(0, end + 1);
36
+ }
37
+ const firstColon = host.indexOf(":");
38
+ if (firstColon === -1) return host;
39
+ if (host.indexOf(":", firstColon + 1) !== -1) return host;
40
+ return host.slice(0, firstColon);
41
+ }
42
+ /** Strip IPv6 zone identifier: `fe80::1%eth0` → `fe80::1`. */
43
+ function stripZoneId(host) {
44
+ const zone = host.indexOf("%");
45
+ if (zone === -1) return host;
46
+ return host.slice(0, zone);
47
+ }
48
+ /**
49
+ * Strip trailing dots (RFC 1034 absolute DNS form): `localhost.` → `localhost`.
50
+ * Without this, `metadata.google.internal.` would fall through to `public` and
51
+ * bypass the cloud-metadata / `.localhost` checks, since WHATWG URL parsing
52
+ * preserves the trailing dot in `url.hostname`.
53
+ */
54
+ function stripTrailingDot(host) {
55
+ return host.replace(/\.+$/, "");
56
+ }
57
+ /** Fast dotted-decimal shape check. Does NOT validate octet bounds. */
58
+ function looksLikeIPv4(host) {
59
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
60
+ }
61
+ /** Pack a validated dotted-decimal IPv4 into a 32-bit unsigned integer. */
62
+ function ipv4ToUint32(ip) {
63
+ const parts = ip.split(".");
64
+ return (Number(parts[0]) << 24 | Number(parts[1]) << 16 | Number(parts[2]) << 8 | Number(parts[3])) >>> 0;
65
+ }
66
+ /** Check whether a 32-bit value matches `prefix/length` (both unsigned). */
67
+ function inIPv4Range(value, prefix, length) {
68
+ if (length === 0) return true;
69
+ const mask = length === 32 ? 4294967295 : -1 << 32 - length >>> 0;
70
+ return (value & mask) === (prefix & mask);
71
+ }
72
+ function classifyIPv4(ip) {
73
+ if (ip === "0.0.0.0") return "unspecified";
74
+ if (ip === "255.255.255.255") return "broadcast";
75
+ const n = ipv4ToUint32(ip);
76
+ if (inIPv4Range(n, ipv4ToUint32("127.0.0.0"), 8)) return "loopback";
77
+ if (inIPv4Range(n, ipv4ToUint32("10.0.0.0"), 8)) return "private";
78
+ if (inIPv4Range(n, ipv4ToUint32("172.16.0.0"), 12)) return "private";
79
+ if (inIPv4Range(n, ipv4ToUint32("192.168.0.0"), 16)) return "private";
80
+ if (inIPv4Range(n, ipv4ToUint32("169.254.0.0"), 16)) return "linkLocal";
81
+ if (inIPv4Range(n, ipv4ToUint32("100.64.0.0"), 10)) return "sharedAddressSpace";
82
+ if (inIPv4Range(n, ipv4ToUint32("192.0.2.0"), 24)) return "documentation";
83
+ if (inIPv4Range(n, ipv4ToUint32("198.51.100.0"), 24)) return "documentation";
84
+ if (inIPv4Range(n, ipv4ToUint32("203.0.113.0"), 24)) return "documentation";
85
+ if (inIPv4Range(n, ipv4ToUint32("198.18.0.0"), 15)) return "benchmarking";
86
+ if (inIPv4Range(n, ipv4ToUint32("224.0.0.0"), 4)) return "multicast";
87
+ if (inIPv4Range(n, ipv4ToUint32("0.0.0.0"), 8)) return "reserved";
88
+ if (inIPv4Range(n, ipv4ToUint32("192.0.0.0"), 24)) return "reserved";
89
+ if (inIPv4Range(n, ipv4ToUint32("240.0.0.0"), 4)) return "reserved";
90
+ return "public";
91
+ }
92
+ /**
93
+ * Extract an IPv4 address embedded in an expanded IPv6 literal.
94
+ *
95
+ * Used to recurse into tunnel/translation forms (6to4, NAT64, Teredo) so a
96
+ * private destination cannot be smuggled behind a syntactically-public IPv6
97
+ * literal. `startGroup` is the index of the first of two 16-bit groups in the
98
+ * expanded form (`0000:0000:...`). With `xor: true`, the 32-bit value is XORed
99
+ * with `0xffffffff` before decoding (Teredo obfuscates the client IPv4 this
100
+ * way).
101
+ */
102
+ function extractEmbeddedIPv4(expanded, startGroup, options = {}) {
103
+ const offset = startGroup * 5;
104
+ const g1 = Number.parseInt(expanded.slice(offset, offset + 4), 16);
105
+ const g2 = Number.parseInt(expanded.slice(offset + 5, offset + 9), 16);
106
+ if (!Number.isFinite(g1) || !Number.isFinite(g2)) return null;
107
+ let combined = (g1 << 16 | g2) >>> 0;
108
+ if (options.xor) combined = (combined ^ 4294967295) >>> 0;
109
+ return `${combined >>> 24 & 255}.${combined >>> 16 & 255}.${combined >>> 8 & 255}.${combined & 255}`;
110
+ }
111
+ /**
112
+ * Classify an expanded, full-form, lowercase IPv6 address (no IPv4-mapped
113
+ * input — those are unmapped to IPv4 before reaching here).
114
+ *
115
+ * 6to4 (`2002::/16`), NAT64 (`64:ff9b::/96`) and Teredo (`2001:0000::/32`)
116
+ * embed an IPv4 that can route to private/loopback space. If the embedded
117
+ * IPv4 classifies as non-`public`, return `reserved` — blocks SSRF without
118
+ * advertising the address as a loopback literal for RFC 8252 §7.3 matching.
119
+ */
120
+ function classifyIPv6(expanded) {
121
+ if (expanded === "0000:0000:0000:0000:0000:0000:0000:0000") return "unspecified";
122
+ if (expanded === "0000:0000:0000:0000:0000:0000:0000:0001") return "loopback";
123
+ const firstByte = Number.parseInt(expanded.slice(0, 2), 16);
124
+ const secondByte = Number.parseInt(expanded.slice(2, 4), 16);
125
+ if (firstByte === 255) return "multicast";
126
+ if (firstByte === 254 && (secondByte & 192) === 128) return "linkLocal";
127
+ if ((firstByte & 254) === 252) return "private";
128
+ if (expanded.startsWith("2001:0db8:")) return "documentation";
129
+ if (expanded.startsWith("2002:")) {
130
+ const embedded = extractEmbeddedIPv4(expanded, 1);
131
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
132
+ return "public";
133
+ }
134
+ if (expanded.startsWith("0064:ff9b:0000:0000:0000:0000:")) {
135
+ const embedded = extractEmbeddedIPv4(expanded, 6);
136
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
137
+ return "reserved";
138
+ }
139
+ if (expanded.startsWith("2001:0000:")) {
140
+ const embedded = extractEmbeddedIPv4(expanded, 6, { xor: true });
141
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
142
+ return "reserved";
143
+ }
144
+ if (expanded.startsWith("0100:0000:0000:0000:")) return "reserved";
145
+ return "public";
146
+ }
147
+ /**
148
+ * Classify a host string according to RFC 6890 / RFC 6761.
149
+ *
150
+ * Accepts inputs in any of these shapes and normalizes before classifying:
151
+ *
152
+ * - Bare IPv4: `127.0.0.1`
153
+ * - Bare IPv6: `::1`, `fe80::1%eth0`
154
+ * - Bracketed IPv6: `[::1]`
155
+ * - Host with port: `localhost:3000`, `127.0.0.1:443`, `[::1]:8080`
156
+ * - FQDN: `example.com`, `tenant.localhost`
157
+ * - IPv4-mapped IPv6: `::ffff:192.0.2.1` (reported as `literal: "ipv4"`)
158
+ *
159
+ * Invalid or non-resolvable FQDNs are returned as `{ kind: "public", literal: "fqdn" }`
160
+ * — this function never throws. Callers that need structural validation must
161
+ * combine this with a URL/hostname validator upstream.
162
+ *
163
+ * @example
164
+ * classifyHost("127.0.0.1")
165
+ * // { kind: "loopback", literal: "ipv4", canonical: "127.0.0.1" }
166
+ *
167
+ * @example
168
+ * classifyHost("[::1]:8080")
169
+ * // { kind: "loopback", literal: "ipv6", canonical: "0000:0000:...:0001" }
170
+ *
171
+ * @example
172
+ * classifyHost("::ffff:192.0.2.1")
173
+ * // { kind: "documentation", literal: "ipv4", canonical: "192.0.2.1" }
174
+ *
175
+ * @example
176
+ * classifyHost("tenant-a.localhost")
177
+ * // { kind: "localhost", literal: "fqdn", canonical: "tenant-a.localhost" }
178
+ */
179
+ function classifyHost(host) {
180
+ const lowered = stripTrailingDot(stripZoneId(stripBrackets(stripPort(host.trim())))).toLowerCase();
181
+ if (lowered === "") return {
182
+ kind: "reserved",
183
+ literal: "fqdn",
184
+ canonical: ""
185
+ };
186
+ if (!isValidIP(lowered)) {
187
+ if (lowered === "localhost" || lowered.endsWith(".localhost")) return {
188
+ kind: "localhost",
189
+ literal: "fqdn",
190
+ canonical: lowered
191
+ };
192
+ if (CLOUD_METADATA_HOSTS.has(lowered)) return {
193
+ kind: "cloudMetadata",
194
+ literal: "fqdn",
195
+ canonical: lowered
196
+ };
197
+ return {
198
+ kind: "public",
199
+ literal: "fqdn",
200
+ canonical: lowered
201
+ };
202
+ }
203
+ if (looksLikeIPv4(lowered)) return {
204
+ kind: classifyIPv4(lowered),
205
+ literal: "ipv4",
206
+ canonical: lowered
207
+ };
208
+ const canonical = normalizeIP(lowered, { ipv6Subnet: 128 });
209
+ if (looksLikeIPv4(canonical)) return {
210
+ kind: classifyIPv4(canonical),
211
+ literal: "ipv4",
212
+ canonical
213
+ };
214
+ return {
215
+ kind: classifyIPv6(canonical),
216
+ literal: "ipv6",
217
+ canonical
218
+ };
219
+ }
220
+ /**
221
+ * Strict loopback-IP-literal check per RFC 8252 §7.3.
222
+ *
223
+ * Returns true ONLY for IPv4 `127.0.0.0/8` or IPv6 `::1`. The DNS name
224
+ * `localhost` returns false — RFC 8252 §8.3 explicitly recommends against
225
+ * relying on name resolution for loopback redirect URIs.
226
+ *
227
+ * Use this for OAuth redirect URI matching.
228
+ *
229
+ * @example
230
+ * isLoopbackIP("127.0.0.1") // true
231
+ * isLoopbackIP("::1") // true
232
+ * isLoopbackIP("[::1]:8080") // true
233
+ * isLoopbackIP("localhost") // false (use isLoopbackHost for DNS names)
234
+ * isLoopbackIP("0.0.0.0") // false (unspecified, not loopback)
235
+ */
236
+ function isLoopbackIP(host) {
237
+ return classifyHost(host).kind === "loopback";
238
+ }
239
+ /**
240
+ * Permissive loopback check for developer-ergonomics code paths.
241
+ *
242
+ * Returns true for IPv4 `127.0.0.0/8`, IPv6 `::1`, the literal name `localhost`,
243
+ * and any RFC 6761 `.localhost` subdomain (`tenant.localhost`, `app.localhost`).
244
+ *
245
+ * Use this for things like: allowing HTTP for dev servers, skipping Secure
246
+ * cookie requirements, browser-trust heuristics. Do NOT use this for OAuth
247
+ * redirect URI matching — use {@link isLoopbackIP} there.
248
+ *
249
+ * @example
250
+ * isLoopbackHost("localhost") // true
251
+ * isLoopbackHost("tenant.localhost") // true (RFC 6761)
252
+ * isLoopbackHost("127.0.0.1") // true
253
+ * isLoopbackHost("0.0.0.0") // false (unspecified, NOT loopback)
254
+ */
255
+ function isLoopbackHost(host) {
256
+ const kind = classifyHost(host).kind;
257
+ return kind === "loopback" || kind === "localhost";
258
+ }
259
+ /**
260
+ * First-line SSRF gate: returns true ONLY for hosts that classify as `public`.
261
+ *
262
+ * Every RFC 6890 special-purpose range (loopback, private, link-local,
263
+ * unspecified, documentation, multicast, broadcast, reserved, shared address
264
+ * space, benchmarking) and cloud-metadata FQDN returns false.
265
+ *
266
+ * Use this BEFORE issuing a server-side fetch to a user-supplied URL, e.g.
267
+ * OAuth introspection endpoints, webhook targets, or metadata-document
268
+ * fetches (CIMD).
269
+ *
270
+ * Limitations (this is a syntactic check, not a complete SSRF mitigation):
271
+ * - No DNS resolution: a public-looking FQDN that resolves to a private IP
272
+ * passes this check. Re-verify the resolved address before connecting, or
273
+ * pin the socket to the resolved IP.
274
+ * - No DNS-rebinding defense: attackers can return a public IP on the first
275
+ * lookup and a private IP on the second. Resolve once and reuse the IP.
276
+ * - No redirect following: HTTP 3xx responses can redirect to private hosts.
277
+ * Re-run this check on every redirect target, or disable auto-follow.
278
+ *
279
+ * @example
280
+ * isPublicRoutableHost("example.com") // true
281
+ * isPublicRoutableHost("127.0.0.1") // false (loopback)
282
+ * isPublicRoutableHost("169.254.169.254") // false (linkLocal / AWS IMDS)
283
+ * isPublicRoutableHost("metadata.google.internal") // false (cloudMetadata)
284
+ * isPublicRoutableHost("10.0.0.1") // false (private)
285
+ * isPublicRoutableHost("::ffff:127.0.0.1") // false (mapped loopback)
286
+ */
287
+ function isPublicRoutableHost(host) {
288
+ return classifyHost(host).kind === "public";
289
+ }
290
+ //#endregion
291
+ export { classifyHost, isLoopbackHost, isLoopbackIP, isPublicRoutableHost };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -170,6 +170,9 @@
170
170
  "peerDependenciesMeta": {
171
171
  "@cloudflare/workers-types": {
172
172
  "optional": true
173
+ },
174
+ "@opentelemetry/api": {
175
+ "optional": true
173
176
  }
174
177
  },
175
178
  "scripts": {
@@ -0,0 +1,64 @@
1
+ import type { Span, Tracer } from "@opentelemetry/api";
2
+
3
+ type OpenTelemetryAPI = Pick<
4
+ typeof import("@opentelemetry/api"),
5
+ "trace" | "SpanStatusCode"
6
+ >;
7
+
8
+ function createNoopSpan(): Span {
9
+ return {
10
+ end(): void {},
11
+ setAttribute(_key: string, _value: unknown): void {},
12
+ setStatus(_status: unknown): void {},
13
+ recordException(_exception: unknown): void {},
14
+ } as Span;
15
+ }
16
+
17
+ function createNoopTracer(noopSpanInstance: Span): Tracer {
18
+ function startActiveSpan<F extends (span: Span) => unknown>(
19
+ _name: string,
20
+ _options: { attributes?: Record<string, string | number | boolean> },
21
+ fn: F,
22
+ ): ReturnType<F> {
23
+ return fn(noopSpanInstance) as ReturnType<F>;
24
+ }
25
+
26
+ return { startActiveSpan } as Tracer;
27
+ }
28
+
29
+ function createNoopTraceAPI() {
30
+ const noopTracer = createNoopTracer(createNoopSpan());
31
+ return {
32
+ getTracer(_name?: string, _version?: string) {
33
+ return noopTracer;
34
+ },
35
+ };
36
+ }
37
+
38
+ function createNoopOpenTelemetryAPI(): OpenTelemetryAPI {
39
+ return {
40
+ SpanStatusCode: {
41
+ UNSET: 0,
42
+ OK: 1,
43
+ ERROR: 2,
44
+ },
45
+ trace: createNoopTraceAPI(),
46
+ } as OpenTelemetryAPI;
47
+ }
48
+
49
+ const noopOpenTelemetryAPI = createNoopOpenTelemetryAPI();
50
+
51
+ let openTelemetryAPIPromise: Promise<void> | undefined;
52
+ let openTelemetryAPI: OpenTelemetryAPI | undefined;
53
+
54
+ export function getOpenTelemetryAPI(): OpenTelemetryAPI {
55
+ if (!openTelemetryAPIPromise) {
56
+ openTelemetryAPIPromise = import("@opentelemetry/api")
57
+ .then((mod) => {
58
+ openTelemetryAPI = mod;
59
+ })
60
+ .catch(() => /* ignore failures */ undefined);
61
+ }
62
+
63
+ return openTelemetryAPI ?? noopOpenTelemetryAPI;
64
+ }
@@ -1,12 +1,10 @@
1
1
  import type { Span } from "@opentelemetry/api";
2
- import { SpanStatusCode, trace } from "@opentelemetry/api";
2
+ import { getOpenTelemetryAPI } from "./api";
3
3
  import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes";
4
4
 
5
5
  const INSTRUMENTATION_SCOPE = "better-auth";
6
6
  const INSTRUMENTATION_VERSION = import.meta.env?.BETTER_AUTH_VERSION ?? "1.0.0";
7
7
 
8
- const tracer = trace.getTracer(INSTRUMENTATION_SCOPE, INSTRUMENTATION_VERSION);
9
-
10
8
  /**
11
9
  * Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
12
10
  * callbacks). These are APIErrors with 3xx status codes and should not be
@@ -27,6 +25,7 @@ function isRedirectError(err: unknown): boolean {
27
25
  }
28
26
 
29
27
  function endSpanWithError(span: Span, err: unknown) {
28
+ const { SpanStatusCode } = getOpenTelemetryAPI();
30
29
  if (isRedirectError(err)) {
31
30
  span.setAttribute(
32
31
  ATTR_HTTP_RESPONSE_STATUS_CODE,
@@ -66,6 +65,12 @@ export function withSpan<T>(
66
65
  attributes: Record<string, string | number | boolean>,
67
66
  fn: () => T | Promise<T>,
68
67
  ): T | Promise<T> {
68
+ const { trace } = getOpenTelemetryAPI();
69
+ const tracer = trace.getTracer(
70
+ INSTRUMENTATION_SCOPE,
71
+ INSTRUMENTATION_VERSION,
72
+ );
73
+
69
74
  return tracer.startActiveSpan(name, { attributes }, (span) => {
70
75
  try {
71
76
  const result = fn();
@@ -0,0 +1,53 @@
1
+ import type { Awaitable } from "../types/helper";
2
+
3
+ export interface MapConcurrentOptions {
4
+ /**
5
+ * Max in-flight mappers. Non-integer values are floored, then clamped
6
+ * to the range `[1, items.length]`. `NaN` falls back to 1.
7
+ */
8
+ concurrency: number;
9
+ /**
10
+ * Rejects with `signal.reason` when aborted. In-flight mappers keep
11
+ * running but their results are not returned.
12
+ */
13
+ signal?: AbortSignal;
14
+ }
15
+
16
+ /**
17
+ * Run an async mapper over items with bounded concurrency.
18
+ * Preserves input order in the result. Fails fast on the first rejection.
19
+ */
20
+ export async function mapConcurrent<T, R>(
21
+ items: readonly T[],
22
+ fn: (item: T, index: number) => Awaitable<R>,
23
+ options: MapConcurrentOptions,
24
+ ): Promise<R[]> {
25
+ const n = items.length;
26
+ if (n === 0) return [];
27
+
28
+ const { signal } = options;
29
+ if (signal?.aborted) throw signal.reason;
30
+
31
+ const raw = Math.floor(options.concurrency);
32
+ const width = Math.min(n, raw >= 1 ? raw : 1);
33
+
34
+ const results = new Array<R>(n);
35
+ let idx = 0;
36
+ let failed = false;
37
+
38
+ const worker = async (): Promise<void> => {
39
+ while (!failed && idx < n) {
40
+ if (signal?.aborted) throw signal.reason;
41
+ const i = idx++;
42
+ try {
43
+ results[i] = await fn(items[i] as T, i);
44
+ } catch (error) {
45
+ failed = true;
46
+ throw error;
47
+ }
48
+ }
49
+ };
50
+
51
+ await Promise.all(Array.from({ length: width }, worker));
52
+ return results;
53
+ }
@@ -0,0 +1,401 @@
1
+ import { isValidIP, normalizeIP } from "./ip";
2
+
3
+ /**
4
+ * Host classification per RFC 6890 (Special-Purpose IP Address Registries),
5
+ * RFC 6761 (Special-Use Domain Names), and RFC 8252 §7.3 (loopback redirect URIs).
6
+ *
7
+ * This module is the single source of truth for "is this host public? private?
8
+ * loopback? link-local?" in the codebase. Consumers MUST prefer these predicates
9
+ * over bespoke regexes or substring matches; divergent checks are how bypass
10
+ * vulnerabilities get introduced (e.g. Oligo's "0.0.0.0 Day" 2024).
11
+ *
12
+ * Four user-facing primitives:
13
+ *
14
+ * - `classifyHost(host)` — the workhorse. Returns a {@link HostClassification}
15
+ * with `kind`, `literal`, and `canonical` fields.
16
+ * - `isLoopbackIP(host)` — strict: IPv4 `127.0.0.0/8` or IPv6 `::1` only.
17
+ * Use this for RFC 8252 §7.3 loopback redirect URI matching where IP
18
+ * literals are REQUIRED.
19
+ * - `isLoopbackHost(host)` — permissive: also accepts `localhost` and RFC 6761
20
+ * `.localhost` subdomains. Use this for developer ergonomics (CORS, cookie
21
+ * secure bypass, dev-mode HTTP allow-list).
22
+ * - `isPublicRoutableHost(host)` — SSRF gate. Returns false for every
23
+ * non-`public` kind. Use this before server-side fetches to user-controlled
24
+ * URLs.
25
+ */
26
+
27
+ /**
28
+ * The semantic kind of a host, derived from RFC 6890 special-purpose registries
29
+ * plus a few domain-name categories (localhost, cloud metadata FQDNs).
30
+ */
31
+ export type HostKind =
32
+ /** IPv4 `127.0.0.0/8` or IPv6 `::1`. */
33
+ | "loopback"
34
+ /** DNS name `localhost` or RFC 6761 `.localhost` TLD. */
35
+ | "localhost"
36
+ /** IPv4 `0.0.0.0` or IPv6 `::` — "this host on this network", not loopback. */
37
+ | "unspecified"
38
+ /** RFC 1918 `10/8`, `172.16/12`, `192.168/16`, or IPv6 ULA `fc00::/7`. */
39
+ | "private"
40
+ /** IPv4 `169.254/16` or IPv6 `fe80::/10`. Includes AWS IMDS `169.254.169.254`. */
41
+ | "linkLocal"
42
+ /** RFC 6598 carrier-grade NAT `100.64.0.0/10`. */
43
+ | "sharedAddressSpace"
44
+ /** RFC 5737 `192.0.2/24`, `198.51.100/24`, `203.0.113/24`, or RFC 3849 `2001:db8::/32`. */
45
+ | "documentation"
46
+ /** RFC 2544 `198.18.0.0/15`. */
47
+ | "benchmarking"
48
+ /** IPv4 `224.0.0.0/4` or IPv6 `ff00::/8`. */
49
+ | "multicast"
50
+ /** IPv4 limited broadcast `255.255.255.255`. */
51
+ | "broadcast"
52
+ /** Other RFC 6890 special-purpose ranges (0/8, 192.0.0/24, 240/4, 2001::/32, etc.). */
53
+ | "reserved"
54
+ /** Cloud metadata service FQDN (e.g. `metadata.google.internal`). */
55
+ | "cloudMetadata"
56
+ /** Any host not matching a special-purpose range above. */
57
+ | "public";
58
+
59
+ /**
60
+ * The syntactic form of the input host: an IPv4 literal, an IPv6 literal, or
61
+ * a domain name. IPv4-mapped IPv6 (`::ffff:192.0.2.1`) is reported as `ipv4`
62
+ * because it's unmapped during canonicalization.
63
+ */
64
+ export type HostLiteral = "ipv4" | "ipv6" | "fqdn";
65
+
66
+ /**
67
+ * Result of {@link classifyHost}. All fields are readonly.
68
+ *
69
+ * @property kind - Semantic classification per RFC 6890 + RFC 6761.
70
+ * @property literal - Syntactic form of the input (IPv4, IPv6, or FQDN).
71
+ * @property canonical - Lowercase, port-stripped, bracket-stripped, zone-id-stripped
72
+ * form suitable for equality comparison. IPv6 is expanded to full form.
73
+ * IPv4-mapped IPv6 is collapsed to the underlying IPv4.
74
+ */
75
+ export interface HostClassification {
76
+ readonly kind: HostKind;
77
+ readonly literal: HostLiteral;
78
+ readonly canonical: string;
79
+ }
80
+
81
+ /**
82
+ * Cloud provider instance metadata service FQDNs. These resolve to link-local
83
+ * IPs (usually `169.254.169.254`) inside their respective clouds and are
84
+ * prime SSRF targets.
85
+ *
86
+ * The IPs themselves are already caught by the `linkLocal` kind; this set
87
+ * only exists for the FQDN form that a naive server-side fetch might resolve
88
+ * via its own resolver.
89
+ */
90
+ const CLOUD_METADATA_HOSTS: ReadonlySet<string> = new Set([
91
+ "metadata.google.internal",
92
+ "metadata.goog",
93
+ "metadata",
94
+ "instance-data",
95
+ "instance-data.ec2.internal",
96
+ ]);
97
+
98
+ /** Strip `[...]` if the entire input is bracketed (IPv6 literal form). */
99
+ function stripBrackets(host: string): string {
100
+ if (host.length >= 2 && host.startsWith("[") && host.endsWith("]")) {
101
+ return host.slice(1, -1);
102
+ }
103
+ return host;
104
+ }
105
+
106
+ /**
107
+ * Strip trailing `:port` from host-with-port strings.
108
+ *
109
+ * - Bracketed IPv6 with port: `[::1]:8080` → `[::1]`
110
+ * - IPv4/FQDN with port: `127.0.0.1:3000` / `example.com:443` → base form
111
+ * - Bare IPv6: `::1` / `fe80::1` → unchanged (multiple colons means no port)
112
+ */
113
+ function stripPort(host: string): string {
114
+ if (host.startsWith("[")) {
115
+ const end = host.indexOf("]");
116
+ if (end === -1) return host;
117
+ return host.slice(0, end + 1);
118
+ }
119
+ const firstColon = host.indexOf(":");
120
+ if (firstColon === -1) return host;
121
+ if (host.indexOf(":", firstColon + 1) !== -1) return host;
122
+ return host.slice(0, firstColon);
123
+ }
124
+
125
+ /** Strip IPv6 zone identifier: `fe80::1%eth0` → `fe80::1`. */
126
+ function stripZoneId(host: string): string {
127
+ const zone = host.indexOf("%");
128
+ if (zone === -1) return host;
129
+ return host.slice(0, zone);
130
+ }
131
+
132
+ /**
133
+ * Strip trailing dots (RFC 1034 absolute DNS form): `localhost.` → `localhost`.
134
+ * Without this, `metadata.google.internal.` would fall through to `public` and
135
+ * bypass the cloud-metadata / `.localhost` checks, since WHATWG URL parsing
136
+ * preserves the trailing dot in `url.hostname`.
137
+ */
138
+ function stripTrailingDot(host: string): string {
139
+ return host.replace(/\.+$/, "");
140
+ }
141
+
142
+ /** Fast dotted-decimal shape check. Does NOT validate octet bounds. */
143
+ function looksLikeIPv4(host: string): boolean {
144
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
145
+ }
146
+
147
+ /** Pack a validated dotted-decimal IPv4 into a 32-bit unsigned integer. */
148
+ function ipv4ToUint32(ip: string): number {
149
+ const parts = ip.split(".");
150
+ return (
151
+ ((Number(parts[0]) << 24) |
152
+ (Number(parts[1]) << 16) |
153
+ (Number(parts[2]) << 8) |
154
+ Number(parts[3])) >>>
155
+ 0
156
+ );
157
+ }
158
+
159
+ /** Check whether a 32-bit value matches `prefix/length` (both unsigned). */
160
+ function inIPv4Range(value: number, prefix: number, length: number): boolean {
161
+ if (length === 0) return true;
162
+ const mask = length === 32 ? 0xffffffff : (~0 << (32 - length)) >>> 0;
163
+ return (value & mask) === (prefix & mask);
164
+ }
165
+
166
+ function classifyIPv4(ip: string): HostKind {
167
+ if (ip === "0.0.0.0") return "unspecified";
168
+ if (ip === "255.255.255.255") return "broadcast";
169
+
170
+ const n = ipv4ToUint32(ip);
171
+
172
+ if (inIPv4Range(n, ipv4ToUint32("127.0.0.0"), 8)) return "loopback";
173
+ if (inIPv4Range(n, ipv4ToUint32("10.0.0.0"), 8)) return "private";
174
+ if (inIPv4Range(n, ipv4ToUint32("172.16.0.0"), 12)) return "private";
175
+ if (inIPv4Range(n, ipv4ToUint32("192.168.0.0"), 16)) return "private";
176
+ if (inIPv4Range(n, ipv4ToUint32("169.254.0.0"), 16)) return "linkLocal";
177
+ if (inIPv4Range(n, ipv4ToUint32("100.64.0.0"), 10))
178
+ return "sharedAddressSpace";
179
+ if (inIPv4Range(n, ipv4ToUint32("192.0.2.0"), 24)) return "documentation";
180
+ if (inIPv4Range(n, ipv4ToUint32("198.51.100.0"), 24)) return "documentation";
181
+ if (inIPv4Range(n, ipv4ToUint32("203.0.113.0"), 24)) return "documentation";
182
+ if (inIPv4Range(n, ipv4ToUint32("198.18.0.0"), 15)) return "benchmarking";
183
+ if (inIPv4Range(n, ipv4ToUint32("224.0.0.0"), 4)) return "multicast";
184
+ if (inIPv4Range(n, ipv4ToUint32("0.0.0.0"), 8)) return "reserved";
185
+ if (inIPv4Range(n, ipv4ToUint32("192.0.0.0"), 24)) return "reserved";
186
+ if (inIPv4Range(n, ipv4ToUint32("240.0.0.0"), 4)) return "reserved";
187
+
188
+ return "public";
189
+ }
190
+
191
+ /**
192
+ * Extract an IPv4 address embedded in an expanded IPv6 literal.
193
+ *
194
+ * Used to recurse into tunnel/translation forms (6to4, NAT64, Teredo) so a
195
+ * private destination cannot be smuggled behind a syntactically-public IPv6
196
+ * literal. `startGroup` is the index of the first of two 16-bit groups in the
197
+ * expanded form (`0000:0000:...`). With `xor: true`, the 32-bit value is XORed
198
+ * with `0xffffffff` before decoding (Teredo obfuscates the client IPv4 this
199
+ * way).
200
+ */
201
+ function extractEmbeddedIPv4(
202
+ expanded: string,
203
+ startGroup: number,
204
+ options: { xor?: boolean } = {},
205
+ ): string | null {
206
+ const offset = startGroup * 5;
207
+ const g1 = Number.parseInt(expanded.slice(offset, offset + 4), 16);
208
+ const g2 = Number.parseInt(expanded.slice(offset + 5, offset + 9), 16);
209
+ if (!Number.isFinite(g1) || !Number.isFinite(g2)) return null;
210
+ let combined = ((g1 << 16) | g2) >>> 0;
211
+ if (options.xor) combined = (combined ^ 0xffffffff) >>> 0;
212
+ return `${(combined >>> 24) & 0xff}.${(combined >>> 16) & 0xff}.${(combined >>> 8) & 0xff}.${combined & 0xff}`;
213
+ }
214
+
215
+ /**
216
+ * Classify an expanded, full-form, lowercase IPv6 address (no IPv4-mapped
217
+ * input — those are unmapped to IPv4 before reaching here).
218
+ *
219
+ * 6to4 (`2002::/16`), NAT64 (`64:ff9b::/96`) and Teredo (`2001:0000::/32`)
220
+ * embed an IPv4 that can route to private/loopback space. If the embedded
221
+ * IPv4 classifies as non-`public`, return `reserved` — blocks SSRF without
222
+ * advertising the address as a loopback literal for RFC 8252 §7.3 matching.
223
+ */
224
+ function classifyIPv6(expanded: string): HostKind {
225
+ if (expanded === "0000:0000:0000:0000:0000:0000:0000:0000")
226
+ return "unspecified";
227
+ if (expanded === "0000:0000:0000:0000:0000:0000:0000:0001") return "loopback";
228
+
229
+ const firstByte = Number.parseInt(expanded.slice(0, 2), 16);
230
+ const secondByte = Number.parseInt(expanded.slice(2, 4), 16);
231
+
232
+ if (firstByte === 0xff) return "multicast";
233
+ if (firstByte === 0xfe && (secondByte & 0xc0) === 0x80) return "linkLocal";
234
+ if ((firstByte & 0xfe) === 0xfc) return "private";
235
+
236
+ if (expanded.startsWith("2001:0db8:")) return "documentation";
237
+
238
+ if (expanded.startsWith("2002:")) {
239
+ const embedded = extractEmbeddedIPv4(expanded, 1);
240
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
241
+ return "public";
242
+ }
243
+
244
+ if (expanded.startsWith("0064:ff9b:0000:0000:0000:0000:")) {
245
+ const embedded = extractEmbeddedIPv4(expanded, 6);
246
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
247
+ return "reserved";
248
+ }
249
+
250
+ if (expanded.startsWith("2001:0000:")) {
251
+ const embedded = extractEmbeddedIPv4(expanded, 6, { xor: true });
252
+ if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
253
+ return "reserved";
254
+ }
255
+
256
+ if (expanded.startsWith("0100:0000:0000:0000:")) return "reserved";
257
+
258
+ return "public";
259
+ }
260
+
261
+ /**
262
+ * Classify a host string according to RFC 6890 / RFC 6761.
263
+ *
264
+ * Accepts inputs in any of these shapes and normalizes before classifying:
265
+ *
266
+ * - Bare IPv4: `127.0.0.1`
267
+ * - Bare IPv6: `::1`, `fe80::1%eth0`
268
+ * - Bracketed IPv6: `[::1]`
269
+ * - Host with port: `localhost:3000`, `127.0.0.1:443`, `[::1]:8080`
270
+ * - FQDN: `example.com`, `tenant.localhost`
271
+ * - IPv4-mapped IPv6: `::ffff:192.0.2.1` (reported as `literal: "ipv4"`)
272
+ *
273
+ * Invalid or non-resolvable FQDNs are returned as `{ kind: "public", literal: "fqdn" }`
274
+ * — this function never throws. Callers that need structural validation must
275
+ * combine this with a URL/hostname validator upstream.
276
+ *
277
+ * @example
278
+ * classifyHost("127.0.0.1")
279
+ * // { kind: "loopback", literal: "ipv4", canonical: "127.0.0.1" }
280
+ *
281
+ * @example
282
+ * classifyHost("[::1]:8080")
283
+ * // { kind: "loopback", literal: "ipv6", canonical: "0000:0000:...:0001" }
284
+ *
285
+ * @example
286
+ * classifyHost("::ffff:192.0.2.1")
287
+ * // { kind: "documentation", literal: "ipv4", canonical: "192.0.2.1" }
288
+ *
289
+ * @example
290
+ * classifyHost("tenant-a.localhost")
291
+ * // { kind: "localhost", literal: "fqdn", canonical: "tenant-a.localhost" }
292
+ */
293
+ export function classifyHost(host: string): HostClassification {
294
+ const stripped = stripTrailingDot(
295
+ stripZoneId(stripBrackets(stripPort(host.trim()))),
296
+ );
297
+ const lowered = stripped.toLowerCase();
298
+
299
+ if (lowered === "") {
300
+ return { kind: "reserved", literal: "fqdn", canonical: "" };
301
+ }
302
+
303
+ if (!isValidIP(lowered)) {
304
+ if (lowered === "localhost" || lowered.endsWith(".localhost")) {
305
+ return { kind: "localhost", literal: "fqdn", canonical: lowered };
306
+ }
307
+ if (CLOUD_METADATA_HOSTS.has(lowered)) {
308
+ return { kind: "cloudMetadata", literal: "fqdn", canonical: lowered };
309
+ }
310
+ return { kind: "public", literal: "fqdn", canonical: lowered };
311
+ }
312
+
313
+ if (looksLikeIPv4(lowered)) {
314
+ return { kind: classifyIPv4(lowered), literal: "ipv4", canonical: lowered };
315
+ }
316
+
317
+ const canonical = normalizeIP(lowered, { ipv6Subnet: 128 });
318
+
319
+ if (looksLikeIPv4(canonical)) {
320
+ return {
321
+ kind: classifyIPv4(canonical),
322
+ literal: "ipv4",
323
+ canonical,
324
+ };
325
+ }
326
+
327
+ return { kind: classifyIPv6(canonical), literal: "ipv6", canonical };
328
+ }
329
+
330
+ /**
331
+ * Strict loopback-IP-literal check per RFC 8252 §7.3.
332
+ *
333
+ * Returns true ONLY for IPv4 `127.0.0.0/8` or IPv6 `::1`. The DNS name
334
+ * `localhost` returns false — RFC 8252 §8.3 explicitly recommends against
335
+ * relying on name resolution for loopback redirect URIs.
336
+ *
337
+ * Use this for OAuth redirect URI matching.
338
+ *
339
+ * @example
340
+ * isLoopbackIP("127.0.0.1") // true
341
+ * isLoopbackIP("::1") // true
342
+ * isLoopbackIP("[::1]:8080") // true
343
+ * isLoopbackIP("localhost") // false (use isLoopbackHost for DNS names)
344
+ * isLoopbackIP("0.0.0.0") // false (unspecified, not loopback)
345
+ */
346
+ export function isLoopbackIP(host: string): boolean {
347
+ return classifyHost(host).kind === "loopback";
348
+ }
349
+
350
+ /**
351
+ * Permissive loopback check for developer-ergonomics code paths.
352
+ *
353
+ * Returns true for IPv4 `127.0.0.0/8`, IPv6 `::1`, the literal name `localhost`,
354
+ * and any RFC 6761 `.localhost` subdomain (`tenant.localhost`, `app.localhost`).
355
+ *
356
+ * Use this for things like: allowing HTTP for dev servers, skipping Secure
357
+ * cookie requirements, browser-trust heuristics. Do NOT use this for OAuth
358
+ * redirect URI matching — use {@link isLoopbackIP} there.
359
+ *
360
+ * @example
361
+ * isLoopbackHost("localhost") // true
362
+ * isLoopbackHost("tenant.localhost") // true (RFC 6761)
363
+ * isLoopbackHost("127.0.0.1") // true
364
+ * isLoopbackHost("0.0.0.0") // false (unspecified, NOT loopback)
365
+ */
366
+ export function isLoopbackHost(host: string): boolean {
367
+ const kind = classifyHost(host).kind;
368
+ return kind === "loopback" || kind === "localhost";
369
+ }
370
+
371
+ /**
372
+ * First-line SSRF gate: returns true ONLY for hosts that classify as `public`.
373
+ *
374
+ * Every RFC 6890 special-purpose range (loopback, private, link-local,
375
+ * unspecified, documentation, multicast, broadcast, reserved, shared address
376
+ * space, benchmarking) and cloud-metadata FQDN returns false.
377
+ *
378
+ * Use this BEFORE issuing a server-side fetch to a user-supplied URL, e.g.
379
+ * OAuth introspection endpoints, webhook targets, or metadata-document
380
+ * fetches (CIMD).
381
+ *
382
+ * Limitations (this is a syntactic check, not a complete SSRF mitigation):
383
+ * - No DNS resolution: a public-looking FQDN that resolves to a private IP
384
+ * passes this check. Re-verify the resolved address before connecting, or
385
+ * pin the socket to the resolved IP.
386
+ * - No DNS-rebinding defense: attackers can return a public IP on the first
387
+ * lookup and a private IP on the second. Resolve once and reuse the IP.
388
+ * - No redirect following: HTTP 3xx responses can redirect to private hosts.
389
+ * Re-run this check on every redirect target, or disable auto-follow.
390
+ *
391
+ * @example
392
+ * isPublicRoutableHost("example.com") // true
393
+ * isPublicRoutableHost("127.0.0.1") // false (loopback)
394
+ * isPublicRoutableHost("169.254.169.254") // false (linkLocal / AWS IMDS)
395
+ * isPublicRoutableHost("metadata.google.internal") // false (cloudMetadata)
396
+ * isPublicRoutableHost("10.0.0.1") // false (private)
397
+ * isPublicRoutableHost("::ffff:127.0.0.1") // false (mapped loopback)
398
+ */
399
+ export function isPublicRoutableHost(host: string): boolean {
400
+ return classifyHost(host).kind === "public";
401
+ }