From 8f27a5556f251d6596534a16e5d3214542f9d03e Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Wed, 29 Jul 2026 01:17:05 +0300 Subject: [PATCH 1/3] feat(did): refuse redirects when resolving did:web by default allowedHttpHosts is applied to the URL built from the DID, but the document fetch followed redirects, so the check only governed the first hop: a did:web served over https could redirect the resolver to plain http, or to a host the allowlist would have rejected. Because the redirect target is chosen by the DID's own host, this also turns resolution into an outbound request the issuer controls. did:web documents are served directly at a well-known path, so redirects are refused by default via redirect: "error". followRedirects: true restores the previous behavior for deployments that need it. --- .changeset/wild-hounds-shave.md | 11 +++ .../did-resolvers/web-did-resolver.test.ts | 76 +++++++++++++++++-- .../did/src/did-resolvers/web-did-resolver.ts | 27 ++++++- 3 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 .changeset/wild-hounds-shave.md diff --git a/.changeset/wild-hounds-shave.md b/.changeset/wild-hounds-shave.md new file mode 100644 index 0000000..ee3eafb --- /dev/null +++ b/.changeset/wild-hounds-shave.md @@ -0,0 +1,11 @@ +--- +"@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: "error"`. 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..f9c5102 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: "error" }, ) }) @@ -92,7 +92,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/custom/path/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "error" }, ) }) @@ -125,7 +125,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/.well-known/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "error" }, ) }) @@ -161,7 +161,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/issuers/v1/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "error" }, ) }) @@ -197,7 +197,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/issuers/v1/did.json", - { mode: "cors" }, + { mode: "cors", redirect: "error" }, ) }) @@ -345,6 +345,72 @@ describe("web-did-resolver", () => { }) }) + it("refuses redirects by default", async () => { + // The allowedHttpHosts check applies to the resolved URL only, so a + // followed redirect could reach a host or scheme it would reject. + mockFetch.mockRejectedValueOnce( + new TypeError("fetch failed: unexpected redirect"), + ) + + 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: "error" }, + ) + expect(result.didResolutionMetadata.error).toBe("notFound") + }) + + 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..ebca7fb 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,15 @@ 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" : "error", + }) if (!res.ok) { throw new Error( @@ -141,6 +158,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 +173,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") From 22f6a5daead3d505d17665a8840f5047db4d61f2 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 17:22:48 +0300 Subject: [PATCH 2/3] feat(did): report the refused redirect target via manual redirect mode Per review: redirect: "error" rejects with a bare TypeError, which the resolver can only surface as a generic notFound. With redirect: "manual" the redirect resolves as a response, so the resolver throws a precise error naming the Location target on Node; browsers surface an opaque redirect and get the same error without a target. --- .changeset/wild-hounds-shave.md | 7 +- .../did-resolvers/web-did-resolver.test.ts | 66 +++++++++++++++---- .../did/src/did-resolvers/web-did-resolver.ts | 15 ++++- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/.changeset/wild-hounds-shave.md b/.changeset/wild-hounds-shave.md index ee3eafb..85c36db 100644 --- a/.changeset/wild-hounds-shave.md +++ b/.changeset/wild-hounds-shave.md @@ -7,5 +7,8 @@ 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: "error"`. Set -`followRedirects: true` to restore the previous behavior. +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 f9c5102..5a7f092 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", redirect: "error" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -92,7 +92,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/custom/path/did.json", - { mode: "cors", redirect: "error" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -125,7 +125,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/.well-known/did.json", - { mode: "cors", redirect: "error" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -161,7 +161,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/issuers/v1/did.json", - { mode: "cors", redirect: "error" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -197,7 +197,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/issuers/v1/did.json", - { mode: "cors", redirect: "error" }, + { mode: "cors", redirect: "manual" }, ) }) @@ -345,12 +345,18 @@ describe("web-did-resolver", () => { }) }) - it("refuses redirects by default", async () => { + 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. - mockFetch.mockRejectedValueOnce( - new TypeError("fetch failed: unexpected redirect"), - ) + // 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() @@ -374,9 +380,47 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/.well-known/did.json", - { mode: "cors", redirect: "error" }, + { 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", + ) + }) + + 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", + ) }) it("follows redirects when followRedirects is true", async () => { diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index ebca7fb..6fe06ac 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -75,9 +75,22 @@ async function fetchDidDocumentAtUrl( ): Promise { const res = await fetch(url, { mode: "cors", - redirect: followRedirects ? "follow" : "error", + 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") + throw new Error( + `DID resolution refused a redirect${location ? ` to ${location}` : ""}`, + ) + } + if (!res.ok) { throw new Error( `DID must resolve to a valid https URL containing a JSON document: Bad response ${res.statusText}`, From 2ea4a2576d0812e06fc725e6207d3191b8f14d05 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 17:49:08 +0300 Subject: [PATCH 3/3] fix(did): point the redirect error at followRedirects --- packages/did/src/did-resolvers/web-did-resolver.test.ts | 4 ++-- packages/did/src/did-resolvers/web-did-resolver.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) 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 5a7f092..ed81501 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.test.ts @@ -384,7 +384,7 @@ describe("web-did-resolver", () => { ) expect(result.didResolutionMetadata.error).toBe("notFound") expect(result.didResolutionMetadata.message).toBe( - "resolver_error: DID resolution refused a redirect to http://internal.host/did.json", + "resolver_error: DID resolution refused a redirect to http://internal.host/did.json. Set followRedirects: true to allow redirects.", ) }) @@ -419,7 +419,7 @@ describe("web-did-resolver", () => { expect(result.didResolutionMetadata.error).toBe("notFound") expect(result.didResolutionMetadata.message).toBe( - "resolver_error: DID resolution refused a redirect", + "resolver_error: DID resolution refused a redirect. Set followRedirects: true to allow redirects.", ) }) diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index 6fe06ac..c3130ee 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -86,8 +86,9 @@ async function fetchDidDocumentAtUrl( (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${location ? ` to ${location}` : ""}`, + `DID resolution refused a redirect${target}. Set followRedirects: true to allow redirects.`, ) }