Skip to content

Commit d74bbbd

Browse files
committed
Merge tag '1.9.2'
Fedify 1.9.2
2 parents 4ac6d43 + 3114e4a commit d74bbbd

7 files changed

Lines changed: 168 additions & 40 deletions

File tree

CHANGES.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,26 @@ To be released.
4848
[#493]: https://github.com/fedify-dev/fedify/pull/493
4949

5050

51+
Version 1.9.2
52+
-------------
53+
54+
Released on December 20, 2025.
55+
56+
### @fedify/fedify
57+
58+
- Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in
59+
the document loader's HTML parsing. An attacker-controlled server could
60+
respond with a malicious HTML payload that blocked the event loop.
61+
[[CVE-2025-68475]]
62+
63+
### @fedify/sqlite
64+
65+
- Fixed `SyntaxError: Identifier 'Temporal' has already been declared` error
66+
that occurred when using `SqliteKvStore` on Node.js or Bun. The error
67+
was caused by duplicate `Temporal` imports during the build process.
68+
[[#487]]
69+
70+
5171
Version 1.9.1
5272
-------------
5373

@@ -365,6 +385,28 @@ Released on October 14, 2025.
365385
CommonJS-based Node.js applications. [[#429], [#431]]
366386

367387

388+
Version 1.8.15
389+
--------------
390+
391+
Released on December 20, 2025.
392+
393+
### @fedify/fedify
394+
395+
- Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in
396+
the document loader's HTML parsing. An attacker-controlled server could
397+
respond with a malicious HTML payload that blocked the event loop.
398+
[[CVE-2025-68475]]
399+
400+
### @fedify/sqlite
401+
402+
- Fixed `SyntaxError: Identifier 'Temporal' has already been declared` error
403+
that occurred when using `SqliteKvStore` on Node.js or Bun. The error
404+
was caused by duplicate `Temporal` imports during the build process.
405+
[[#487]]
406+
407+
[#487]: https://github.com/fedify-dev/fedify/issues/487
408+
409+
368410
Version 1.8.14
369411
--------------
370412

@@ -800,6 +842,17 @@ the versioning.
800842
[iTerm]: https://iterm2.com/
801843

802844

845+
Version 1.7.14
846+
--------------
847+
848+
Released on December 20, 2025.
849+
850+
- Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in
851+
the document loader's HTML parsing. An attacker-controlled server could
852+
respond with a malicious HTML payload that blocked the event loop.
853+
[[CVE-2025-68475]]
854+
855+
803856
Version 1.7.13
804857
--------------
805858

@@ -985,6 +1038,19 @@ Released on June 25, 2025.
9851038
[#252]: https://github.com/fedify-dev/fedify/pull/252
9861039

9871040

1041+
Version 1.6.13
1042+
--------------
1043+
1044+
Released on December 20, 2025.
1045+
1046+
- Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in
1047+
the document loader's HTML parsing. An attacker-controlled server could
1048+
respond with a malicious HTML payload that blocked the event loop.
1049+
[[CVE-2025-68475]]
1050+
1051+
[CVE-2025-68475]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-rchf-xwx2-hm93
1052+
1053+
9881054
Version 1.6.12
9891055
--------------
9901056

docs/manual/kv.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,28 @@ const federation = createFederation<void>({
127127

128128
:::
129129

130+
> [!TIP]
131+
> If you are using Bun, you may encounter a type error in your IDE because
132+
> TypeScript's language server runs on Node.js and resolves the
133+
> `@fedify/sqlite` module using Node.js [conditional exports] instead of
134+
> Bun's. To fix this, add the following to your *tsconfig.json*:
135+
>
136+
> ~~~~ json
137+
> {
138+
> "compilerOptions": {
139+
> "moduleResolution": "bundler",
140+
> "customConditions": ["bun"]
141+
> }
142+
> }
143+
> ~~~~
144+
>
145+
> This tells TypeScript to use Bun's module resolution when resolving
146+
> [conditional exports].
147+
130148
[`SqliteKvStore`]: https://jsr.io/@fedify/sqlite/doc/kv/~/SqliteKvStore
131149
[`node:sqlite`]: https://nodejs.org/api/sqlite.html
132150
[`bun:sqlite`]: https://bun.com/docs/api/sqlite
151+
[conditional exports]: https://nodejs.org/api/packages.html#conditional-exports
133152
134153
### `DenoKvStore` (Deno only)
135154

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" };
@@ -366,6 +366,34 @@ test("getDocumentLoader()", async (t) => {
366366
);
367367
});
368368

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

packages/fedify/src/runtime/docloader.ts

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

packages/sqlite/deno.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
"./kv": "./src/kv.ts"
88
},
99
"imports": {
10-
"@fedify/sqlite": "./src/mod.ts",
11-
"@fedify/sqlite/": "./src/",
1210
"#sqlite": "./src/sqlite.node.ts"
1311
},
1412
"exclude": [

packages/sqlite/src/kv.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { PlatformDatabase } from "#sqlite";
2+
import { SqliteKvStore } from "@fedify/sqlite/kv";
23
import * as temporal from "@js-temporal/polyfill";
34
import { delay } from "@std/async/delay";
45
import assert from "node:assert/strict";
56
import { test } from "node:test";
6-
import { SqliteKvStore } from "./kv.ts";
77

88
let Temporal: typeof temporal.Temporal;
99
if ("Temporal" in globalThis) {
@@ -31,7 +31,7 @@ test("SqliteKvStore.initialize()", async () => {
3131
try {
3232
await store.initialize();
3333
const result = await db.prepare(`
34-
SELECT name FROM sqlite_master
34+
SELECT name FROM sqlite_master
3535
WHERE type='table' AND name=?
3636
`).get(tableName);
3737
assert(result !== undefined);
@@ -180,7 +180,7 @@ test("SqliteKvStore.drop()", async () => {
180180
try {
181181
await store.drop();
182182
const result = await db.prepare(`
183-
SELECT name FROM sqlite_master
183+
SELECT name FROM sqlite_master
184184
WHERE type='table' AND name=?
185185
`).get(tableName);
186186
// Bun returns null, Node returns undefined

packages/sqlite/src/kv.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { type PlatformDatabase, SqliteDatabase } from "#sqlite";
22
import type { KvKey, KvStore, KvStoreSetOptions } from "@fedify/fedify";
3-
import { Temporal } from "@js-temporal/polyfill";
43
import { getLogger } from "@logtape/logtape";
54
import { isEqual } from "es-toolkit";
65
import type { SqliteDatabaseAdapter } from "./adapter.ts";
@@ -81,8 +80,8 @@ export class SqliteKvStore implements KvStore {
8180

8281
const result = this.#db
8382
.prepare(`
84-
SELECT value
85-
FROM "${this.#tableName}"
83+
SELECT value
84+
FROM "${this.#tableName}"
8685
WHERE key = ? AND (expires IS NULL OR expires > ?)
8786
`)
8887
.get(encodedKey, now);
@@ -170,8 +169,8 @@ export class SqliteKvStore implements KvStore {
170169

171170
const currentResult = this.#db
172171
.prepare(`
173-
SELECT value
174-
FROM "${this.#tableName}"
172+
SELECT value
173+
FROM "${this.#tableName}"
175174
WHERE key = ? AND (expires IS NULL OR expires > ?)
176175
`)
177176
.get(encodedKey, now) as { value: string } | undefined;
@@ -236,7 +235,7 @@ export class SqliteKvStore implements KvStore {
236235
`);
237236

238237
this.#db.exec(`
239-
CREATE INDEX IF NOT EXISTS "idx_${this.#tableName}_expires"
238+
CREATE INDEX IF NOT EXISTS "idx_${this.#tableName}_expires"
240239
ON "${this.#tableName}" (expires)
241240
`);
242241

0 commit comments

Comments
 (0)