Skip to content

Commit a4a7bb6

Browse files
committed
Merge tag '1.7.14' into 1.8-maintenance
Fedify 1.7.14
2 parents e6352c1 + c6d7e74 commit a4a7bb6

5 files changed

Lines changed: 125 additions & 40 deletions

File tree

.github/workflows/build.yaml

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
ref: ${{ github.event.pull_request.head.sha }}
5454
- uses: denoland/setup-deno@v2
5555
with:
56-
deno-version: v2.x
56+
deno-version: 2.5.6
5757
- run: deno task test --coverage=.cov --junit-path=.test-report.xml
5858
env:
5959
RUST_BACKTRACE: ${{ runner.debug }}
@@ -124,7 +124,7 @@ jobs:
124124
ref: ${{ github.event.pull_request.head.sha }}
125125
- uses: denoland/setup-deno@v2
126126
with:
127-
deno-version: v2.x
127+
deno-version: 2.5.6
128128
- uses: pnpm/action-setup@v4
129129
with:
130130
version: 10
@@ -181,7 +181,7 @@ jobs:
181181
ref: ${{ github.event.pull_request.head.sha }}
182182
- uses: denoland/setup-deno@v2
183183
with:
184-
deno-version: v2.x
184+
deno-version: 2.5.6
185185
- uses: pnpm/action-setup@v4
186186
with:
187187
version: 10
@@ -207,7 +207,7 @@ jobs:
207207
ref: ${{ github.event.pull_request.head.sha }}
208208
- uses: denoland/setup-deno@v2
209209
with:
210-
deno-version: v2.x
210+
deno-version: 2.5.6
211211
- uses: pnpm/action-setup@v4
212212
with:
213213
version: 10
@@ -230,7 +230,7 @@ jobs:
230230
ref: ${{ github.event.pull_request.head.sha }}
231231
- uses: denoland/setup-deno@v2
232232
with:
233-
deno-version: v2.x
233+
deno-version: 2.5.6
234234
- run: deno task hooks:pre-commit
235235

236236
release-test:
@@ -248,7 +248,7 @@ jobs:
248248
ref: ${{ github.event.pull_request.head.sha }}
249249
- uses: denoland/setup-deno@v2
250250
with:
251-
deno-version: v2.x
251+
deno-version: 2.5.6
252252
- uses: pnpm/action-setup@v4
253253
with:
254254
version: 10
@@ -283,7 +283,7 @@ jobs:
283283
ref: ${{ github.event.pull_request.head.sha }}
284284
- uses: denoland/setup-deno@v2
285285
with:
286-
deno-version: v2.x
286+
deno-version: 2.5.6
287287
- uses: pnpm/action-setup@v4
288288
with:
289289
version: latest
@@ -337,7 +337,10 @@ jobs:
337337
if [[ "$GITHUB_REF_TYPE" != tag ]]; then
338338
rm fedify-cli-*.tgz
339339
fi
340-
- run: deno task pack
340+
- run: |
341+
set -ex
342+
mkdir -p "$RUNNER_TEMP"
343+
TMPDIR="$RUNNER_TEMP" deno task pack
341344
working-directory: ${{ github.workspace }}/packages/cli/
342345
- id: extract-changelog
343346
uses: dahlia/submark@5a5ff0a58382fb812616a5801402f5aef00f90ce
@@ -381,7 +384,12 @@ jobs:
381384
set -ex
382385
for pkg in fedify-*.tgz; do
383386
if [[ "$GITHUB_REF_TYPE" = "tag" ]]; then
384-
npm publish --logs-dir=. --provenance --access public "$pkg" \
387+
npm publish \
388+
--logs-dir=. \
389+
--provenance \
390+
--access public \
391+
--tag latest \
392+
"$pkg" \
385393
|| grep "Cannot publish over previously published version" *.log
386394
rm *.log
387395
elif [[ "$GITHUB_EVENT_NAME" = "pull_request_target" ]]; then
@@ -468,7 +476,7 @@ jobs:
468476
ref: ${{ github.event.pull_request.head.sha }}
469477
- uses: denoland/setup-deno@v2
470478
with:
471-
deno-version: v2.x
479+
deno-version: 2.5.6
472480
- run: deno task codegen
473481
working-directory: ${{ github.workspace }}/packages/fedify/
474482
- uses: denoland/deployctl@v1

CHANGES.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ Version 1.8.15
88

99
To be released.
1010

11+
### @fedify/fedify
12+
13+
- Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in
14+
the document loader's HTML parsing. An attacker-controlled server could
15+
respond with a malicious HTML payload that blocked the event loop.
16+
[[CVE-2025-68475]]
17+
1118
### @fedify/sqlite
1219

1320
- Fixed `SyntaxError: Identifier 'Temporal' has already been declared` error
@@ -453,6 +460,17 @@ the versioning.
453460
[iTerm]: https://iterm2.com/
454461

455462

463+
Version 1.7.14
464+
--------------
465+
466+
Released on December 20, 2025.
467+
468+
- Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in
469+
the document loader's HTML parsing. An attacker-controlled server could
470+
respond with a malicious HTML payload that blocked the event loop.
471+
[[CVE-2025-68475]]
472+
473+
456474
Version 1.7.13
457475
--------------
458476

@@ -638,6 +656,19 @@ Released on June 25, 2025.
638656
[#252]: https://github.com/fedify-dev/fedify/pull/252
639657

640658

659+
Version 1.6.13
660+
--------------
661+
662+
Released on December 20, 2025.
663+
664+
- Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in
665+
the document loader's HTML parsing. An attacker-controlled server could
666+
respond with a malicious HTML payload that blocked the event loop.
667+
[[CVE-2025-68475]]
668+
669+
[CVE-2025-68475]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-rchf-xwx2-hm93
670+
671+
641672
Version 1.6.12
642673
--------------
643674

mise.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
# Bun version should be kept in sync with GitHub Actions workflows
33
bun = "1.2.22"
44
# Deno version should be kept in sync with GitHub Actions workflows
5-
deno = "2.5.0"
5+
deno = "2.5.6"
66
node = "22"
77
"npm:pnpm" = "latest"

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { assertEquals, assertRejects, assertThrows } from "@std/assert";
1+
import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert";
22
import fetchMock from "fetch-mock";
33
import process from "node:process";
44
import metadata from "../../deno.json" with { type: "json" };
@@ -365,6 +365,34 @@ test("getDocumentLoader()", async (t) => {
365365
);
366366
});
367367

368+
// Regression test for ReDoS vulnerability (CVE-2025-68475)
369+
// Malicious HTML payload: <a a="b" a="b" ... (unclosed tag)
370+
// With the vulnerable regex, this causes catastrophic backtracking
371+
const maliciousPayload = "<a" + ' a="b"'.repeat(30) + " ";
372+
373+
fetchMock.get("https://example.com/redos", {
374+
body: maliciousPayload,
375+
headers: { "Content-Type": "text/html; charset=utf-8" },
376+
});
377+
378+
await t.step("ReDoS resistance (CVE-2025-68475)", async () => {
379+
const start = performance.now();
380+
// The malicious HTML will fail JSON parsing, but the important thing is
381+
// that it should complete quickly (not hang due to ReDoS)
382+
await assertRejects(
383+
() => fetchDocumentLoader("https://example.com/redos"),
384+
SyntaxError,
385+
);
386+
const elapsed = performance.now() - start;
387+
388+
// Should complete in under 1 second. With the vulnerable regex,
389+
// this would take 14+ seconds for 30 repetitions.
390+
assert(
391+
elapsed < 1000,
392+
`Potential ReDoS vulnerability detected: ${elapsed}ms (expected < 1000ms)`,
393+
);
394+
});
395+
368396
fetchMock.hardReset();
369397
});
370398

packages/fedify/src/runtime/docloader.ts

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -254,37 +254,55 @@ export async function getRemoteDocument(
254254
contentType === "application/xhtml+xml" ||
255255
contentType?.startsWith("application/xhtml+xml;"))
256256
) {
257-
const p =
258-
/<(a|link)((\s+[a-z][a-z:_-]*=("[^"]*"|'[^']*'|[^\s>]+))+)\s*\/?>/ig;
259-
const p2 = /\s+([a-z][a-z:_-]*)=("([^"]*)"|'([^']*)'|([^\s>]+))/ig;
257+
// Security: Limit HTML response size to mitigate ReDoS attacks
258+
const MAX_HTML_SIZE = 1024 * 1024; // 1MB
260259
const html = await response.text();
261-
let m: RegExpExecArray | null;
262-
const rawAttribs: string[] = [];
263-
while ((m = p.exec(html)) !== null) rawAttribs.push(m[2]);
264-
for (const rawAttrs of rawAttribs) {
265-
let m2: RegExpExecArray | null;
266-
const attribs: Record<string, string> = {};
267-
while ((m2 = p2.exec(rawAttrs)) !== null) {
268-
const key = m2[1].toLowerCase();
269-
const value = m2[3] ?? m2[4] ?? m2[5] ?? "";
270-
attribs[key] = value;
271-
}
272-
if (
273-
attribs.rel === "alternate" && "type" in attribs && (
274-
attribs.type === "application/activity+json" ||
275-
attribs.type === "application/ld+json" ||
276-
attribs.type.startsWith("application/ld+json;")
277-
) && "href" in attribs &&
278-
new URL(attribs.href, docUrl).href !== docUrl.href
279-
) {
280-
logger.debug(
281-
"Found alternate document: {alternateUrl} from {url}",
282-
{ alternateUrl: attribs.href, url: documentUrl },
283-
);
284-
return await fetch(new URL(attribs.href, docUrl).href);
260+
if (html.length > MAX_HTML_SIZE) {
261+
logger.warn(
262+
"HTML response too large, skipping alternate link discovery: {url}",
263+
{ url: documentUrl, size: html.length },
264+
);
265+
document = JSON.parse(html);
266+
} else {
267+
// Safe regex patterns without nested quantifiers to prevent ReDoS
268+
// (CVE-2025-68475)
269+
// Step 1: Extract <a ...> or <link ...> tags
270+
const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi;
271+
// Step 2: Parse attributes
272+
const attrPattern =
273+
/([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
274+
275+
let tagMatch: RegExpExecArray | null;
276+
while ((tagMatch = tagPattern.exec(html)) !== null) {
277+
const tagContent = tagMatch[2];
278+
let attrMatch: RegExpExecArray | null;
279+
const attribs: Record<string, string> = {};
280+
281+
// Reset regex state for attribute parsing
282+
attrPattern.lastIndex = 0;
283+
while ((attrMatch = attrPattern.exec(tagContent)) !== null) {
284+
const key = attrMatch[1].toLowerCase();
285+
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
286+
attribs[key] = value;
287+
}
288+
289+
if (
290+
attribs.rel === "alternate" && "type" in attribs && (
291+
attribs.type === "application/activity+json" ||
292+
attribs.type === "application/ld+json" ||
293+
attribs.type.startsWith("application/ld+json;")
294+
) && "href" in attribs &&
295+
new URL(attribs.href, docUrl).href !== docUrl.href
296+
) {
297+
logger.debug(
298+
"Found alternate document: {alternateUrl} from {url}",
299+
{ alternateUrl: attribs.href, url: documentUrl },
300+
);
301+
return await fetch(new URL(attribs.href, docUrl).href);
302+
}
285303
}
304+
document = JSON.parse(html);
286305
}
287-
document = JSON.parse(html);
288306
} else {
289307
document = await response.json();
290308
}

0 commit comments

Comments
 (0)