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 {