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 be05eac..ed81501 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,7 @@ describe("web-did-resolver", () => { }) expect(mockFetch).toHaveBeenCalledWith( "https://example.com/.well-known/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -92,7 +92,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/custom/path/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -125,7 +125,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/.well-known/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -161,7 +161,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/issuers/v1/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -197,7 +197,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/issuers/v1/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -345,6 +345,116 @@ 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" }, + ) + 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" }, + ) + }) + 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 e12f377..c3130ee 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 } const DEFAULT_ALLOWED_HTTP_HOSTS: string[] = [] @@ -57,9 +68,29 @@ const DEFAULT_DOC_PATH = "/.well-known/did.json" */ async function fetchDidDocumentAtUrl( url: string | URL, - { fetch = globalThis.fetch }: { fetch?: FetchLike } = {}, + { + fetch = globalThis.fetch, + followRedirects = false, + }: { fetch?: FetchLike; followRedirects?: boolean } = {}, ): Promise { - const res = await fetch(url, { mode: "cors" }) + const res = await fetch(url, { + mode: "cors", + redirect: followRedirects ? "follow" : "manual", + }) + + // 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( @@ -141,6 +172,7 @@ export function getResolver({ docPath = DEFAULT_DOC_PATH, fetch = globalThis.fetch, allowedHttpHosts = DEFAULT_ALLOWED_HTTP_HOSTS, + followRedirects = false, }: DidWebResolverOptions = {}): { web: DIDResolver } { async function resolve( did: string, @@ -155,7 +187,10 @@ export function getResolver({ let didDocument: DIDDocument | null = null try { - didDocument = await fetchDidDocumentAtUrl(url, { fetch }) + didDocument = await fetchDidDocumentAtUrl(url, { + fetch, + followRedirects, + }) if (!isDidDocumentForDid(didDocument, did)) { throw new Error("DID document id does not match requested did")