Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/wild-hounds-shave.md
Original file line number Diff line number Diff line change
@@ -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.
120 changes: 115 additions & 5 deletions packages/did/src/did-resolvers/web-did-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe("web-did-resolver", () => {
})
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/did.json",
{ mode: "cors" },
{ mode: "cors", redirect: "manual" },
)
})

Expand Down Expand Up @@ -92,7 +92,7 @@ describe("web-did-resolver", () => {

expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/custom/path/did.json",
{ mode: "cors" },
{ mode: "cors", redirect: "manual" },
)
})

Expand Down Expand Up @@ -125,7 +125,7 @@ describe("web-did-resolver", () => {

expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8787/.well-known/did.json",
{ mode: "cors" },
{ mode: "cors", redirect: "manual" },
)
})

Expand Down Expand Up @@ -161,7 +161,7 @@ describe("web-did-resolver", () => {

expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/issuers/v1/did.json",
{ mode: "cors" },
{ mode: "cors", redirect: "manual" },
)
})

Expand Down Expand Up @@ -197,7 +197,7 @@ describe("web-did-resolver", () => {

expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8787/issuers/v1/did.json",
{ mode: "cors" },
{ mode: "cors", redirect: "manual" },
)
})

Expand Down Expand Up @@ -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<DIDResolutionResult>
>(),
},
{},
)

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<DIDResolutionResult>
>(),
},
{},
)

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<DIDResolutionResult>
>(),
},
{},
)

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<FetchLike>().mockResolvedValueOnce(
new Response(JSON.stringify(mockDidDocument), {
Expand Down
41 changes: 38 additions & 3 deletions packages/did/src/did-resolvers/web-did-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand All @@ -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<DidDocument> {
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(
Expand Down Expand Up @@ -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,
Expand All @@ -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")
Expand Down
Loading