VYPR
High severityOSV Advisory· Published Dec 22, 2025· Updated Dec 22, 2025

Fedify has ReDoS Vulnerability in HTML Parsing Regex

CVE-2025-68475

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.

PackageAffected versionsPatched versions
@fedify/fedifynpm
< 1.6.131.6.13
@fedify/fedifynpm
>= 1.7.0, < 1.7.141.7.14
@fedify/fedifynpm
>= 1.8.0, < 1.8.151.8.15
@fedify/fedifynpm
>= 1.9.0, < 1.9.21.9.2

Affected products

1

Patches

2
bf2f0783634e

Merge commit from fork

https://github.com/fedify-dev/fedifyHong Minhee (洪 民憙)Dec 20, 2025via ghsa
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();
       }
    
2bdcb24d7d6d

Fix ReDoS vulnerability in HTML parsing (CVE-2025-68475)

https://github.com/fedify-dev/fedifyHong MinheeDec 19, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.