Skip to content

Commit af1f472

Browse files
committed
Merge tag '2.1.1'
Fedify 2.1.1
2 parents 3a97632 + 1c7dab5 commit af1f472

6 files changed

Lines changed: 323 additions & 28 deletions

File tree

.github/workflows/build.yaml

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,12 @@ jobs:
5555
echo "Skipping private package: $pkg"
5656
continue
5757
fi
58-
if [[ "$TAG" = "latest" ]]; then
59-
npm publish --logs-dir=. --provenance --access public "$pkg" \
60-
|| grep "Cannot publish over previously published version" *.log
61-
else
62-
npm publish \
63-
--logs-dir=. \
64-
--provenance \
65-
--access public \
66-
--tag "$TAG" \
67-
"$pkg" \
68-
|| grep "Cannot publish over previously published version" *.log
69-
fi
58+
npm publish \
59+
--logs-dir=. \
60+
--provenance \
61+
--access public \
62+
--tag "$TAG" \
63+
"$pkg" \
64+
|| grep "Cannot publish over previously published version" *.log
7065
rm -f *.log
7166
done

CHANGES.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,28 @@ Version 2.2.0
99
To be released.
1010

1111

12+
Version 2.1.1
13+
-------------
14+
15+
Released on March 27, 2026.
16+
17+
### @fedify/fedify
18+
19+
- Limited the number of HTTP redirects followed by the remote document
20+
loaders and signed HTTP fetches to mitigate resource exhaustion during
21+
remote key and document resolution. [[CVE-2026-34148] by Abhinav Jaswal]
22+
23+
- Stopped the remote document loaders and signed HTTP fetches from
24+
revisiting the same URL within a redirect chain, preventing
25+
self-referential redirect loops. [[CVE-2026-34148] by Abhinav Jaswal]
26+
27+
- Persisted negative public key cache entries for failed remote key
28+
fetches, reducing repeated retries against the same unavailable key
29+
across requests. [[CVE-2026-34148] by Abhinav Jaswal]
30+
31+
[CVE-2026-34148]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-gm9m-gwc4-hwgp
32+
33+
1234
Version 2.1.0
1335
-------------
1436

@@ -210,6 +232,26 @@ Released on March 24, 2026.
210232
[#599]: https://github.com/fedify-dev/fedify/pull/599
211233

212234

235+
Version 2.0.8
236+
-------------
237+
238+
Released on March 27, 2026.
239+
240+
### @fedify/fedify
241+
242+
- Limited the number of HTTP redirects followed by the remote document
243+
loaders and signed HTTP fetches to mitigate resource exhaustion during
244+
remote key and document resolution. [[CVE-2026-34148] by Abhinav Jaswal]
245+
246+
- Stopped the remote document loaders and signed HTTP fetches from
247+
revisiting the same URL within a redirect chain, preventing
248+
self-referential redirect loops. [[CVE-2026-34148] by Abhinav Jaswal]
249+
250+
- Persisted negative public key cache entries for failed remote key
251+
fetches, reducing repeated retries against the same unavailable key
252+
across requests. [[CVE-2026-34148] by Abhinav Jaswal]
253+
254+
213255
Version 2.0.7
214256
-------------
215257

@@ -924,6 +966,26 @@ Released on February 22, 2026.
924966
[#351]: https://github.com/fedify-dev/fedify/issues/351
925967

926968

969+
Version 1.10.5
970+
--------------
971+
972+
Released on March 27, 2026.
973+
974+
### @fedify/fedify
975+
976+
- Limited the number of HTTP redirects followed by the remote document
977+
loaders and signed HTTP fetches to mitigate resource exhaustion during
978+
remote key and document resolution. [[CVE-2026-34148] by Abhinav Jaswal]
979+
980+
- Stopped the remote document loaders and signed HTTP fetches from
981+
revisiting the same URL within a redirect chain, preventing
982+
self-referential redirect loops. [[CVE-2026-34148] by Abhinav Jaswal]
983+
984+
- Persisted negative public key cache entries for failed remote key
985+
fetches, reducing repeated retries against the same unavailable key
986+
across requests. [[CVE-2026-34148] by Abhinav Jaswal]
987+
988+
927989
Version 1.10.4
928990
--------------
929991

@@ -1077,6 +1139,26 @@ Released on December 24, 2025.
10771139
- Implemented `list()` method in `WorkersKvStore`. [[#498], [#500]]
10781140

10791141

1142+
Version 1.9.6
1143+
-------------
1144+
1145+
Released on March 27, 2026.
1146+
1147+
### @fedify/fedify
1148+
1149+
- Limited the number of HTTP redirects followed by the remote document
1150+
loaders and signed HTTP fetches to mitigate resource exhaustion during
1151+
remote key and document resolution. [[CVE-2026-34148] by Abhinav Jaswal]
1152+
1153+
- Stopped the remote document loaders and signed HTTP fetches from
1154+
revisiting the same URL within a redirect chain, preventing
1155+
self-referential redirect loops. [[CVE-2026-34148] by Abhinav Jaswal]
1156+
1157+
- Persisted negative public key cache entries for failed remote key
1158+
fetches, reducing repeated retries against the same unavailable key
1159+
across requests. [[CVE-2026-34148] by Abhinav Jaswal]
1160+
1161+
10801162
Version 1.9.5
10811163
-------------
10821164

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,82 @@ test("doubleKnock() complex redirect chain test", async () => {
17751775
fetchMock.hardReset();
17761776
});
17771777

1778+
test("doubleKnock() throws on too many redirects", async () => {
1779+
fetchMock.spyGlobal();
1780+
1781+
let requestCount = 0;
1782+
fetchMock.post("begin:https://example.com/too-many-redirects/", (cl) => {
1783+
requestCount++;
1784+
const index = Number(cl.url.split("/").at(-1));
1785+
return Response.redirect(
1786+
`https://example.com/too-many-redirects/${index + 1}`,
1787+
302,
1788+
);
1789+
});
1790+
1791+
const request = new Request("https://example.com/too-many-redirects/0", {
1792+
method: "POST",
1793+
body: "Redirect loop",
1794+
headers: {
1795+
"Content-Type": "text/plain",
1796+
},
1797+
});
1798+
1799+
await assertRejects(
1800+
() =>
1801+
doubleKnock(
1802+
request,
1803+
{
1804+
keyId: rsaPublicKey2.id!,
1805+
privateKey: rsaPrivateKey2,
1806+
},
1807+
),
1808+
Error,
1809+
"Too many redirections",
1810+
);
1811+
assertEquals(requestCount, 21);
1812+
1813+
fetchMock.hardReset();
1814+
});
1815+
1816+
test("doubleKnock() detects redirect loops", async () => {
1817+
fetchMock.spyGlobal();
1818+
1819+
let requestCount = 0;
1820+
fetchMock.post("https://example.com/redirect-loop-a", () => {
1821+
requestCount++;
1822+
return Response.redirect("https://example.com/redirect-loop-b", 302);
1823+
});
1824+
fetchMock.post("https://example.com/redirect-loop-b", () => {
1825+
requestCount++;
1826+
return Response.redirect("https://example.com/redirect-loop-a", 302);
1827+
});
1828+
1829+
const request = new Request("https://example.com/redirect-loop-a", {
1830+
method: "POST",
1831+
body: "Redirect loop",
1832+
headers: {
1833+
"Content-Type": "text/plain",
1834+
},
1835+
});
1836+
1837+
await assertRejects(
1838+
() =>
1839+
doubleKnock(
1840+
request,
1841+
{
1842+
keyId: rsaPublicKey2.id!,
1843+
privateKey: rsaPrivateKey2,
1844+
},
1845+
),
1846+
Error,
1847+
"Redirect loop detected",
1848+
);
1849+
assertEquals(requestCount, 2);
1850+
1851+
fetchMock.hardReset();
1852+
});
1853+
17781854
test("doubleKnock() async specDeterminer test", async () => {
17791855
// Install mock fetch handler
17801856
fetchMock.spyGlobal();

packages/fedify/src/sig/http.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CryptographicKey } from "@fedify/vocab";
2-
import type { DocumentLoader } from "@fedify/vocab-runtime";
2+
import { type DocumentLoader, FetchError } from "@fedify/vocab-runtime";
33
import { getLogger } from "@logtape/logtape";
44
import {
55
type Span,
@@ -34,6 +34,8 @@ import {
3434
validateCryptoKey,
3535
} from "./key.ts";
3636

37+
const DEFAULT_MAX_REDIRECTION = 20;
38+
3739
/**
3840
* The standard to use for signing and verifying HTTP signatures.
3941
* @since 1.6.0
@@ -1605,8 +1607,19 @@ export async function doubleKnock(
16051607
request: Request,
16061608
identity: { keyId: URL; privateKey: CryptoKey },
16071609
options: DoubleKnockOptions = {},
1610+
): Promise<Response> {
1611+
return await doubleKnockInternal(request, identity, options);
1612+
}
1613+
1614+
async function doubleKnockInternal(
1615+
request: Request,
1616+
identity: { keyId: URL; privateKey: CryptoKey },
1617+
options: DoubleKnockOptions,
1618+
redirected = 0,
1619+
visited = new Set<string>(),
16081620
): Promise<Response> {
16091621
const { specDeterminer, log, tracerProvider, signal } = options;
1622+
visited.add(request.url);
16101623
const origin = new URL(request.url).origin;
16111624
const firstTrySpec: HttpMessageSignaturesSpec = specDeterminer == null
16121625
? "rfc9421"
@@ -1638,11 +1651,26 @@ export async function doubleKnock(
16381651
response.status >= 300 && response.status < 400 &&
16391652
response.headers.has("Location")
16401653
) {
1654+
if (redirected >= DEFAULT_MAX_REDIRECTION) {
1655+
throw new FetchError(
1656+
request.url,
1657+
`Too many redirections (${redirected + 1})`,
1658+
);
1659+
}
16411660
const location = response.headers.get("Location")!;
1642-
return doubleKnock(
1643-
createRedirectRequest(request, location, body),
1661+
const redirectRequest = createRedirectRequest(request, location, body);
1662+
if (visited.has(redirectRequest.url)) {
1663+
throw new FetchError(
1664+
request.url,
1665+
`Redirect loop detected: ${redirectRequest.url}`,
1666+
);
1667+
}
1668+
return doubleKnockInternal(
1669+
redirectRequest,
16441670
identity,
16451671
{ ...options, body },
1672+
redirected + 1,
1673+
visited,
16461674
);
16471675
} else if (
16481676
// FIXME: Temporary hotfix for Mastodon RFC 9421 implementation bug (as of 2025-06-19).
@@ -1764,11 +1792,26 @@ export async function doubleKnock(
17641792
response.status >= 300 && response.status < 400 &&
17651793
response.headers.has("Location")
17661794
) {
1795+
if (redirected >= DEFAULT_MAX_REDIRECTION) {
1796+
throw new FetchError(
1797+
request.url,
1798+
`Too many redirections (${redirected + 1})`,
1799+
);
1800+
}
17671801
const location = response.headers.get("Location")!;
1768-
return doubleKnock(
1769-
createRedirectRequest(request, location, body),
1802+
const redirectRequest = createRedirectRequest(request, location, body);
1803+
if (visited.has(redirectRequest.url)) {
1804+
throw new FetchError(
1805+
request.url,
1806+
`Redirect loop detected: ${redirectRequest.url}`,
1807+
);
1808+
}
1809+
return doubleKnockInternal(
1810+
redirectRequest,
17701811
identity,
17711812
{ ...options, body },
1813+
redirected + 1,
1814+
visited,
17721815
);
17731816
} else if (response.status !== 400 && response.status !== 401) {
17741817
await specDeterminer?.rememberSpec(origin, spec);

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,73 @@ test("getDocumentLoader()", async (t) => {
369369
);
370370
});
371371

372+
let redirectAttempts = 0;
373+
fetchMock.get("begin:https://example.com/too-many-redirects/", (cl) => {
374+
redirectAttempts++;
375+
const index = Number(cl.url.split("/").at(-1));
376+
return {
377+
status: 302,
378+
headers: {
379+
Location: `https://example.com/too-many-redirects/${index + 1}`,
380+
},
381+
};
382+
});
383+
384+
await t.test("too many redirects", async () => {
385+
redirectAttempts = 0;
386+
await rejects(
387+
() => fetchDocumentLoader("https://example.com/too-many-redirects/0"),
388+
FetchError,
389+
"Too many redirections",
390+
);
391+
deepStrictEqual(redirectAttempts, 21);
392+
});
393+
394+
let loopAttempts = 0;
395+
fetchMock.get("https://example.com/redirect-loop-a", () => {
396+
loopAttempts++;
397+
return {
398+
status: 302,
399+
headers: { Location: "https://example.com/redirect-loop-b" },
400+
};
401+
});
402+
fetchMock.get("https://example.com/redirect-loop-b", () => {
403+
loopAttempts++;
404+
return {
405+
status: 302,
406+
headers: { Location: "https://example.com/redirect-loop-a" },
407+
};
408+
});
409+
410+
await t.test("redirect loop", async () => {
411+
loopAttempts = 0;
412+
await rejects(
413+
() => fetchDocumentLoader("https://example.com/redirect-loop-a"),
414+
FetchError,
415+
"Redirect loop detected",
416+
);
417+
deepStrictEqual(loopAttempts, 2);
418+
});
419+
420+
let relativeLoopAttempts = 0;
421+
fetchMock.get("https://example.com/redirect-loop-relative", () => {
422+
relativeLoopAttempts++;
423+
return {
424+
status: 302,
425+
headers: { Location: "/redirect-loop-relative" },
426+
};
427+
});
428+
429+
await t.test("redirect loop with relative location", async () => {
430+
relativeLoopAttempts = 0;
431+
await rejects(
432+
() => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
433+
FetchError,
434+
"Redirect loop detected",
435+
);
436+
deepStrictEqual(relativeLoopAttempts, 1);
437+
});
438+
372439
// Regression test for ReDoS vulnerability (CVE-2025-68475)
373440
// Malicious HTML payload: <a a="b" a="b" ... (unclosed tag)
374441
// With the vulnerable regex, this causes catastrophic backtracking

0 commit comments

Comments
 (0)