Fedify has ReDoS Vulnerability in HTML Parsing Regex
Description
Fedify is a TypeScript library for building federated server apps powered by ActivityPub. Prior to versions 1.6.13, 1.7.14, 1.8.15, and 1.9.2, a Regular Expression Denial of Service (ReDoS) vulnerability exists in Fedify's document loader. The HTML parsing regex at packages/fedify/src/runtime/docloader.ts:259 contains nested quantifiers that cause catastrophic backtracking when processing maliciously crafted HTML responses. This issue has been patched in versions 1.6.13, 1.7.14, 1.8.15, and 1.9.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@fedify/fedifynpm | < 1.6.13 | 1.6.13 |
@fedify/fedifynpm | >= 1.7.0, < 1.7.14 | 1.7.14 |
@fedify/fedifynpm | >= 1.8.0, < 1.8.15 | 1.8.15 |
@fedify/fedifynpm | >= 1.9.0, < 1.9.2 | 1.9.2 |
Affected products
1- Range: 0.1.0, 0.10.0, 0.11.0, …
Patches
2bf2f0783634eMerge commit from fork
3 files changed · +82 −29
CHANGES.md+7 −0 modified@@ -124,6 +124,13 @@ Released on June 30, 2025. typed literal object (e.g., `"votersCount":{"type":"xsd:nonNegativeInteger", "@value":123}`). + - Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in + the document loader's HTML parsing. An attacker-controlled server could + respond with a malicious HTML payload that blocked the event loop. + [[CVE-2025-68475]] + +[CVE-2025-68475]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-rchf-xwx2-hm93 + Version 1.6.2 -------------
fedify/runtime/docloader.test.ts+29 −1 modified@@ -1,4 +1,4 @@ -import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert"; import fetchMock from "fetch-mock"; import process from "node:process"; import metadata from "../deno.json" with { type: "json" }; @@ -364,6 +364,34 @@ test("getDocumentLoader()", async (t) => { ); }); + // Regression test for ReDoS vulnerability (CVE-2025-68475) + // Malicious HTML payload: <a a="b" a="b" ... (unclosed tag) + // With the vulnerable regex, this causes catastrophic backtracking + const maliciousPayload = "<a" + ' a="b"'.repeat(30) + " "; + + fetchMock.get("https://example.com/redos", { + body: maliciousPayload, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + + await t.step("ReDoS resistance (CVE-2025-68475)", async () => { + const start = performance.now(); + // The malicious HTML will fail JSON parsing, but the important thing is + // that it should complete quickly (not hang due to ReDoS) + await assertRejects( + () => fetchDocumentLoader("https://example.com/redos"), + SyntaxError, + ); + const elapsed = performance.now() - start; + + // Should complete in under 1 second. With the vulnerable regex, + // this would take 14+ seconds for 30 repetitions. + assert( + elapsed < 1000, + `Potential ReDoS vulnerability detected: ${elapsed}ms (expected < 1000ms)`, + ); + }); + fetchMock.hardReset(); });
fedify/runtime/docloader.ts+46 −28 modified@@ -235,37 +235,55 @@ export async function getRemoteDocument( contentType === "application/xhtml+xml" || contentType?.startsWith("application/xhtml+xml;")) ) { - const p = - /<(a|link)((\s+[a-z][a-z:_-]*=("[^"]*"|'[^']*'|[^\s>]+))+)\s*\/?>/ig; - const p2 = /\s+([a-z][a-z:_-]*)=("([^"]*)"|'([^']*)'|([^\s>]+))/ig; + // Security: Limit HTML response size to mitigate ReDoS attacks + const MAX_HTML_SIZE = 1024 * 1024; // 1MB const html = await response.text(); - let m: RegExpExecArray | null; - const rawAttribs: string[] = []; - while ((m = p.exec(html)) !== null) rawAttribs.push(m[2]); - for (const rawAttrs of rawAttribs) { - let m2: RegExpExecArray | null; - const attribs: Record<string, string> = {}; - while ((m2 = p2.exec(rawAttrs)) !== null) { - const key = m2[1].toLowerCase(); - const value = m2[3] ?? m2[4] ?? m2[5] ?? ""; - attribs[key] = value; - } - if ( - attribs.rel === "alternate" && "type" in attribs && ( - attribs.type === "application/activity+json" || - attribs.type === "application/ld+json" || - attribs.type.startsWith("application/ld+json;") - ) && "href" in attribs && - new URL(attribs.href, docUrl).href !== docUrl.href - ) { - logger.debug( - "Found alternate document: {alternateUrl} from {url}", - { alternateUrl: attribs.href, url: documentUrl }, - ); - return await fetch(new URL(attribs.href, docUrl).href); + if (html.length > MAX_HTML_SIZE) { + logger.warn( + "HTML response too large, skipping alternate link discovery: {url}", + { url: documentUrl, size: html.length }, + ); + document = JSON.parse(html); + } else { + // Safe regex patterns without nested quantifiers to prevent ReDoS + // (CVE-2025-68475) + // Step 1: Extract <a ...> or <link ...> tags + const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi; + // Step 2: Parse attributes + const attrPattern = + /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; + + let tagMatch: RegExpExecArray | null; + while ((tagMatch = tagPattern.exec(html)) !== null) { + const tagContent = tagMatch[2]; + let attrMatch: RegExpExecArray | null; + const attribs: Record<string, string> = {}; + + // Reset regex state for attribute parsing + attrPattern.lastIndex = 0; + while ((attrMatch = attrPattern.exec(tagContent)) !== null) { + const key = attrMatch[1].toLowerCase(); + const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? ""; + attribs[key] = value; + } + + if ( + attribs.rel === "alternate" && "type" in attribs && ( + attribs.type === "application/activity+json" || + attribs.type === "application/ld+json" || + attribs.type.startsWith("application/ld+json;") + ) && "href" in attribs && + new URL(attribs.href, docUrl).href !== docUrl.href + ) { + logger.debug( + "Found alternate document: {alternateUrl} from {url}", + { alternateUrl: attribs.href, url: documentUrl }, + ); + return await fetch(new URL(attribs.href, docUrl).href); + } } + document = JSON.parse(html); } - document = JSON.parse(html); } else { document = await response.json(); }
2bdcb24d7d6dFix ReDoS vulnerability in HTML parsing (CVE-2025-68475)
3 files changed · +82 −29
CHANGES.md+7 −0 modified@@ -124,6 +124,13 @@ Released on June 30, 2025. typed literal object (e.g., `"votersCount":{"type":"xsd:nonNegativeInteger", "@value":123}`). + - Fixed a ReDoS (Regular Expression Denial of Service) vulnerability in + the document loader's HTML parsing. An attacker-controlled server could + respond with a malicious HTML payload that blocked the event loop. + [[CVE-2025-68475]] + +[CVE-2025-68475]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-rchf-xwx2-hm93 + Version 1.6.2 -------------
fedify/runtime/docloader.test.ts+29 −1 modified@@ -1,4 +1,4 @@ -import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert"; import fetchMock from "fetch-mock"; import process from "node:process"; import metadata from "../deno.json" with { type: "json" }; @@ -364,6 +364,34 @@ test("getDocumentLoader()", async (t) => { ); }); + // Regression test for ReDoS vulnerability (CVE-2025-68475) + // Malicious HTML payload: <a a="b" a="b" ... (unclosed tag) + // With the vulnerable regex, this causes catastrophic backtracking + const maliciousPayload = "<a" + ' a="b"'.repeat(30) + " "; + + fetchMock.get("https://example.com/redos", { + body: maliciousPayload, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + + await t.step("ReDoS resistance (CVE-2025-68475)", async () => { + const start = performance.now(); + // The malicious HTML will fail JSON parsing, but the important thing is + // that it should complete quickly (not hang due to ReDoS) + await assertRejects( + () => fetchDocumentLoader("https://example.com/redos"), + SyntaxError, + ); + const elapsed = performance.now() - start; + + // Should complete in under 1 second. With the vulnerable regex, + // this would take 14+ seconds for 30 repetitions. + assert( + elapsed < 1000, + `Potential ReDoS vulnerability detected: ${elapsed}ms (expected < 1000ms)`, + ); + }); + fetchMock.hardReset(); });
fedify/runtime/docloader.ts+46 −28 modified@@ -235,37 +235,55 @@ export async function getRemoteDocument( contentType === "application/xhtml+xml" || contentType?.startsWith("application/xhtml+xml;")) ) { - const p = - /<(a|link)((\s+[a-z][a-z:_-]*=("[^"]*"|'[^']*'|[^\s>]+))+)\s*\/?>/ig; - const p2 = /\s+([a-z][a-z:_-]*)=("([^"]*)"|'([^']*)'|([^\s>]+))/ig; + // Security: Limit HTML response size to mitigate ReDoS attacks + const MAX_HTML_SIZE = 1024 * 1024; // 1MB const html = await response.text(); - let m: RegExpExecArray | null; - const rawAttribs: string[] = []; - while ((m = p.exec(html)) !== null) rawAttribs.push(m[2]); - for (const rawAttrs of rawAttribs) { - let m2: RegExpExecArray | null; - const attribs: Record<string, string> = {}; - while ((m2 = p2.exec(rawAttrs)) !== null) { - const key = m2[1].toLowerCase(); - const value = m2[3] ?? m2[4] ?? m2[5] ?? ""; - attribs[key] = value; - } - if ( - attribs.rel === "alternate" && "type" in attribs && ( - attribs.type === "application/activity+json" || - attribs.type === "application/ld+json" || - attribs.type.startsWith("application/ld+json;") - ) && "href" in attribs && - new URL(attribs.href, docUrl).href !== docUrl.href - ) { - logger.debug( - "Found alternate document: {alternateUrl} from {url}", - { alternateUrl: attribs.href, url: documentUrl }, - ); - return await fetch(new URL(attribs.href, docUrl).href); + if (html.length > MAX_HTML_SIZE) { + logger.warn( + "HTML response too large, skipping alternate link discovery: {url}", + { url: documentUrl, size: html.length }, + ); + document = JSON.parse(html); + } else { + // Safe regex patterns without nested quantifiers to prevent ReDoS + // (CVE-2025-68475) + // Step 1: Extract <a ...> or <link ...> tags + const tagPattern = /<(a|link)\s+([^>]*?)\s*\/?>/gi; + // Step 2: Parse attributes + const attrPattern = + /([a-z][a-z:_-]*)=(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; + + let tagMatch: RegExpExecArray | null; + while ((tagMatch = tagPattern.exec(html)) !== null) { + const tagContent = tagMatch[2]; + let attrMatch: RegExpExecArray | null; + const attribs: Record<string, string> = {}; + + // Reset regex state for attribute parsing + attrPattern.lastIndex = 0; + while ((attrMatch = attrPattern.exec(tagContent)) !== null) { + const key = attrMatch[1].toLowerCase(); + const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? ""; + attribs[key] = value; + } + + if ( + attribs.rel === "alternate" && "type" in attribs && ( + attribs.type === "application/activity+json" || + attribs.type === "application/ld+json" || + attribs.type.startsWith("application/ld+json;") + ) && "href" in attribs && + new URL(attribs.href, docUrl).href !== docUrl.href + ) { + logger.debug( + "Found alternate document: {alternateUrl} from {url}", + { alternateUrl: attribs.href, url: documentUrl }, + ); + return await fetch(new URL(attribs.href, docUrl).href); + } } + document = JSON.parse(html); } - document = JSON.parse(html); } else { document = await response.json(); }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/advisories/GHSA-rchf-xwx2-hm93ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-68475ghsaADVISORY
- github.com/fedify-dev/fedify/commit/2bdcb24d7d6d5886e0214ed504b63a6dc5488779ghsax_refsource_MISCWEB
- github.com/fedify-dev/fedify/commit/bf2f0783634efed2663d1b187dc55461ee1f987aghsax_refsource_MISCWEB
- github.com/fedify-dev/fedify/releases/tag/1.6.13ghsax_refsource_MISCWEB
- github.com/fedify-dev/fedify/releases/tag/1.7.14ghsax_refsource_MISCWEB
- github.com/fedify-dev/fedify/releases/tag/1.8.15ghsax_refsource_MISCWEB
- github.com/fedify-dev/fedify/releases/tag/1.9.2ghsax_refsource_MISCWEB
- github.com/fedify-dev/fedify/security/advisories/GHSA-rchf-xwx2-hm93ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.