From 96aa786977a07befa51808113eadfffe22a05e29 Mon Sep 17 00:00:00 2001 From: Kewe63 Date: Wed, 2 Sep 2026 15:35:00 +0300 Subject: [PATCH] fix(did): validate did:jwks fetch URLs --- .../did-resolvers/get-did-resolver.test.ts | 86 +++++++ .../did/src/did-resolvers/get-did-resolver.ts | 221 +++++++++++++++++- 2 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 packages/did/src/did-resolvers/get-did-resolver.test.ts diff --git a/packages/did/src/did-resolvers/get-did-resolver.test.ts b/packages/did/src/did-resolvers/get-did-resolver.test.ts new file mode 100644 index 00000000..6ef5ca6d --- /dev/null +++ b/packages/did/src/did-resolvers/get-did-resolver.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest" + +import { getDidResolver } from "./get-did-resolver" + +type MockFetch = (input: string | URL | Request) => Promise + +const mockJwks = { + keys: [ + { + kty: "EC", + crv: "P-256", + x: "f83OJ3D2xF4d2cBFj4JfFq8RUBnOXHnm9dXfNhf0U4o", + y: "x_FEzRu9-2jMlqG8tWJBz1y8Z5bO1T_3WqF5svQ7vZk", + use: "sig", + }, + ], +} + +function getFetchInputUrl(input: string | URL | Request): string { + if (typeof input === "string") { + return input + } + + if (input instanceof URL) { + return input.href + } + + return input.url +} + +function createDiscoveryFetch(jwksUri: string) { + return vi.fn(async (input) => { + const url = getFetchInputUrl(input) + + if (url === "https://issuer.example/.well-known/jwks.json") { + return new Response("not found", { + status: 404, + statusText: "Not Found", + }) + } + + if (url === "https://issuer.example/.well-known/openid-configuration") { + return Response.json({ jwks_uri: jwksUri }) + } + + return Response.json(mockJwks) + }) +} + +describe("getDidResolver", () => { + it.each([ + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "https://[::ffff:127.0.0.1]/jwks.json", + "https://[::ffff:192.168.0.1]/jwks.json", + "https://[fe90::1]/jwks.json", + ])( + "rejects did:jwks OIDC jwks_uri targets that do not satisfy the URL policy: %s", + async (jwksUri) => { + const fetch = createDiscoveryFetch(jwksUri) + + const resolver = getDidResolver({ webOptions: { fetch } }) + const result = await resolver.resolve("did:jwks:issuer.example") + + expect(result.didDocument).toBeNull() + expect(result.didResolutionMetadata.error).toBe("internalError") + expect(fetch).toHaveBeenCalledTimes(2) + expect(fetch).not.toHaveBeenCalledWith(jwksUri, undefined) + }, + ) + + it.each(["https://keys.example/jwks.json", "https://fd.example/jwks.json"])( + "allows did:jwks OIDC jwks_uri targets that satisfy the URL policy: %s", + async (jwksUri) => { + const fetch = createDiscoveryFetch(jwksUri) + + const resolver = getDidResolver({ webOptions: { fetch } }) + const result = await resolver.resolve("did:jwks:issuer.example") + + expect(result.didDocument?.id).toBe("did:jwks:issuer.example") + expect(result.didResolutionMetadata.contentType).toBe( + "application/did+ld+json", + ) + expect(fetch).toHaveBeenCalledWith(jwksUri, undefined) + }, + ) +}) diff --git a/packages/did/src/did-resolvers/get-did-resolver.ts b/packages/did/src/did-resolvers/get-did-resolver.ts index f322abe0..b3b2f61a 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.ts @@ -16,6 +16,212 @@ interface GetDidResolverOptions extends ResolverOptions { webOptions?: DidWebResolverOptions } +type FetchInput = string | URL | Request + +const DEFAULT_WEB_OPTIONS: DidWebResolverOptions = { + allowedHttpHosts: ["localhost", "127.0.0.1", "0.0.0.0"], +} + +const IPV4_PRIVATE_RANGES = [ + { start: "10.0.0.0", end: "10.255.255.255" }, + { start: "127.0.0.0", end: "127.255.255.255" }, + { start: "169.254.0.0", end: "169.254.255.255" }, + { start: "172.16.0.0", end: "172.31.255.255" }, + { start: "192.168.0.0", end: "192.168.255.255" }, +] + +function getFetchUrl(input: FetchInput): URL { + if (typeof input === "string" || input instanceof URL) { + return new URL(input) + } + + return new URL(input.url) +} + +function ipv4ToNumber(ip: string): number | null { + const parts = ip.split(".") + if (parts.length !== 4) { + return null + } + + let value = 0 + for (const part of parts) { + if (!/^\d+$/.test(part)) { + return null + } + + const byte = Number(part) + if (byte < 0 || byte > 255) { + return null + } + + value = value * 256 + byte + } + + return value +} + +function isPrivateIpv4Address(hostname: string): boolean { + const address = ipv4ToNumber(hostname) + if (address === null) { + return false + } + + if (address === 0) { + return true + } + + return IPV4_PRIVATE_RANGES.some(({ start, end }) => { + const startAddress = ipv4ToNumber(start) + const endAddress = ipv4ToNumber(end) + return ( + startAddress !== null && + endAddress !== null && + address >= startAddress && + address <= endAddress + ) + }) +} + +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/^\[|\]$/g, "") +} + +function expandIpv6Address(ip: string): number[] | null { + if (!ip.includes(":")) { + return null + } + + let normalized = ip + const embeddedIpv4 = ip.match(/(?\d+\.\d+\.\d+\.\d+)$/)?.groups?.ipv4 + if (embeddedIpv4) { + const ipv4 = ipv4ToNumber(embeddedIpv4) + if (ipv4 === null) { + return null + } + + normalized = ip.replace( + embeddedIpv4, + `${((ipv4 >>> 16) & 0xffff).toString(16)}:${(ipv4 & 0xffff).toString(16)}`, + ) + } + + if (normalized.split("::").length > 2) { + return null + } + + const [left = "", right = ""] = normalized.split("::") + const leftGroups = left ? left.split(":") : [] + const rightGroups = right ? right.split(":") : [] + + if (leftGroups.length + rightGroups.length > 8) { + return null + } + + const zeroGroups = 8 - leftGroups.length - rightGroups.length + if (!normalized.includes("::") && zeroGroups !== 0) { + return null + } + + const groups = [ + ...leftGroups, + ...Array.from({ length: zeroGroups }, () => "0"), + ...rightGroups, + ] + + if (groups.length !== 8) { + return null + } + + return groups.map((group) => { + if (!/^[0-9a-f]{1,4}$/i.test(group)) { + return Number.NaN + } + + return Number.parseInt(group, 16) + }) +} + +function isPrivateIpv6Address(hostname: string): boolean { + const groups = expandIpv6Address(hostname) + if (!groups || groups.some(Number.isNaN)) { + return false + } + + const isUnspecified = groups.every((group) => group === 0) + const isLoopback = + groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1 + const firstGroup = groups[0] ?? 0 + const sixthGroup = groups[5] ?? 0 + const seventhGroup = groups[6] ?? 0 + const eighthGroup = groups[7] ?? 0 + const isUniqueLocal = (firstGroup & 0xfe00) === 0xfc00 + const isLinkLocal = (firstGroup & 0xffc0) === 0xfe80 + const isIpv4Mapped = + groups.slice(0, 5).every((group) => group === 0) && sixthGroup === 0xffff + const mappedIpv4 = isIpv4Mapped + ? `${seventhGroup >>> 8}.${seventhGroup & 0xff}.${eighthGroup >>> 8}.${eighthGroup & 0xff}` + : null + + return ( + isUnspecified || + isLoopback || + isUniqueLocal || + isLinkLocal || + (mappedIpv4 !== null && isPrivateIpv4Address(mappedIpv4)) + ) +} + +function isLocalOrPrivateHost(hostname: string): boolean { + const normalized = normalizeHostname(hostname) + + return ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + isPrivateIpv4Address(normalized) || + isPrivateIpv6Address(normalized) + ) +} + +function validateJwksFetchUrl( + input: FetchInput, + allowedHttpHosts: string[], +): void { + const url = getFetchUrl(input) + const hostname = normalizeHostname(url.hostname) + const normalizedAllowedHttpHosts = new Set( + allowedHttpHosts.map(normalizeHostname), + ) + + if ( + isLocalOrPrivateHost(hostname) && + !normalizedAllowedHttpHosts.has(hostname) + ) { + throw new Error( + `Refusing to fetch did:jwks URL at private host: ${url.href}`, + ) + } + + if ( + url.protocol !== "https:" && + !(url.protocol === "http:" && normalizedAllowedHttpHosts.has(hostname)) + ) { + throw new Error( + `Refusing to fetch did:jwks URL with unsafe scheme: ${url.href}`, + ) + } +} + +function createJwksFetch( + fetch: NonNullable, + allowedHttpHosts: string[], +): NonNullable { + return (input, init) => { + validateJwksFetchUrl(input, allowedHttpHosts) + return fetch(input, init) + } +} + /** * Get a did resolver that can resolve multiple DID methods. * @@ -23,17 +229,18 @@ interface GetDidResolverOptions extends ResolverOptions { * @returns A new {@link DidResolver} instance */ export function getDidResolver({ - webOptions = { - allowedHttpHosts: ["localhost", "127.0.0.1", "0.0.0.0"], - }, + webOptions, ...options }: GetDidResolverOptions = {}): DidResolver { - const webFetch = webOptions.fetch + const resolvedWebOptions = webOptions ?? DEFAULT_WEB_OPTIONS + const webFetch = resolvedWebOptions.fetch ?? globalThis.fetch + const jwksAllowedHttpHosts = webOptions?.allowedHttpHosts ?? [] const keyResolver = getKeyDidResolver() - const webResolver = getWebDidResolver(webOptions) + const webResolver = getWebDidResolver(resolvedWebOptions) const jwksResolver = getJwksDidResolver({ - ...webOptions, - fetch: webFetch ? (input, init) => webFetch(input, init) : globalThis.fetch, + ...resolvedWebOptions, + allowedHttpHosts: jwksAllowedHttpHosts, + fetch: createJwksFetch(webFetch, jwksAllowedHttpHosts), }) const pkhResolver = getPkhDidResolver()