mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-20 20:59:52 -05:00
fix(cimd): route client_id SSRF checks through the shared host classifier (#10126)
This commit is contained in:
@@ -24,7 +24,9 @@ betterAuth({
|
||||
});
|
||||
```
|
||||
|
||||
Ships with §3/§4.1 validation, SSRF protection for private/reserved IPs and cloud metadata endpoints, a 5-second fetch timeout, a 5 KB response size limit (UTF-8 byte-counted), origin binding for redirect URIs, and lifecycle hooks (`onClientCreated`, `onClientRefreshed`). Advertises `client_id_metadata_document_supported` in OAuth/OIDC discovery metadata.
|
||||
Ships with §3/§4.1 validation, SSRF protection that rejects private, reserved, link-local, unspecified, and cloud-metadata hosts (including IPv4-mapped IPv6 and 6to4/NAT64/Teredo tunnel forms), a 5-second fetch timeout, a 5 KB response size limit (UTF-8 byte-counted), origin binding for redirect URIs, and lifecycle hooks (`onClientCreated`, `onClientRefreshed`). Advertises `client_id_metadata_document_supported` in OAuth/OIDC discovery metadata.
|
||||
|
||||
Loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`, `*.localhost`), including plain HTTP, are fetched only when the new `allowLoopback` option is enabled, so a production server never fetches its own loopback interface. Loopback `redirect_uris` stay allowed for native and desktop flows.
|
||||
|
||||
The `allowFetch` pre-fetch gate lets operators add origin allowlists, per-host rate limits, or DNS-level defenses beyond the built-in IP-literal check.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@better-auth/core": patch
|
||||
---
|
||||
|
||||
Harden the host classifier (`@better-auth/core/utils/host`) against three non-public address forms it previously reported as public: deprecated IPv4-compatible IPv6 (`::w.x.y.z`, RFC 4291 §2.5.5.1, e.g. `[::127.0.0.1]` which `URL` normalizes to `[::7f00:1]`), the 6to4 relay anycast prefix `192.88.99.0/24` (RFC 7526), and deprecated site-local `fec0::/10` (RFC 3879). SSRF gates built on `isPublicRoutableHost` / `classifyHost` (`jwks_uri` validation, SSO OIDC discovery, CIMD metadata fetches) now reject these.
|
||||
@@ -105,6 +105,12 @@ export const cimdOptionsType = {
|
||||
description:
|
||||
"Metadata fields whose URL values must share the same origin as the `client_id` URL. Localhost is accepted for redirect URI fields (for native and desktop flows) but never for `client_uri` or custom fields. Pass an empty array to disable origin binding.",
|
||||
},
|
||||
allowLoopback: {
|
||||
type: "boolean",
|
||||
default: "false",
|
||||
description:
|
||||
"Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`, `*.localhost`), including plain HTTP, so an auth server can fetch a metadata document hosted on the same machine. Off by default; enable only for local development.",
|
||||
},
|
||||
allowFetch: {
|
||||
type: "(url, ctx) => boolean | Promise<boolean>",
|
||||
default: "always allow",
|
||||
@@ -130,7 +136,7 @@ export const cimdOptionsType = {
|
||||
The fetched document must satisfy both §3 and §4.1 of the draft. Key rules enforced by the plugin:
|
||||
|
||||
* `client_id` in the body must equal the fetch URL byte-for-byte.
|
||||
* HTTPS only. HTTP is allowed only for `localhost`, `127.0.0.1`, `[::1]`, and `*.localhost` to support local development.
|
||||
* HTTPS only. Loopback URLs (`localhost`, `127.0.0.0/8`, `::1`, `*.localhost`), including plain HTTP, are accepted only when `allowLoopback` is enabled for local development.
|
||||
* No fragments, credentials, or `./` / `../` dot segments in the URL.
|
||||
* A path component is required (`https://example.com/` is rejected; `https://example.com/meta.json` is accepted).
|
||||
* `client_secret` and `client_secret_expires_at` are prohibited.
|
||||
@@ -163,7 +169,7 @@ The fetched document must satisfy both §3 and §4.1 of the draft. Key rules enf
|
||||
|
||||
The plugin ships defense in depth against common abuses of unauthenticated client discovery:
|
||||
|
||||
* **SSRF protection (server-side request forgery).** Private and reserved IP ranges (RFC 6890), IPv4-mapped IPv6, link-local addresses, and cloud metadata endpoints (`metadata.google.internal`, `169.254.169.254`) are all rejected. The plugin does no DNS resolution, so the same rules run identically on Node, Bun, Deno, and Cloudflare Workers.
|
||||
* **SSRF protection (server-side request forgery).** `client_id` URLs that resolve to private, reserved, link-local, unspecified, or cloud-metadata hosts (RFC 6890), including IPv4-mapped IPv6 and 6to4/NAT64/Teredo tunnel forms, are rejected; loopback is rejected too unless `allowLoopback` is enabled. Host classification uses the framework's shared classifier, so it stays in step with the other SSRF gates. The plugin does no DNS resolution, so the same rules run identically on Node, Bun, Deno, and Cloudflare Workers.
|
||||
* **Fetch hardening.** 5s timeout, 5 KB response size limit (UTF-8 byte-counted), no redirect following, `Accept: application/json` only.
|
||||
* **Origin binding.** Fields in `originBoundFields` must share the `client_id` origin. Cross-domain redirect injection is blocked by default.
|
||||
* **No secrets from documents.** Symmetric authentication methods and `client_secret` fields are rejected before anything is written to the database.
|
||||
@@ -175,7 +181,7 @@ The plugin ships defense in depth against common abuses of unauthenticated clien
|
||||
|
||||
* **Refresh runs on the OAuth hot path.** When a CIMD client's record is older than `refreshRate`, the next OAuth request for that client awaits the metadata document refresh before completing. A slow or unreachable metadata host raises tail latency on `/oauth2/authorize`, `/oauth2/token`, `/oauth2/introspect`, and friends for that client. Pick `refreshRate` generous enough that the refresh stays rare, and keep `allowFetch` aligned with your metadata hosts' reliability profile.
|
||||
* **No fetch deduplication yet.** Concurrent requests for the same stale client can all trigger independent metadata fetches. An external coordination layer or higher `refreshRate` is the practical mitigation today.
|
||||
* **HTTP allowed only for localhost.** `localhost`, `127.0.0.1`, `[::1]`, and `*.localhost` are accepted over plain HTTP so you can develop locally. Every other host must use HTTPS.
|
||||
* **Loopback is opt-in.** Loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`, `*.localhost`), including plain HTTP, are fetched only when `allowLoopback` is set. Leave it off in production so the server never fetches its own loopback interface. Loopback `redirect_uris` remain allowed for native/desktop flows regardless.
|
||||
|
||||
## Composition with `clientDiscovery`
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ const PKCE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
||||
|
||||
describe("CIMD end-to-end flow", () => {
|
||||
it("runs the full authorize → consent → token → userinfo → refresh loop with a URL client_id", async (t) => {
|
||||
// 1. Host the CIMD metadata document on a local HTTP server. CIMD
|
||||
// permits HTTP for localhost so no TLS scaffolding is required.
|
||||
// 1. Host the CIMD metadata document on a local HTTP server. The
|
||||
// `allowLoopback` option permits loopback client_id URLs over plain
|
||||
// HTTP, so no TLS scaffolding is required.
|
||||
const metadataHost = createServer();
|
||||
metadataHost.listen(0);
|
||||
t.after(() => metadataHost.close());
|
||||
@@ -71,7 +72,7 @@ describe("CIMD end-to-end flow", () => {
|
||||
scopes: ["openid", "profile", "email", "offline_access"],
|
||||
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
|
||||
}),
|
||||
cimd({ refreshRate: "60m" }),
|
||||
cimd({ refreshRate: "60m", allowLoopback: true }),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -263,6 +264,9 @@ describe("CIMD end-to-end flow", () => {
|
||||
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
|
||||
}),
|
||||
cimd({
|
||||
// Loopback client_ids are used by this smoke test; the
|
||||
// allowFetch gate then blocks the specific host below.
|
||||
allowLoopback: true,
|
||||
// Block any client_id whose host is `localhost:59999`.
|
||||
allowFetch: (url) => new URL(url).host !== "localhost:59999",
|
||||
}),
|
||||
|
||||
@@ -272,7 +272,9 @@ async function fetchAndValidateMetadataDocument(
|
||||
cimdOptions: CimdOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// §3: validate the URL structure before fetching
|
||||
const urlError = validateClientIdUrl(clientIdUrl);
|
||||
const urlError = validateClientIdUrl(clientIdUrl, {
|
||||
allowLoopback: cimdOptions.allowLoopback,
|
||||
});
|
||||
if (urlError) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error: "invalid_client",
|
||||
|
||||
@@ -26,9 +26,10 @@ export function cimdClientDiscovery(
|
||||
options: CimdOptions = {},
|
||||
): ClientDiscovery {
|
||||
const resolver = createCimdResolver(options);
|
||||
const allowLoopback = options.allowLoopback ?? false;
|
||||
return {
|
||||
id: "cimd",
|
||||
matches: isUrlClientId,
|
||||
matches: (clientId) => isUrlClientId(clientId, { allowLoopback }),
|
||||
resolve: resolver,
|
||||
discoveryMetadata: { client_id_metadata_document_supported: true },
|
||||
};
|
||||
@@ -59,9 +60,11 @@ export const cimd = (options: CimdOptions = {}) => {
|
||||
|
||||
export { createCimdResolver } from "./resolver";
|
||||
export type { CimdOptions } from "./types";
|
||||
export type { ClientIdMetadataDocumentResult } from "./validate-metadata-document";
|
||||
export type {
|
||||
ClientIdMetadataDocumentResult,
|
||||
ClientIdUrlOptions,
|
||||
} from "./validate-metadata-document";
|
||||
export {
|
||||
isLocalhost,
|
||||
isUrlClientId,
|
||||
validateCimdMetadata,
|
||||
validateClientIdUrl,
|
||||
|
||||
@@ -80,7 +80,9 @@ export function createCimdResolver(
|
||||
const refreshRate = cimdOptions.refreshRate ?? "60m";
|
||||
|
||||
return async (ctx, clientId, existing) => {
|
||||
if (!isUrlClientId(clientId)) {
|
||||
if (
|
||||
!isUrlClientId(clientId, { allowLoopback: cimdOptions.allowLoopback })
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ export interface CimdOptions {
|
||||
* @default ["redirect_uris", "post_logout_redirect_uris", "client_uri"]
|
||||
*/
|
||||
originBoundFields?: string[];
|
||||
/**
|
||||
* Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`,
|
||||
* `*.localhost`), including plain HTTP, so an auth server can fetch a
|
||||
* metadata document hosted on the same machine. Off by default; enable
|
||||
* only for local development.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowLoopback?: boolean;
|
||||
/**
|
||||
* Pre-fetch gate called before a metadata document is requested. Return
|
||||
* `false` to reject the `client_id` URL.
|
||||
|
||||
@@ -22,27 +22,42 @@ describe("isUrlClientId", () => {
|
||||
expect(isUrlClientId("https://example.com/meta")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts http://localhost URLs (dev mode)", () => {
|
||||
expect(isUrlClientId("http://localhost/meta")).toBe(true);
|
||||
expect(isUrlClientId("http://localhost:3000/meta")).toBe(true);
|
||||
it("matches mixed-case URL schemes (schemes are case-insensitive)", () => {
|
||||
expect(isUrlClientId("HTTPS://example.com/meta")).toBe(true);
|
||||
expect(isUrlClientId("HtTpS://example.com/meta")).toBe(true);
|
||||
expect(
|
||||
isUrlClientId("HTTP://localhost/meta", { allowLoopback: true }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts http://127.0.0.1 (dev mode)", () => {
|
||||
expect(isUrlClientId("http://127.0.0.1/meta")).toBe(true);
|
||||
expect(isUrlClientId("http://127.0.0.1:8080/meta")).toBe(true);
|
||||
it("matches https:// loopback URLs regardless of allowLoopback", () => {
|
||||
expect(isUrlClientId("https://127.0.0.1/meta")).toBe(true);
|
||||
expect(isUrlClientId("https://localhost/meta")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts http://[::1] (dev mode)", () => {
|
||||
expect(isUrlClientId("http://[::1]/meta")).toBe(true);
|
||||
it("does not match http:// loopback without allowLoopback", () => {
|
||||
expect(isUrlClientId("http://localhost/meta")).toBe(false);
|
||||
expect(isUrlClientId("http://127.0.0.1:8080/meta")).toBe(false);
|
||||
expect(isUrlClientId("http://[::1]/meta")).toBe(false);
|
||||
expect(isUrlClientId("http://app.localhost/meta")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts localhost subdomains (dev mode)", () => {
|
||||
expect(isUrlClientId("http://app.localhost/meta")).toBe(true);
|
||||
expect(isUrlClientId("http://app.localhost:3000/meta")).toBe(true);
|
||||
it("matches http:// loopback when allowLoopback is set (dev mode)", () => {
|
||||
const dev = { allowLoopback: true };
|
||||
expect(isUrlClientId("http://localhost/meta", dev)).toBe(true);
|
||||
expect(isUrlClientId("http://localhost:3000/meta", dev)).toBe(true);
|
||||
expect(isUrlClientId("http://127.0.0.1/meta", dev)).toBe(true);
|
||||
expect(isUrlClientId("http://127.0.0.1:8080/meta", dev)).toBe(true);
|
||||
expect(isUrlClientId("http://[::1]/meta", dev)).toBe(true);
|
||||
expect(isUrlClientId("http://app.localhost/meta", dev)).toBe(true);
|
||||
expect(isUrlClientId("http://app.localhost:3000/meta", dev)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http:// non-localhost URLs", () => {
|
||||
it("rejects http:// non-loopback even with allowLoopback", () => {
|
||||
expect(isUrlClientId("http://example.com/meta")).toBe(false);
|
||||
expect(
|
||||
isUrlClientId("http://example.com/meta", { allowLoopback: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-URL strings", () => {
|
||||
@@ -87,14 +102,20 @@ describe("validateClientIdUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-https non-localhost", () => {
|
||||
it("rejects non-https non-loopback", () => {
|
||||
const result = validateClientIdUrl("http://example.com/meta");
|
||||
expect(result).toContain("HTTPS");
|
||||
});
|
||||
|
||||
it("accepts http://localhost/meta (dev)", () => {
|
||||
expect(validateClientIdUrl("http://localhost/meta")).toBeNull();
|
||||
expect(validateClientIdUrl("http://localhost:8080/meta")).toBeNull();
|
||||
it("rejects http://localhost without allowLoopback", () => {
|
||||
expect(validateClientIdUrl("http://localhost/meta")).not.toBeNull();
|
||||
expect(validateClientIdUrl("http://localhost:8080/meta")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("accepts http://localhost with allowLoopback (dev)", () => {
|
||||
const dev = { allowLoopback: true };
|
||||
expect(validateClientIdUrl("http://localhost/meta", dev)).toBeNull();
|
||||
expect(validateClientIdUrl("http://localhost:8080/meta", dev)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects private IP 10.0.0.1", () => {
|
||||
@@ -117,10 +138,16 @@ describe("validateClientIdUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects loopback IP 127.0.0.1 via https", () => {
|
||||
// 127.0.0.1 is localhost, so it should be allowed (localhost is exempt)
|
||||
// but only over http; https://127.0.0.1 is treated as localhost
|
||||
expect(validateClientIdUrl("https://127.0.0.1/meta")).toBeNull();
|
||||
it("rejects loopback IP 127.0.0.1 via https without allowLoopback", () => {
|
||||
expect(validateClientIdUrl("https://127.0.0.1/meta")).not.toBeNull();
|
||||
expect(validateClientIdUrl("https://127.0.0.1:8080/meta")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("accepts loopback over https/http with allowLoopback (dev)", () => {
|
||||
const dev = { allowLoopback: true };
|
||||
expect(validateClientIdUrl("https://127.0.0.1:8080/meta", dev)).toBeNull();
|
||||
expect(validateClientIdUrl("http://127.0.0.1:8080/meta", dev)).toBeNull();
|
||||
expect(validateClientIdUrl("https://localhost/meta", dev)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts public IP like 8.8.8.8", () => {
|
||||
@@ -147,16 +174,17 @@ describe("validateClientIdUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects IPv4-mapped IPv6 targeting private IPs", () => {
|
||||
it("rejects IPv4-mapped IPv6 targeting private/link-local IPs", () => {
|
||||
expect(
|
||||
validateClientIdUrl("https://[::ffff:169.254.169.254]/meta"),
|
||||
).toContain("private");
|
||||
expect(validateClientIdUrl("https://[::ffff:127.0.0.1]/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
expect(validateClientIdUrl("https://[::ffff:10.0.0.1]/meta")).toContain(
|
||||
"private",
|
||||
);
|
||||
// IPv4-mapped loopback classifies as loopback (not "private"); still rejected.
|
||||
expect(
|
||||
validateClientIdUrl("https://[::ffff:127.0.0.1]/meta"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects cloud metadata hostname", () => {
|
||||
@@ -165,8 +193,61 @@ describe("validateClientIdUrl", () => {
|
||||
).toContain("private");
|
||||
});
|
||||
|
||||
it("accepts subdomain of localhost", () => {
|
||||
expect(validateClientIdUrl("http://app.localhost/meta")).toBeNull();
|
||||
it("rejects subdomain of localhost without allowLoopback", () => {
|
||||
expect(validateClientIdUrl("http://app.localhost/meta")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("accepts subdomain of localhost with allowLoopback (dev)", () => {
|
||||
expect(
|
||||
validateClientIdUrl("http://app.localhost/meta", { allowLoopback: true }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects IPv6 unspecified [::] (0.0.0.0-day class)", () => {
|
||||
expect(validateClientIdUrl("https://[::]/meta")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects trailing-dot cloud-metadata FQDN", () => {
|
||||
expect(
|
||||
validateClientIdUrl("https://metadata.google.internal./meta"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects additional cloud-metadata FQDNs", () => {
|
||||
expect(validateClientIdUrl("https://metadata.goog/meta")).not.toBeNull();
|
||||
expect(validateClientIdUrl("https://metadata/meta")).not.toBeNull();
|
||||
expect(validateClientIdUrl("https://instance-data/meta")).not.toBeNull();
|
||||
expect(
|
||||
validateClientIdUrl("https://instance-data.ec2.internal/meta"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects IPv6 tunnel forms embedding private/IMDS IPv4 (6to4, NAT64, Teredo)", () => {
|
||||
expect(validateClientIdUrl("https://[2002:7f00:1::]/meta")).not.toBeNull();
|
||||
expect(
|
||||
validateClientIdUrl("https://[64:ff9b::7f00:1]/meta"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
validateClientIdUrl("https://[2001:0:0:0:0:0:7f00:1]/meta"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects deprecated IPv4-compatible IPv6 embedding loopback/IMDS/private", () => {
|
||||
// new URL() normalizes [::127.0.0.1] to [::7f00:1] (no ::ffff: marker).
|
||||
expect(validateClientIdUrl("https://[::127.0.0.1]/meta")).not.toBeNull();
|
||||
expect(
|
||||
validateClientIdUrl("https://[::169.254.169.254]/meta"),
|
||||
).not.toBeNull();
|
||||
expect(validateClientIdUrl("https://[::10.0.0.1]/meta")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects percent-encoded dot segments", () => {
|
||||
expect(validateClientIdUrl("https://example.com/%2e%2e/meta")).toContain(
|
||||
"dot segments",
|
||||
);
|
||||
expect(validateClientIdUrl("https://example.com/%2e/meta")).toContain(
|
||||
"dot segments",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -418,16 +499,16 @@ describe("validateCimdMetadata", () => {
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-redirect origin-bound field with localhost URL", () => {
|
||||
// Localhost bypass only applies to redirect URI fields. A localhost
|
||||
// value on client_uri still fails origin binding.
|
||||
it("rejects loopback URL on a non-redirect field (client_uri)", () => {
|
||||
// The loopback exception applies only to redirect URI fields; a loopback
|
||||
// client_uri is rejected by the SSRF check.
|
||||
const result = validateCimdMetadata(fetchUrl, {
|
||||
client_id: fetchUrl,
|
||||
redirect_uris: ["https://example.com/callback"],
|
||||
client_uri: "http://localhost:3000/about",
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("same origin");
|
||||
expect(result.error).toContain("private");
|
||||
});
|
||||
|
||||
it("accepts localhost URL on post_logout_redirect_uris", () => {
|
||||
@@ -462,6 +543,28 @@ describe("validateCimdMetadata", () => {
|
||||
expect(result.error).toContain("private");
|
||||
});
|
||||
|
||||
it("rejects IPv4-compatible IPv6 in logo_uri (SSRF)", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
logo_uri: "https://[::169.254.169.254]/logo.png",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("private");
|
||||
});
|
||||
|
||||
it("accepts public client_uri and logo_uri", () => {
|
||||
const result = validateCimdMetadata(
|
||||
fetchUrl,
|
||||
validMetadata(fetchUrl, {
|
||||
client_uri: "https://example.com/about",
|
||||
logo_uri: "https://cdn.example.com/logo.png",
|
||||
}),
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns warning for query string in fetchUrl", () => {
|
||||
const urlWithQuery = "https://example.com/client-metadata.json?v=1";
|
||||
const result = validateCimdMetadata(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Pure validation for Client ID Metadata Documents.
|
||||
// Validation for Client ID Metadata Documents.
|
||||
// Implements draft-ietf-oauth-client-id-metadata-document §3 and §4.1.
|
||||
// Zero side-effect imports: testable without building the monorepo.
|
||||
import {
|
||||
isLoopbackHost,
|
||||
isPublicRoutableHost,
|
||||
} from "@better-auth/core/utils/host";
|
||||
|
||||
const DOT_SEGMENT_RE = /\/\.\.?(?:\/|$|#|\?)/;
|
||||
const DOT_SEGMENT_RE = /\/(?:\.|%2e)(?:\.|%2e)?(?:\/|$|#|\?)/i;
|
||||
|
||||
const PROHIBITED_FIELDS = new Set([
|
||||
"client_secret",
|
||||
@@ -25,146 +28,56 @@ export interface ClientIdMetadataDocumentResult {
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/** Hostnames that are considered "localhost" for development flows. */
|
||||
export function isLocalhost(hostname: string): boolean {
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "[::1]" ||
|
||||
hostname === "::1" ||
|
||||
hostname.endsWith(".localhost")
|
||||
);
|
||||
export interface ClientIdUrlOptions {
|
||||
/**
|
||||
* Permit loopback `client_id` URLs (`localhost`, `127.0.0.0/8`, `::1`,
|
||||
* `*.localhost`) and plain HTTP for them. Off by default.
|
||||
*/
|
||||
allowLoopback?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a dotted-decimal IPv4 address is private, reserved, or
|
||||
* otherwise non-routable for a public SSRF target. Covers the subset of
|
||||
* RFC 6890 special-purpose ranges that an adversarial `client_id` URL
|
||||
* could point at to reach internal infrastructure or disrupt fetches.
|
||||
*/
|
||||
function isPrivateIpv4(host: string): boolean {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p))) {
|
||||
return false;
|
||||
}
|
||||
const a = Number(parts[0]);
|
||||
const b = Number(parts[1]);
|
||||
const c = Number(parts[2]);
|
||||
return (
|
||||
// Loopback (127.0.0.0/8), private (RFC 1918), "this network"
|
||||
// (0.0.0.0/8), link-local (169.254.0.0/16), shared address space
|
||||
// (100.64.0.0/10).
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
a === 0 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
// Benchmarking (RFC 2544): 198.18.0.0/15.
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
// Documentation (RFC 5737).
|
||||
(a === 192 && b === 0 && c === 2) ||
|
||||
(a === 198 && b === 51 && c === 100) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
// 6to4 anycast relay (RFC 7526 deprecated): 192.88.99.0/24.
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
// Multicast (RFC 5771): 224.0.0.0/4.
|
||||
(a >= 224 && a <= 239) ||
|
||||
// Reserved / future use (RFC 1112 + broadcast 255.255.255.255):
|
||||
// 240.0.0.0/4.
|
||||
a >= 240
|
||||
);
|
||||
}
|
||||
|
||||
// Matches ::ffff:a.b.c.d (dotted-decimal, as written by humans)
|
||||
const V4_MAPPED_DOTTED_RE =
|
||||
/^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/;
|
||||
|
||||
// Matches hex-pair form (e.g. ::ffff:a9fe:a9fe), as normalized by the URL parser
|
||||
const V4_MAPPED_HEX_RE =
|
||||
/^(?:0{0,4}:){0,4}:?(?:0{0,4}:)?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
|
||||
|
||||
/**
|
||||
* Convert two hex groups from an IPv4-mapped IPv6 address to dotted-decimal IPv4.
|
||||
* e.g. "a9fe" "a9fe" -> "169.254.169.254"
|
||||
*/
|
||||
function hexGroupsToIpv4(hi: string, lo: string): string {
|
||||
const h = Number.parseInt(hi, 16);
|
||||
const l = Number.parseInt(lo, 16);
|
||||
return `${(h >> 8) & 0xff}.${h & 0xff}.${(l >> 8) & 0xff}.${l & 0xff}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a hostname is private/reserved per RFC 6890.
|
||||
* Detect a URL-formatted client_id (Client ID Metadata Document pattern).
|
||||
*
|
||||
* Handles bracketed IPv6 (as returned by URL.hostname), IPv4-mapped
|
||||
* IPv6 in both dotted-decimal and hex-normalized forms, and cloud
|
||||
* metadata hostnames. No DNS resolution, so it runs identically on
|
||||
* Node, Bun, Deno, and Workers.
|
||||
* HTTPS URLs always match; plain HTTP matches only loopback hosts, and only
|
||||
* when `allowLoopback` is set. This is a routing predicate, not a security
|
||||
* gate: it performs no DNS resolution, so callers MUST also run
|
||||
* {@link validateClientIdUrl} (and a fetch-time policy) before fetching.
|
||||
*/
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
const host =
|
||||
lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
||||
|
||||
if (host === "::1") {
|
||||
return true;
|
||||
}
|
||||
if (isPrivateIpv4(host)) {
|
||||
return true;
|
||||
}
|
||||
if (host.includes(":")) {
|
||||
const dottedMatch = host.match(V4_MAPPED_DOTTED_RE);
|
||||
if (dottedMatch && isPrivateIpv4(dottedMatch[1]!)) {
|
||||
return true;
|
||||
}
|
||||
const hexMatch = host.match(V4_MAPPED_HEX_RE);
|
||||
if (hexMatch) {
|
||||
const ipv4 = hexGroupsToIpv4(hexMatch[1]!, hexMatch[2]!);
|
||||
if (isPrivateIpv4(ipv4)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Link-local (fe80::/10)
|
||||
if (/^fe[89ab]/.test(host)) {
|
||||
return true;
|
||||
}
|
||||
// Unique-local (fc00::/7)
|
||||
if (host.startsWith("fc") || host.startsWith("fd")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (host === "metadata.google.internal") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect URL-formatted client_id (Client ID Metadata Document pattern).
|
||||
* HTTPS always accepted; HTTP accepted for localhost variants
|
||||
* (localhost, 127.0.0.1, [::1], *.localhost) for development.
|
||||
*/
|
||||
export function isUrlClientId(clientId: string): boolean {
|
||||
if (clientId.startsWith("https://")) {
|
||||
return true;
|
||||
}
|
||||
if (!clientId.startsWith("http://")) {
|
||||
return false;
|
||||
}
|
||||
export function isUrlClientId(
|
||||
clientId: string,
|
||||
options?: ClientIdUrlOptions,
|
||||
): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
return isLocalhost(new URL(clientId).hostname);
|
||||
parsed = new URL(clientId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol === "https:") {
|
||||
return true;
|
||||
}
|
||||
if (parsed.protocol !== "http:") {
|
||||
return false;
|
||||
}
|
||||
if (!options?.allowLoopback) {
|
||||
return false;
|
||||
}
|
||||
return isLoopbackHost(parsed.hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a client_id URL per IETF draft §3.
|
||||
* Returns null on success, error string on failure.
|
||||
* Returns null on success, an error string on failure.
|
||||
*
|
||||
* Loopback hosts are rejected unless `allowLoopback` is set; every other
|
||||
* non-public host (private, link-local, cloud-metadata, IPv6 tunnels) is
|
||||
* rejected.
|
||||
*/
|
||||
export function validateClientIdUrl(url: string): string | null {
|
||||
export function validateClientIdUrl(
|
||||
url: string,
|
||||
options?: ClientIdUrlOptions,
|
||||
): string | null {
|
||||
// §3: check the raw URL for dot segments before the URL class normalizes them
|
||||
if (DOT_SEGMENT_RE.test(url)) {
|
||||
return "client_id URL MUST NOT contain dot segments";
|
||||
@@ -186,10 +99,6 @@ export function validateClientIdUrl(url: string): string | null {
|
||||
return "client_id URL must use HTTPS";
|
||||
}
|
||||
|
||||
if (parsed.protocol === "http:" && !isLocalhost(parsed.hostname)) {
|
||||
return "client_id URL must use HTTPS (HTTP allowed only for localhost)";
|
||||
}
|
||||
|
||||
// §3: MUST NOT contain credentials
|
||||
if (parsed.username || parsed.password) {
|
||||
return "client_id URL MUST NOT contain credentials";
|
||||
@@ -200,9 +109,18 @@ export function validateClientIdUrl(url: string): string | null {
|
||||
return "client_id URL MUST contain a path component";
|
||||
}
|
||||
|
||||
// SSRF: block private/reserved hosts
|
||||
if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) {
|
||||
return "client_id URL must not resolve to a private or reserved address";
|
||||
if (isLoopbackHost(parsed.hostname)) {
|
||||
if (!options?.allowLoopback) {
|
||||
return "client_id URL must not target a loopback address (set allowLoopback to enable local development)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
return "client_id URL must use HTTPS (HTTP is allowed only for loopback in development)";
|
||||
}
|
||||
if (!isPublicRoutableHost(parsed.hostname)) {
|
||||
return "client_id URL must not target a private or reserved address";
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -362,7 +280,7 @@ export function validateCimdMetadata(
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { valid: false, error: `${field} must use HTTP(S)` };
|
||||
}
|
||||
if (!isLocalhost(parsed.hostname) && isPrivateHost(parsed.hostname)) {
|
||||
if (!isPublicRoutableHost(parsed.hostname)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${field} must not point to a private or reserved address`,
|
||||
@@ -429,12 +347,11 @@ export function validateCimdMetadata(
|
||||
};
|
||||
}
|
||||
|
||||
// Allow localhost redirect URIs for local/native app flows; the
|
||||
// localhost exception only applies to redirect URI fields, never
|
||||
// to client_uri or other origin-bound fields.
|
||||
// Loopback redirect URIs are allowed for local/native app flows
|
||||
// (RFC 8252); this exception applies only to redirect URI fields.
|
||||
const isRedirectField =
|
||||
key === "redirect_uris" || key === "post_logout_redirect_uris";
|
||||
const localhostAllowed = isRedirectField && isLocalhost(uri.hostname);
|
||||
const localhostAllowed = isRedirectField && isLoopbackHost(uri.hostname);
|
||||
if (uri.origin !== clientIdOrigin && !localhostAllowed) {
|
||||
return {
|
||||
valid: false,
|
||||
|
||||
@@ -145,6 +145,12 @@ describe("Host Classification", () => {
|
||||
expect(classifyHost("254.255.255.254").kind).toBe("reserved");
|
||||
});
|
||||
|
||||
it("should identify 6to4 relay anycast 192.88.99.0/24 as reserved (RFC 7526)", () => {
|
||||
expect(classifyHost("192.88.99.1").kind).toBe("reserved");
|
||||
expect(classifyHost("192.88.99.255").kind).toBe("reserved");
|
||||
expect(isPublicRoutableHost("192.88.99.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should identify public addresses", () => {
|
||||
expect(classifyHost("8.8.8.8").kind).toBe("public");
|
||||
expect(classifyHost("1.1.1.1").kind).toBe("public");
|
||||
@@ -192,6 +198,12 @@ describe("Host Classification", () => {
|
||||
expect(classifyHost("2001:0db8:abcd::1").kind).toBe("documentation");
|
||||
});
|
||||
|
||||
it("should flag deprecated site-local fec0::/10 (RFC 3879)", () => {
|
||||
expect(classifyHost("fec0::1").kind).toBe("reserved");
|
||||
expect(classifyHost("fef0::1").kind).toBe("reserved");
|
||||
expect(isPublicRoutableHost("fec0::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should identify public IPv6", () => {
|
||||
expect(classifyHost("2606:4700:4700::1111").kind).toBe("public");
|
||||
expect(classifyHost("2a00:1450:4001:828::200e").kind).toBe("public");
|
||||
@@ -232,6 +244,15 @@ describe("Host Classification", () => {
|
||||
// 169.254.169.254 XOR 0xFFFFFFFF → 5601:5601
|
||||
expect(classifyHost("2001:0:0:0:0:0:5601:5601").kind).toBe("reserved");
|
||||
});
|
||||
|
||||
it("should flag deprecated IPv4-compatible ::/96 (RFC 4291 §2.5.5.1)", () => {
|
||||
// WHATWG URL normalizes [::127.0.0.1] to [::7f00:1] (no ::ffff: marker)
|
||||
expect(classifyHost("::7f00:1").kind).toBe("reserved"); // 127.0.0.1
|
||||
expect(classifyHost("::a9fe:a9fe").kind).toBe("reserved"); // IMDS
|
||||
expect(classifyHost("::a00:1").kind).toBe("reserved"); // 10.0.0.1
|
||||
expect(classifyHost("::808:808").kind).toBe("reserved"); // 8.8.8.8 (deprecated form)
|
||||
expect(isPublicRoutableHost("::7f00:1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IANA special-purpose ranges (not globally reachable)", () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ export type HostKind =
|
||||
| "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.). */
|
||||
/** Other RFC 6890 special-purpose ranges (0.0.0.0/8, 192.0.0.0/24, 192.88.99.0/24, 240.0.0.0/4, 2001::/32, etc.). */
|
||||
| "reserved"
|
||||
/** Cloud metadata service FQDN (e.g. `metadata.google.internal`). */
|
||||
| "cloudMetadata"
|
||||
@@ -183,6 +183,8 @@ function classifyIPv4(ip: string): HostKind {
|
||||
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";
|
||||
// 6to4 relay anycast (RFC 7526, deprecated), not globally reachable.
|
||||
if (inIPv4Range(n, ipv4ToUint32("192.88.99.0"), 24)) return "reserved";
|
||||
if (inIPv4Range(n, ipv4ToUint32("240.0.0.0"), 4)) return "reserved";
|
||||
|
||||
return "public";
|
||||
@@ -231,6 +233,8 @@ function classifyIPv6(expanded: string): HostKind {
|
||||
|
||||
if (firstByte === 0xff) return "multicast";
|
||||
if (firstByte === 0xfe && (secondByte & 0xc0) === 0x80) return "linkLocal";
|
||||
// fec0::/10 — deprecated site-local (RFC 3879), not globally reachable.
|
||||
if (firstByte === 0xfe && (secondByte & 0xc0) === 0xc0) return "reserved";
|
||||
if ((firstByte & 0xfe) === 0xfc) return "private";
|
||||
|
||||
if (expanded.startsWith("2001:0db8:")) return "documentation";
|
||||
@@ -270,6 +274,11 @@ function classifyIPv6(expanded: string): HostKind {
|
||||
// 5f00::/16 — SRv6 SIDs (RFC 9602), not globally reachable.
|
||||
if (expanded.startsWith("5f00:")) return "reserved";
|
||||
|
||||
// ::/96 — deprecated IPv4-compatible IPv6 (RFC 4291 §2.5.5.1). `::` and
|
||||
// `::1` are matched above; the rest of the block embeds an IPv4 (e.g.
|
||||
// `::127.0.0.1`) and is never a valid public target.
|
||||
if (expanded.startsWith("0000:0000:0000:0000:0000:0000:")) return "reserved";
|
||||
|
||||
return "public";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user