Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
--------------
Expand Down
10 changes: 10 additions & 0 deletions changes.d/fedify/document-url-schemes.md
Original file line number Diff line number Diff line change
@@ -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]]
10 changes: 10 additions & 0 deletions changes.d/vocab-runtime/document-url-schemes.md
Original file line number Diff line number Diff line change
@@ -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]]
125 changes: 125 additions & 0 deletions packages/fedify/src/utils/docloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
): Promise<void> {
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(
`<link rel="alternate" type="application/activity+json" href="${url}">`,
{ 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]);
},
);
},
);
}
}
4 changes: 4 additions & 0 deletions packages/fedify/src/utils/docloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export function getAuthenticatedDocumentLoader(
}

async function validateUrl(url: string): Promise<void> {
const protocol = new URL(url).protocol;
if (protocol !== "http:" && protocol !== "https:") {
throw new UrlError(`Unsupported protocol: ${protocol}`);
}
if (!allowPrivateAddress) {
try {
await validatePublicUrl(url);
Expand Down
90 changes: 90 additions & 0 deletions packages/vocab-runtime/src/docloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
): Promise<void> {
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(
`<link rel="alternate" type="application/activity+json" href="${url}">`,
{ 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]);
},
);
},
);
}
}
6 changes: 5 additions & 1 deletion packages/vocab-runtime/src/docloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,11 @@ export function getDocumentLoader(
visited = new Set<string>(),
): Promise<RemoteDocument> {
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 {
Expand Down
Loading