-
Notifications
You must be signed in to change notification settings - Fork 136
fix(did): validate OIDC discovery jwks_uri targets during did:jwks resolution #200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| --- | ||
| "@agentcommercekit/did": patch | ||
| --- | ||
|
|
||
| Fixed `did:jwks` resolution not applying a URL policy to the `jwks_uri` | ||
| an OIDC discovery document can point resolution at. | ||
|
|
||
| `did:jwks` resolution (via `jwks-did-resolver`) falls back to fetching an | ||
| OpenID configuration document when the direct JWKS endpoint is unavailable, | ||
| and follows a `jwks_uri` found there. Unlike `did:web`, where the fetch | ||
| target is built deterministically from the DID string itself, this | ||
| OIDC-discovered target comes from response content an attacker-controlled | ||
| DID host can shape - a classic SSRF shape, since it could point at an | ||
| internal service, a cloud metadata endpoint (e.g. `169.254.169.254`), or | ||
| other loopback/private/link-local addresses. | ||
|
|
||
| Every fetch the jwks resolver makes (the direct JWKS try, the OpenID | ||
| configuration fetch, and the discovered `jwks_uri` fetch) now goes through a | ||
| policy check requiring `https:` and rejecting loopback, unspecified, | ||
| private (RFC1918), and link-local IPv4/IPv6 targets, including | ||
| IPv4-mapped/compatible IPv6 forms. | ||
|
|
||
| This is a hostname/IP-literal check performed before each request; it does | ||
| not protect against DNS rebinding (a hostname resolving to a disallowed IP | ||
| only at connect time), which would require enforcement at the socket layer. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import { beforeEach, describe, expect, it, vi } from "vitest" | ||
|
|
||
| import type { FetchLike } from "../types" | ||
| import { createPolicyEnforcedFetch, isSafeFetchTarget } from "./url-policy" | ||
|
|
||
| describe("isSafeFetchTarget", () => { | ||
| it("allows an ordinary https URL", () => { | ||
| expect(isSafeFetchTarget(new URL("https://example.com/jwks.json"))).toBe( | ||
| true, | ||
| ) | ||
| }) | ||
|
|
||
| it("rejects http (non-https)", () => { | ||
| expect(isSafeFetchTarget(new URL("http://example.com/jwks.json"))).toBe( | ||
| false, | ||
| ) | ||
| }) | ||
|
|
||
| it("rejects localhost", () => { | ||
| expect(isSafeFetchTarget(new URL("https://localhost/jwks.json"))).toBe( | ||
| false, | ||
| ) | ||
| }) | ||
|
|
||
| it.each([ | ||
| "127.0.0.1", // loopback | ||
| "0.0.0.0", // unspecified | ||
| "10.1.2.3", // private (10.0.0.0/8) | ||
| "172.16.0.1", // private (172.16.0.0/12) | ||
| "172.31.255.255", // private (172.16.0.0/12, upper bound) | ||
| "192.168.1.1", // private (192.168.0.0/16) | ||
| "169.254.169.254", // link-local (cloud metadata endpoint) | ||
| ])("rejects disallowed IPv4 literal %s", (host) => { | ||
| expect(isSafeFetchTarget(new URL(`https://${host}/jwks.json`))).toBe(false) | ||
| }) | ||
|
|
||
| it.each([ | ||
| "172.15.255.255", // just below the 172.16.0.0/12 private range | ||
| "172.32.0.0", // just above the 172.16.0.0/12 private range | ||
| "1.1.1.1", | ||
| "8.8.8.8", | ||
| ])("allows non-private IPv4 literal %s", (host) => { | ||
| expect(isSafeFetchTarget(new URL(`https://${host}/jwks.json`))).toBe(true) | ||
| }) | ||
|
|
||
| it.each([ | ||
| "[::1]", // loopback | ||
| "[::]", // unspecified | ||
| "[fe80::1]", // link-local | ||
| "[fc00::1]", // unique local | ||
| "[fd00::1]", // unique local | ||
| "[::ffff:127.0.0.1]", // IPv4-mapped loopback | ||
| "[::ffff:169.254.169.254]", // IPv4-mapped link-local (cloud metadata) | ||
| ])("rejects disallowed IPv6 literal %s", (host) => { | ||
| expect(isSafeFetchTarget(new URL(`https://${host}/jwks.json`))).toBe(false) | ||
| }) | ||
|
|
||
| it("allows a non-private IPv6 literal", () => { | ||
| expect( | ||
| isSafeFetchTarget(new URL("https://[2606:4700:4700::1111]/jwks.json")), | ||
| ).toBe(true) | ||
| }) | ||
|
|
||
| it("rejects a decimal-encoded IPv4 loopback address (SSRF bypass attempt)", () => { | ||
| // WHATWG URL parsing canonicalizes non-dotted-quad IPv4 forms (decimal, | ||
| // octal, hex) into standard dotted-quad as part of host parsing, so this | ||
| // is caught by the same IPv4 check without any extra handling here. | ||
| const url = new URL("https://2130706433/jwks.json") // 127.0.0.1 | ||
| expect(url.hostname).toBe("127.0.0.1") | ||
| expect(isSafeFetchTarget(url)).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe("createPolicyEnforcedFetch", () => { | ||
| let mockFetch: FetchLike | ||
|
|
||
| beforeEach(() => { | ||
| mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response("{}")) | ||
| }) | ||
|
|
||
| it("delegates to the wrapped fetch for an allowed URL", async () => { | ||
| const policyEnforcedFetch = createPolicyEnforcedFetch(mockFetch) | ||
|
|
||
| await policyEnforcedFetch("https://example.com/jwks.json") | ||
|
|
||
| expect(mockFetch).toHaveBeenCalledWith( | ||
| "https://example.com/jwks.json", | ||
| undefined, | ||
| ) | ||
| }) | ||
|
|
||
| it("throws instead of delegating for a disallowed URL", async () => { | ||
| const policyEnforcedFetch = createPolicyEnforcedFetch(mockFetch) | ||
|
|
||
| // This mirrors the issue's reproduction: an OIDC discovery document | ||
| // pointing jwks_uri at a cloud metadata endpoint. | ||
| await expect( | ||
| policyEnforcedFetch("http://169.254.169.254/latest/meta-data/"), | ||
| ).rejects.toThrow(/Refusing to fetch disallowed URL/) | ||
|
|
||
| expect(mockFetch).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("checks a Request input's URL, not just string/URL inputs", async () => { | ||
| const policyEnforcedFetch = createPolicyEnforcedFetch(mockFetch) | ||
|
|
||
| await expect( | ||
| policyEnforcedFetch(new Request("http://127.0.0.1/jwks.json")), | ||
| ).rejects.toThrow(/Refusing to fetch disallowed URL/) | ||
|
|
||
| expect(mockFetch).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| /** | ||
| * URL policy applied to fetch targets that are not directly derived from a | ||
| * DID identifier — specifically, the `jwks_uri` an OIDC discovery document | ||
| * can point `did:jwks` resolution at (see {@link createPolicyEnforcedFetch}). | ||
| * | ||
| * Unlike `did:web`, where the fetch target is built deterministically from | ||
| * the DID string itself, the OIDC discovery fallback reads a URL out of | ||
| * *response content* and then fetches that — a classic SSRF shape, since an | ||
| * attacker who controls the DID's host also controls where discovery points | ||
| * next. | ||
| */ | ||
| import type { FetchLike } from "../types" | ||
|
|
||
| /** Hostnames rejected outright, regardless of scheme. */ | ||
| const DISALLOWED_HOSTNAMES = new Set(["localhost"]) | ||
|
|
||
| /** | ||
| * Checks whether a dotted-quad IPv4 address falls in a disallowed range: | ||
| * loopback (127.0.0.0/8), unspecified (0.0.0.0/8), private (10.0.0.0/8, | ||
| * 172.16.0.0/12, 192.168.0.0/16), or link-local (169.254.0.0/16). | ||
| */ | ||
| function isDisallowedIPv4(hostname: string): boolean { | ||
| const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname) | ||
|
|
||
| if (!match) { | ||
| return false | ||
| } | ||
|
|
||
| const parts = match.slice(1, 5).map(Number) | ||
|
|
||
| if (parts.some((part) => part > 255)) { | ||
| return false | ||
| } | ||
|
|
||
| const [a, b, c, d] = parts | ||
|
|
||
| if ( | ||
| a === undefined || | ||
| b === undefined || | ||
| c === undefined || | ||
| d === undefined | ||
| ) { | ||
| return false | ||
| } | ||
|
|
||
| if (a === 127 || a === 0) { | ||
| return true // loopback / unspecified | ||
| } | ||
| if (a === 10) { | ||
| return true // 10.0.0.0/8 | ||
| } | ||
| if (a === 172 && b >= 16 && b <= 31) { | ||
| return true // 172.16.0.0/12 | ||
| } | ||
| if (a === 192 && b === 168) { | ||
| return true // 192.168.0.0/16 | ||
| } | ||
| if (a === 169 && b === 254) { | ||
| return true // 169.254.0.0/16 (link-local) | ||
| } | ||
|
|
||
| return false | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether an IPv6 address (without brackets) falls in a disallowed | ||
| * range: loopback (::1), unspecified (::), link-local (fe80::/10), unique | ||
| * local (fc00::/7), or an IPv4-mapped/compatible address whose embedded | ||
| * IPv4 address is itself disallowed. | ||
| */ | ||
| function isDisallowedIPv6(hostname: string): boolean { | ||
| const lower = hostname.toLowerCase() | ||
|
|
||
| if (lower === "::1" || lower === "::") { | ||
| return true | ||
| } | ||
|
|
||
| // IPv4-mapped (::ffff:a.b.c.d) or IPv4-compatible (::a.b.c.d) forms embed | ||
| // a dotted-quad tail — recurse into the IPv4 check for it. WHATWG URL | ||
| // parsing normalizes these into a canonical hex-group form (e.g. | ||
| // "::ffff:7f00:1" for "::ffff:127.0.0.1"), so check both the rare | ||
| // dotted-quad form and the canonical hex-group form. | ||
| const ipv4DottedTail = /(?:^|:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec( | ||
| lower, | ||
| ) | ||
| if (ipv4DottedTail?.[1] && isDisallowedIPv4(ipv4DottedTail[1])) { | ||
| return true | ||
| } | ||
|
|
||
| const ipv4MappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower) | ||
| if (ipv4MappedHex) { | ||
| const high = parseInt(ipv4MappedHex[1] ?? "0", 16) | ||
| const low = parseInt(ipv4MappedHex[2] ?? "0", 16) | ||
| const dottedQuad = [ | ||
| (high >> 8) & 0xff, | ||
| high & 0xff, | ||
| (low >> 8) & 0xff, | ||
| low & 0xff, | ||
| ].join(".") | ||
| if (isDisallowedIPv4(dottedQuad)) { | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| // fe80::/10 link-local: first hextet's top 10 bits are 1111111010, i.e. | ||
| // fe80-febf. | ||
| const firstHextet = lower.split(":")[0] | ||
| if (firstHextet && /^fe[89ab][0-9a-f]$/.test(firstHextet)) { | ||
| return true | ||
| } | ||
|
|
||
| // fc00::/7 unique local: first hextet's top 7 bits are 1111110, i.e. | ||
| // fc00-fdff. | ||
| if (firstHextet && /^f[cd][0-9a-f]{2}$/.test(firstHextet)) { | ||
| return true | ||
| } | ||
|
|
||
| return false | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether a URL is safe to fetch: `https:` only, and not targeting a | ||
| * loopback, unspecified, private, or link-local host. | ||
| * | ||
| * This is a hostname/IP-literal check performed before the request is made. | ||
| * It does not protect against DNS rebinding (a hostname that resolves to a | ||
| * disallowed IP only at connect time) — that requires enforcement at the | ||
| * socket layer, which a `fetch` wrapper cannot provide. | ||
| */ | ||
| export function isSafeFetchTarget(url: URL): boolean { | ||
| if (url.protocol !== "https:") { | ||
| return false | ||
| } | ||
|
|
||
| let hostname = url.hostname.toLowerCase() | ||
|
|
||
| if (DISALLOWED_HOSTNAMES.has(hostname)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- url-policy.ts ---'
sed -n '120,175p' packages/did/src/did-resolvers/url-policy.ts
printf '%s\n' '--- relevant tests ---'
sed -n '1,75p' packages/did/src/did-resolvers/url-policy.test.ts
printf '%s\n' '--- WHATWG URL hostname behavior ---'
node - <<'JS'
for (const value of [
'https://localhost./jwks.json',
'https://localhost/jwks.json',
]) {
const url = new URL(value)
console.log(JSON.stringify({ value, hostname: url.hostname, host: url.host }))
}
JSRepository: agentcommercekit/ack Length of output: 4617 SSRF (CWE-918): Server-Side Request Forgery (SSRF) Reachability: External · Exploitability: Moderate Normalize the trailing DNS root label before hostname matching.
🤖 Prompt for AI Agents |
||
| return false | ||
| } | ||
|
|
||
| // WHATWG URL parsing brackets IPv6 hostnames (e.g. "[::1]"); strip them | ||
| // before range-checking. | ||
| if (hostname.startsWith("[") && hostname.endsWith("]")) { | ||
| hostname = hostname.slice(1, -1) | ||
| } | ||
|
|
||
| if (isDisallowedIPv4(hostname) || isDisallowedIPv6(hostname)) { | ||
| return false | ||
| } | ||
|
|
||
| return true | ||
| } | ||
|
|
||
| /** | ||
| * Wraps a {@link FetchLike} so every request it makes — including ones the | ||
| * wrapped fetch itself issues internally as a result of following content it | ||
| * already fetched, such as an OIDC-discovered `jwks_uri` — is checked against | ||
| * {@link isSafeFetchTarget} before being allowed through. | ||
| */ | ||
| export function createPolicyEnforcedFetch(fetch: FetchLike): FetchLike { | ||
| return async (input, init) => { | ||
| const url = new URL(input instanceof Request ? input.url : input.toString()) | ||
|
|
||
| if (!isSafeFetchTarget(url)) { | ||
| throw new Error( | ||
| `Refusing to fetch disallowed URL: ${url.protocol}//${url.hostname}`, | ||
| ) | ||
| } | ||
|
|
||
| return fetch(input, init) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- url-policy.ts ---'
sed -n '1,190p' packages/did/src/did-resolvers/url-policy.ts
printf '%s\n' '--- url-policy.test.ts ---'
sed -n '1,150p' packages/did/src/did-resolvers/url-policy.test.ts
printf '%s\n' '--- fetch-related declarations ---'
rg -n --glob '!node_modules' --glob '!dist' --glob '!build' 'createPolicyEnforcedFetch|fetch\s*:\s|redirect\s*:' packages/did package.json packages/*/package.json 2>/dev/null | head -120Repository: agentcommercekit/ack Length of output: 10491 SSRF (CWE-918): Server-Side Request Forgery (SSRF) Reachability: External · Exploitability: Moderate Enforce the policy on redirects. If the wrapped fetch follows redirects, an allowed HTTPS endpoint can redirect to an HTTP or internal URL. Validate every redirect target or reject redirects. Add a regression test that confirms the redirected target receives no request. 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: agentcommercekit/ack
Length of output: 9143
🏁 Script executed:
Repository: agentcommercekit/ack
Length of output: 518
🌐 Web query:
IPv4-compatible IPv6 addresses ::127.0.0.1 modern operating system connection maps to IPv4 loopback RFC 4291 deprecated💡 Result:
IPv4-compatible IPv6 addresses, which have the format::127.0.0.1 (or more generally::/96), were defined in early IPv6 specifications to facilitate the transition from IPv4 to IPv6 [1][2][3]. These addresses are explicitly deprecated by RFC 4291 [1][4]. Modern operating systems and network implementations no longer use or support them for transition mechanisms, and they should not be used in current network deployments [1][5][3]. It is important to distinguish between this deprecated format and the standard loopback addresses used by modern systems: 1. IPv4 Loopback: The address 127.0.0.1 is the standard IPv4 loopback address [6][7]. 2. IPv6 Loopback: The address::1 is the standard IPv6 loopback address [8][7]. The deprecated IPv4-compatible IPv6 addresses were intended for automatic tunneling, a mechanism that has since been replaced by more effective strategies [9][5][3]. In contrast, IPv4-mapped IPv6 addresses (::ffff:0:0/96) remain active and are widely used in dual-stack networking to represent IPv4 addresses within an IPv6-capable socket environment [10][3]. When an application on a modern operating system refers to localhost, it typically resolves to either 127.0.0.1 or::1, depending on system configuration and protocol preference, but it does not map to the deprecated IPv4-compatible address format [6][11][7].
Citations:
SSRF (CWE-918): Server-Side Request Forgery (SSRF)
Reachability: External · Exploitability: Theoretical
Block canonical IPv4-compatible IPv6 literals.
https://[::127.0.0.1]/...normalizes to::7f00:1, which bypasses the::ffff:-only check. Reject::a:bliterals whose embedded IPv4 address is disallowed, and add regression tests.🧰 Tools
🪛 OpenGrep (1.27.1)
[ERROR] 90-90: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.
(coderabbit.command-injection.exec-js)
🤖 Prompt for AI Agents