Skip to content

Commit 2d41835

Browse files
committed
Expose configurable redirect limits
Add public maxRedirection options to the vocab-runtime document loader factory and to DoubleKnockOptions so callers can override the default redirect cap when resolving remote documents or performing signed fetches. Wire the option through getAuthenticatedDocumentLoader(), document the new API in CHANGES.md, and add regression tests covering custom redirect limits in the document loader, doubleKnock(), and authenticated loader paths.
1 parent af1f472 commit 2d41835

7 files changed

Lines changed: 128 additions & 6 deletions

File tree

CHANGES.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ Version 2.2.0
88

99
To be released.
1010

11+
### @fedify/fedify
12+
13+
- Added `DoubleKnockOptions.maxRedirection` to configure the maximum number
14+
of redirects followed by `doubleKnock()`.
15+
`getAuthenticatedDocumentLoader()` now also respects
16+
`GetAuthenticatedDocumentLoaderOptions.maxRedirection`.
17+
18+
### @fedify/vocab-runtime
19+
20+
- Added `DocumentLoaderFactoryOptions.maxRedirection` to configure the
21+
maximum number of redirects followed by `getDocumentLoader()`.
22+
1123

1224
Version 2.1.1
1325
-------------

packages/fedify/src/sig/http.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1813,6 +1813,51 @@ test("doubleKnock() throws on too many redirects", async () => {
18131813
fetchMock.hardReset();
18141814
});
18151815

1816+
test("doubleKnock() respects maxRedirection option", async () => {
1817+
fetchMock.spyGlobal();
1818+
1819+
let requestCount = 0;
1820+
fetchMock.post(
1821+
"begin:https://example.com/custom-too-many-redirects/",
1822+
(cl) => {
1823+
requestCount++;
1824+
const index = Number(cl.url.split("/").at(-1));
1825+
return Response.redirect(
1826+
`https://example.com/custom-too-many-redirects/${index + 1}`,
1827+
302,
1828+
);
1829+
},
1830+
);
1831+
1832+
const request = new Request(
1833+
"https://example.com/custom-too-many-redirects/0",
1834+
{
1835+
method: "POST",
1836+
body: "Redirect loop",
1837+
headers: {
1838+
"Content-Type": "text/plain",
1839+
},
1840+
},
1841+
);
1842+
1843+
await assertRejects(
1844+
() =>
1845+
doubleKnock(
1846+
request,
1847+
{
1848+
keyId: rsaPublicKey2.id!,
1849+
privateKey: rsaPrivateKey2,
1850+
},
1851+
{ maxRedirection: 1 },
1852+
),
1853+
Error,
1854+
"Too many redirections",
1855+
);
1856+
assertEquals(requestCount, 2);
1857+
1858+
fetchMock.hardReset();
1859+
});
1860+
18161861
test("doubleKnock() detects redirect loops", async () => {
18171862
fetchMock.spyGlobal();
18181863

packages/fedify/src/sig/http.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1562,6 +1562,13 @@ export interface DoubleKnockOptions {
15621562
* @since 1.8.0
15631563
*/
15641564
signal?: AbortSignal;
1565+
1566+
/**
1567+
* The maximum number of redirections to follow.
1568+
* @default `20`
1569+
* @since 2.2.0
1570+
*/
1571+
maxRedirection?: number;
15651572
}
15661573

15671574
/**
@@ -1619,6 +1626,7 @@ async function doubleKnockInternal(
16191626
visited = new Set<string>(),
16201627
): Promise<Response> {
16211628
const { specDeterminer, log, tracerProvider, signal } = options;
1629+
const maximumRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
16221630
visited.add(request.url);
16231631
const origin = new URL(request.url).origin;
16241632
const firstTrySpec: HttpMessageSignaturesSpec = specDeterminer == null
@@ -1651,7 +1659,7 @@ async function doubleKnockInternal(
16511659
response.status >= 300 && response.status < 400 &&
16521660
response.headers.has("Location")
16531661
) {
1654-
if (redirected >= DEFAULT_MAX_REDIRECTION) {
1662+
if (redirected >= maximumRedirection) {
16551663
throw new FetchError(
16561664
request.url,
16571665
`Too many redirections (${redirected + 1})`,
@@ -1792,7 +1800,7 @@ async function doubleKnockInternal(
17921800
response.status >= 300 && response.status < 400 &&
17931801
response.headers.has("Location")
17941802
) {
1795-
if (redirected >= DEFAULT_MAX_REDIRECTION) {
1803+
if (redirected >= maximumRedirection) {
17961804
throw new FetchError(
17971805
request.url,
17981806
`Too many redirections (${redirected + 1})`,

packages/fedify/src/utils/docloader.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,38 @@ test("getAuthenticatedDocumentLoader()", async (t) => {
5555
});
5656
assertRejects(() => loader("http://localhost"), UrlError);
5757
});
58+
59+
await t.step("custom max redirection", async () => {
60+
fetchMock.spyGlobal();
61+
let requestCount = 0;
62+
fetchMock.get(
63+
"begin:https://example.com/custom-too-many-redirects/",
64+
(cl) => {
65+
requestCount++;
66+
const index = Number(cl.url.split("/").at(-1));
67+
return Response.redirect(
68+
`https://example.com/custom-too-many-redirects/${index + 1}`,
69+
302,
70+
);
71+
},
72+
);
73+
74+
const loader = getAuthenticatedDocumentLoader(
75+
{
76+
keyId: new URL("https://example.com/key2"),
77+
privateKey: rsaPrivateKey2,
78+
},
79+
{ maxRedirection: 1 },
80+
);
81+
await assertRejects(
82+
() => loader("https://example.com/custom-too-many-redirects/0"),
83+
Error,
84+
"Too many redirections",
85+
);
86+
assertEquals(requestCount, 2);
87+
88+
fetchMock.hardReset();
89+
});
5890
});
5991

6092
test("getAuthenticatedDocumentLoader() cancellation", {

packages/fedify/src/utils/docloader.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,13 @@ export interface GetAuthenticatedDocumentLoaderOptions
5757
*/
5858
export function getAuthenticatedDocumentLoader(
5959
identity: { keyId: URL; privateKey: CryptoKey },
60-
{ allowPrivateAddress, userAgent, specDeterminer, tracerProvider }:
61-
GetAuthenticatedDocumentLoaderOptions = {},
60+
{
61+
allowPrivateAddress,
62+
maxRedirection,
63+
userAgent,
64+
specDeterminer,
65+
tracerProvider,
66+
}: GetAuthenticatedDocumentLoaderOptions = {},
6267
): DocumentLoader {
6368
validateCryptoKey(identity.privateKey);
6469
async function load(
@@ -80,6 +85,7 @@ export function getAuthenticatedDocumentLoader(
8085
originalRequest,
8186
identity,
8287
{
88+
maxRedirection,
8389
specDeterminer,
8490
log: curry(logRequest)(logger),
8591
tracerProvider,

packages/vocab-runtime/src/docloader.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,17 @@ test("getDocumentLoader()", async (t) => {
391391
deepStrictEqual(redirectAttempts, 21);
392392
});
393393

394+
await t.test("custom max redirection", async () => {
395+
redirectAttempts = 0;
396+
const loader = getDocumentLoader({ maxRedirection: 1 });
397+
await rejects(
398+
() => loader("https://example.com/too-many-redirects/0"),
399+
FetchError,
400+
"Too many redirections",
401+
);
402+
deepStrictEqual(redirectAttempts, 2);
403+
});
404+
394405
let loopAttempts = 0;
395406
fetchMock.get("https://example.com/redirect-loop-a", () => {
396407
loopAttempts++;

packages/vocab-runtime/src/docloader.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ export interface DocumentLoaderFactoryOptions {
8888
* If an object is given, it is passed to {@link getUserAgent} function.
8989
*/
9090
userAgent?: GetUserAgentOptions | string;
91+
92+
/**
93+
* The maximum number of redirections to follow.
94+
* @default `20`
95+
* @since 2.2.0
96+
*/
97+
maxRedirection?: number;
9198
}
9299

93100
/**
@@ -285,11 +292,12 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
285292
* @since 1.3.0
286293
*/
287294
export function getDocumentLoader(
288-
{ allowPrivateAddress, skipPreloadedContexts, userAgent }:
295+
{ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent }:
289296
GetDocumentLoaderOptions = {},
290297
): DocumentLoader {
291298
const tracerProvider = trace.getTracerProvider();
292299
const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
300+
const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
293301

294302
async function load(
295303
url: string,
@@ -348,7 +356,7 @@ export function getDocumentLoader(
348356
response.status >= 300 && response.status < 400 &&
349357
response.headers.has("Location")
350358
) {
351-
if (redirected >= DEFAULT_MAX_REDIRECTION) {
359+
if (redirected >= maximumRedirection) {
352360
logger.error(
353361
"Too many redirections ({redirections}) while fetching document.",
354362
{ redirections: redirected + 1, url: currentUrl },

0 commit comments

Comments
 (0)