From b88946225779c94320101d225e1231ccf4111494 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 11 Oct 2026 03:45:34 +0900 Subject: [PATCH] Reject unsupported document URL schemes The only HTTP(S) scheme check in the built-in document loaders lived inside validatePublicUrl(), which both getDocumentLoader() and getAuthenticatedDocumentLoader() skip when allowPrivateAddress is enabled. An option meant to relax address validation therefore also disabled scheme validation, letting ftp:, file:, data: and other URLs reach the runtime's fetch(). The outcome depended on the runtime: Deno throws a generic TypeError instead of UrlError, so callers can treat it as a retryable failure, while Node.js successfully loads data: URLs, so a redirect or alternate link could make the loader return an arbitrary document. Both loaders now reject non-HTTP(S) URLs with UrlError before fetching, whether or not private addresses are allowed. The check runs on every URL the loaders follow, so redirect targets (including those after a double-knock signature fallback) and alternate documents found via the Link header or HTML elements are covered too. HTTP(S) URLs with private addresses remain allowed. The guards mirror the ones added to 2.4-maintenance by the pull request below to keep the merge forward simple. Fixes https://github.com/fedify-dev/fedify/issues/1292 https://github.com/fedify-dev/fedify/pull/1291 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 19 +++ changes.d/fedify/document-url-schemes.md | 10 ++ .../vocab-runtime/document-url-schemes.md | 10 ++ packages/fedify/src/utils/docloader.test.ts | 125 ++++++++++++++++++ packages/fedify/src/utils/docloader.ts | 4 + packages/vocab-runtime/src/docloader.test.ts | 90 +++++++++++++ packages/vocab-runtime/src/docloader.ts | 6 +- 7 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 changes.d/fedify/document-url-schemes.md create mode 100644 changes.d/vocab-runtime/document-url-schemes.md diff --git a/CHANGES.md b/CHANGES.md index 25f3462b3..1a21437cb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,17 @@ Version 2.0.33 To be released. +### @fedify/fedify + + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes such as + `ftp:`, `file:`, and `data:` are now rejected with `UrlError` before + fetching, including redirect and alternate document targets. + HTTP(S) URLs with private addresses remain allowed. [[#1292], [#1293]] + +[#1292]: https://github.com/fedify-dev/fedify/issues/1292 +[#1293]: https://github.com/fedify-dev/fedify/pull/1293 + ### @fedify/cli - Fixed `fedify webfinger` ignoring `-p`/`--allow-private-address` and the @@ -63,6 +74,14 @@ To be released. 404s, are preserved. The fallback includes `Vary: Accept`. [[#1277], [#1280]] +### @fedify/vocab-runtime + + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes such as `ftp:`, + `file:`, and `data:` are now rejected with `UrlError` before fetching, + including redirect and alternate document targets. HTTP(S) URLs with + private addresses remain allowed. [[#1292], [#1293]] + Version 2.0.32 -------------- diff --git a/changes.d/fedify/document-url-schemes.md b/changes.d/fedify/document-url-schemes.md new file mode 100644 index 000000000..a624b63f2 --- /dev/null +++ b/changes.d/fedify/document-url-schemes.md @@ -0,0 +1,10 @@ +--- +links: + '#1292': https://github.com/fedify-dev/fedify/issues/1292 + '#1293': https://github.com/fedify-dev/fedify/pull/1293 +--- + - Fixed `getAuthenticatedDocumentLoader()` skipping URL scheme validation + when `allowPrivateAddress` was enabled. Unsupported schemes such as + `ftp:`, `file:`, and `data:` are now rejected with `UrlError` before + fetching, including redirect and alternate document targets. + HTTP(S) URLs with private addresses remain allowed. [[#1292], [#1293]] diff --git a/changes.d/vocab-runtime/document-url-schemes.md b/changes.d/vocab-runtime/document-url-schemes.md new file mode 100644 index 000000000..fd09f98d3 --- /dev/null +++ b/changes.d/vocab-runtime/document-url-schemes.md @@ -0,0 +1,10 @@ +--- +links: + '#1292': https://github.com/fedify-dev/fedify/issues/1292 + '#1293': https://github.com/fedify-dev/fedify/pull/1293 +--- + - Fixed `getDocumentLoader()` skipping URL scheme validation when + `allowPrivateAddress` was enabled. Unsupported schemes such as `ftp:`, + `file:`, and `data:` are now rejected with `UrlError` before fetching, + including redirect and alternate document targets. HTTP(S) URLs with + private addresses remain allowed. [[#1292], [#1293]] diff --git a/packages/fedify/src/utils/docloader.test.ts b/packages/fedify/src/utils/docloader.test.ts index 97af91ccb..a62618ad7 100644 --- a/packages/fedify/src/utils/docloader.test.ts +++ b/packages/fedify/src/utils/docloader.test.ts @@ -613,3 +613,128 @@ test("getAuthenticatedDocumentLoader() rejects cancellation before fetching", as fetchMock.hardReset(); } }); + +const unsupportedUrls = [ + "ftp://example.com/collection", + "file:///collection", + `data:application/ld+json,${ + encodeURIComponent(JSON.stringify({ id: "https://example.com/data" })) + }`, +]; + +function isUnsupportedProtocolError(error: unknown, url: string): boolean { + return error instanceof UrlError && + error.message === `Unsupported protocol: ${new URL(url).protocol}` && + error.reason === "disallowed"; +} + +async function withFetchStub( + respond: (request: Request) => Response, + callback: (fetched: Request[]) => Promise, +): Promise { + const fetched: Request[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request && init == null + ? input + : new Request(input, init); + fetched.push(request); + return Promise.resolve(respond(request)); + }) as typeof globalThis.fetch; + try { + await callback(fetched); + } finally { + globalThis.fetch = originalFetch; + } +} + +for (const url of unsupportedUrls) { + const scheme = new URL(url).protocol; + const loader = getAuthenticatedDocumentLoader({ + keyId: new URL("https://example.com/key2"), + privateKey: rsaPrivateKey2, + }, { allowPrivateAddress: true }); + + test(`getAuthenticatedDocumentLoader() rejects ${scheme} URLs with private addresses allowed before fetching`, async () => { + await withFetchStub(() => Response.json({}), async (fetched) => { + await rejects(loader(url), (e) => isUnsupportedProtocolError(e, url)); + deepStrictEqual(fetched, []); + }); + }); + + test(`getAuthenticatedDocumentLoader() rejects redirects to ${scheme} URLs with private addresses allowed`, async () => { + const start = "https://example.com/redirect-to-unsupported"; + await withFetchStub(() => Response.redirect(url, 302), async (fetched) => { + await rejects(loader(start), (e) => isUnsupportedProtocolError(e, url)); + deepStrictEqual(fetched.map((r) => r.url), [start]); + }); + }); + + for ( + const firstSpec of ["rfc9421", "draft-cavage-http-signatures-12"] as const + ) { + test(`getAuthenticatedDocumentLoader() rejects redirects to ${scheme} URLs after signature fallback from ${firstSpec}`, async () => { + const start = "https://example.com/fallback-redirect-to-unsupported"; + const fallbackLoader = getAuthenticatedDocumentLoader({ + keyId: new URL("https://example.com/key2"), + privateKey: rsaPrivateKey2, + }, { + allowPrivateAddress: true, + specDeterminer: { + determineSpec: () => firstSpec, + rememberSpec: () => {}, + }, + }); + await withFetchStub( + (request) => + request.headers.has("Signature-Input") === (firstSpec === "rfc9421") + ? new Response("Unauthorized", { status: 401 }) + : Response.redirect(url, 302), + async (fetched) => { + await rejects( + fallbackLoader(start), + (e) => isUnsupportedProtocolError(e, url), + ); + deepStrictEqual(fetched.map((r) => r.url), [start, start]); + deepStrictEqual( + fetched.map((r) => r.headers.has("Signature-Input")), + [firstSpec === "rfc9421", firstSpec !== "rfc9421"], + ); + }, + ); + }); + } + + for (const html of [false, true]) { + test( + `getAuthenticatedDocumentLoader() rejects ${ + html ? "HTML" : "Link header" + } alternates to ${scheme} URLs with private addresses allowed`, + async () => { + const start = "https://example.com/alternate-to-unsupported"; + await withFetchStub( + () => + html + ? new Response( + ``, + { headers: { "Content-Type": "text/html" } }, + ) + : new Response("", { + headers: { + "Content-Type": "text/plain", + Link: + `<${url}>; rel="alternate"; type="application/activity+json"`, + }, + }), + async (fetched) => { + await rejects( + loader(start), + (e) => isUnsupportedProtocolError(e, url), + ); + deepStrictEqual(fetched.map((r) => r.url), [start]); + }, + ); + }, + ); + } +} diff --git a/packages/fedify/src/utils/docloader.ts b/packages/fedify/src/utils/docloader.ts index d3fd3fb1e..42eb43d72 100644 --- a/packages/fedify/src/utils/docloader.ts +++ b/packages/fedify/src/utils/docloader.ts @@ -115,6 +115,10 @@ export function getAuthenticatedDocumentLoader( } async function validateUrl(url: string): Promise { + const protocol = new URL(url).protocol; + if (protocol !== "http:" && protocol !== "https:") { + throw new UrlError(`Unsupported protocol: ${protocol}`); + } if (!allowPrivateAddress) { try { await validatePublicUrl(url); diff --git a/packages/vocab-runtime/src/docloader.test.ts b/packages/vocab-runtime/src/docloader.test.ts index 988c2afbf..5e6107da5 100644 --- a/packages/vocab-runtime/src/docloader.test.ts +++ b/packages/vocab-runtime/src/docloader.test.ts @@ -1084,3 +1084,93 @@ test("getDocumentLoader() rejects cancellation before fetching", async () => { fetchMock.hardReset(); } }); + +const unsupportedUrls = [ + "ftp://example.com/collection", + "file:///collection", + `data:application/ld+json,${ + encodeURIComponent(JSON.stringify({ id: "https://example.com/data" })) + }`, +]; + +function isUnsupportedProtocolError(error: unknown, url: string): boolean { + return error instanceof UrlError && + error.message === `Unsupported protocol: ${new URL(url).protocol}` && + error.reason === "disallowed"; +} + +async function withFetchStub( + respond: (url: string) => Response, + callback: (fetched: string[]) => Promise, +): Promise { + const fetched: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + fetched.push(url); + return Promise.resolve(respond(url)); + }; + try { + await callback(fetched); + } finally { + globalThis.fetch = originalFetch; + } +} + +for (const url of unsupportedUrls) { + const scheme = new URL(url).protocol; + + test(`getDocumentLoader() rejects ${scheme} URLs with private addresses allowed before fetching`, async () => { + await withFetchStub(() => Response.json({}), async (fetched) => { + await rejects( + getDocumentLoader({ allowPrivateAddress: true })(url), + (e) => isUnsupportedProtocolError(e, url), + ); + deepStrictEqual(fetched, []); + }); + }); + + test(`getDocumentLoader() rejects redirects to ${scheme} URLs with private addresses allowed`, async () => { + const start = "https://example.com/redirect-to-unsupported"; + await withFetchStub(() => Response.redirect(url, 302), async (fetched) => { + await rejects( + getDocumentLoader({ allowPrivateAddress: true })(start), + (e) => isUnsupportedProtocolError(e, url), + ); + deepStrictEqual(fetched, [start]); + }); + }); + + for (const html of [false, true]) { + test( + `getDocumentLoader() rejects ${ + html ? "HTML" : "Link header" + } alternates to ${scheme} URLs with private addresses allowed`, + async () => { + const start = "https://example.com/alternate-to-unsupported"; + await withFetchStub( + () => + html + ? new Response( + ``, + { headers: { "Content-Type": "text/html" } }, + ) + : new Response("", { + headers: { + "Content-Type": "text/plain", + Link: + `<${url}>; rel="alternate"; type="application/activity+json"`, + }, + }), + async (fetched) => { + await rejects( + getDocumentLoader({ allowPrivateAddress: true })(start), + (e) => isUnsupportedProtocolError(e, url), + ); + deepStrictEqual(fetched, [start]); + }, + ); + }, + ); + } +} diff --git a/packages/vocab-runtime/src/docloader.ts b/packages/vocab-runtime/src/docloader.ts index fe7e392de..d0bd7ba89 100644 --- a/packages/vocab-runtime/src/docloader.ts +++ b/packages/vocab-runtime/src/docloader.ts @@ -302,7 +302,11 @@ export function getDocumentLoader( visited = new Set(), ): Promise { options?.signal?.throwIfAborted(); - const currentUrl = new URL(url).href; + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new UrlError(`Unsupported protocol: ${parsedUrl.protocol}`); + } + const currentUrl = parsedUrl.href; if (!skipPreloadedContexts && currentUrl in preloadedContexts) { logger.debug("Using preloaded context: {url}.", { url: currentUrl }); return {