diff --git a/.changeset/wild-hounds-shave.md b/.changeset/wild-hounds-shave.md new file mode 100644 index 0000000..85c36db --- /dev/null +++ b/.changeset/wild-hounds-shave.md @@ -0,0 +1,14 @@ +--- +"@agentcommercekit/did": minor +--- + +Refuse redirects when resolving did:web documents by default + +`allowedHttpHosts` is checked against the URL built from the DID, but the +fetch followed redirects, so a redirect could move the request to a host or +scheme that check would have rejected. did:web documents are served directly +at a well-known path, so the resolver now sends `redirect: "manual"` and +refuses any redirect response with a precise error that names the redirect +target when the runtime exposes it (Node does; browsers surface an opaque +redirect without one). Set `followRedirects: true` to restore the previous +behavior. diff --git a/packages/did/src/did-resolvers/web-did-resolver.test.ts b/packages/did/src/did-resolvers/web-did-resolver.test.ts index b0c0616..9a40ccd 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.test.ts @@ -60,7 +60,11 @@ describe("web-did-resolver", () => { }) expect(mockFetch).toHaveBeenCalledWith( "https://example.com/.well-known/did.json", - { mode: "cors", signal: expect.any(AbortSignal) }, + { + mode: "cors", + redirect: "manual", + signal: expect.any(AbortSignal), + }, ) }) @@ -92,7 +96,11 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/custom/path/did.json", - { mode: "cors", signal: expect.any(AbortSignal) }, + { + mode: "cors", + redirect: "manual", + signal: expect.any(AbortSignal), + }, ) }) @@ -125,7 +133,11 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/.well-known/did.json", - { mode: "cors", signal: expect.any(AbortSignal) }, + { + mode: "cors", + redirect: "manual", + signal: expect.any(AbortSignal), + }, ) }) @@ -161,7 +173,11 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/issuers/v1/did.json", - { mode: "cors", signal: expect.any(AbortSignal) }, + { + mode: "cors", + redirect: "manual", + signal: expect.any(AbortSignal), + }, ) }) @@ -197,7 +213,11 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/issuers/v1/did.json", - { mode: "cors", signal: expect.any(AbortSignal) }, + { + mode: "cors", + redirect: "manual", + signal: expect.any(AbortSignal), + }, ) }) @@ -345,6 +365,124 @@ describe("web-did-resolver", () => { }) }) + it("refuses redirects by default and reports the redirect target", async () => { + // The allowedHttpHosts check applies to the resolved URL only, so a + // followed redirect could reach a host or scheme it would reject. With + // `redirect: "manual"` the redirect resolves instead of throwing, so + // the error can name the target from the Location header. + mockFetch.mockResolvedValueOnce({ + status: 302, + headers: { + get: (name: string) => + name === "location" ? "http://internal.host/did.json" : null, + }, + }) + + const did = "did:web:example.com" + const resolver = getResolver() + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + const result = await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/did.json", + { + mode: "cors", + redirect: "manual", + signal: expect.any(AbortSignal), + }, + ) + expect(result.didResolutionMetadata.error).toBe("notFound") + expect(result.didResolutionMetadata.message).toBe( + "resolver_error: DID resolution refused a redirect to http://internal.host/did.json. Set followRedirects: true to allow redirects.", + ) + }) + + it("refuses an opaque browser redirect without a target", async () => { + // Browsers surface manual redirects as an opaque response with no + // readable status or headers. + mockFetch.mockResolvedValueOnce({ + type: "opaqueredirect", + status: 0, + headers: { get: () => null }, + }) + + const did = "did:web:example.com" + const resolver = getResolver() + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + const result = await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + expect(result.didResolutionMetadata.error).toBe("notFound") + expect(result.didResolutionMetadata.message).toBe( + "resolver_error: DID resolution refused a redirect. Set followRedirects: true to allow redirects.", + ) + }) + + it("follows redirects when followRedirects is true", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDidDocument), + }) + + const did = "did:web:example.com" + const resolver = getResolver({ followRedirects: true }) + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/did.json", + { + mode: "cors", + redirect: "follow", + signal: expect.any(AbortSignal), + }, + ) + }) + it("uses custom fetch function when provided", async () => { const customFetch = vi.fn().mockResolvedValueOnce( new Response(JSON.stringify(mockDidDocument), { diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index fdbcc25..05bb964 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -44,6 +44,17 @@ export interface DidWebResolverOptions { * @default [] */ allowedHttpHosts?: string[] + /** + * Whether to follow HTTP redirects while fetching the did document. + * + * The `allowedHttpHosts` check applies to the resolved URL only, so a + * followed redirect can move the request to a host or scheme that check + * would have rejected. did:web documents are served directly at a + * well-known path, so redirects are refused by default. + * + * @default false + */ + followRedirects?: boolean /** * Milliseconds to wait for the DID document fetch before aborting. Must * be a positive integer of at most 2147483647 (the 32-bit timer limit). @@ -69,14 +80,34 @@ async function fetchDidDocumentAtUrl( url: string | URL, { fetch = globalThis.fetch, + followRedirects = false, timeout, - }: { fetch?: FetchLike; timeout?: number } = {}, + }: { + fetch?: FetchLike + followRedirects?: boolean + timeout?: number + } = {}, ): Promise { const res = await fetch(url, { mode: "cors", + redirect: followRedirects ? "follow" : "manual", ...(timeout !== undefined ? { signal: AbortSignal.timeout(timeout) } : {}), }) + // With `redirect: "manual"` a redirect resolves instead of throwing, so we + // can report it precisely. Node exposes the 3xx status and Location header; + // browsers return an opaque redirect (type "opaqueredirect", status 0). + if ( + !followRedirects && + (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) + ) { + const location = res.headers.get("location") + const target = location ? ` to ${location}` : "" + throw new Error( + `DID resolution refused a redirect${target}. Set followRedirects: true to allow redirects.`, + ) + } + if (!res.ok) { throw new Error( `DID must resolve to a valid https URL containing a JSON document: Bad response ${res.statusText}`, @@ -157,6 +188,7 @@ export function getResolver({ docPath = DEFAULT_DOC_PATH, fetch = globalThis.fetch, allowedHttpHosts = DEFAULT_ALLOWED_HTTP_HOSTS, + followRedirects = false, timeout = 5000, }: DidWebResolverOptions = {}): { web: DIDResolver } { // Fail fast on a bad timeout rather than surfacing it later as a @@ -184,7 +216,11 @@ export function getResolver({ let didDocument: DIDDocument | null = null try { - didDocument = await fetchDidDocumentAtUrl(url, { fetch, timeout }) + didDocument = await fetchDidDocumentAtUrl(url, { + fetch, + followRedirects, + timeout, + }) if (!isDidDocumentForDid(didDocument, did)) { throw new Error("DID document id does not match requested did")