fix(client): parse OG tags with DOMParser to remove ReDoS vector

parseOgTags matched untrusted link-preview HTML (up to 50KB) against regexes with two sequential [^>]* quantifiers around a required literal, which backtrack polynomially and froze the UI thread on crafted input. Parse with DOMParser (a linear tokenizer) instead; it also correctly ignores meta-like strings inside comments/scripts. (Security scan F7)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-23 11:59:13 +02:00
co-authored by Claude Opus 4.8
parent e98c1d7cbc
commit 92dae342e0
2 changed files with 39 additions and 36 deletions
@@ -37,48 +37,38 @@ export function clearEmbedCaches(): void {
// -- OG tag parsing -----------------------------------------------------------
/** Escape special regex characters in a string for safe use in `new RegExp()`. */
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Extract Open Graph meta tags from raw HTML using regex (no DOM parser needed). */
/**
* Extract Open Graph meta tags from raw HTML.
*
* F7: parse with the platform HTML tokenizer (DOMParser) instead of backtracking
* regexes. Untrusted preview HTML previously ran through patterns with two
* `[^>]*` quantifiers around a required literal, which backtrack polynomially and
* froze the UI thread on crafted input (ReDoS). A real tokenizer is linear and
* additionally ignores meta-like strings inside comments/scripts.
*
* The 50 KB slice at the call site (fetchOgMeta) is kept as a plain memory bound.
*/
export function parseOgTags(html: string): OgMeta {
function getMetaContent(property: string): string | null {
// Match both property="og:X" and name="og:X" patterns
const escaped = escapeRegex(property);
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']*)["']` +
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
"i",
);
const match = html.match(regex);
if (match !== null) {
return match[1] ?? match[2] ?? null;
const doc = new DOMParser().parseFromString(html, "text/html");
// First matching element wins (document order), mirroring the old first-match
// behaviour. Returns "" for an empty content attribute but null when the
// attribute (or element) is absent, so the title→host fallback still fires.
function metaContent(...ogNames: readonly string[]): string | null {
for (const name of ogNames) {
const el =
doc.querySelector(`meta[property="${name}"]`) ?? doc.querySelector(`meta[name="${name}"]`);
const content = el?.getAttribute("content");
if (content != null) return content;
}
return null;
}
// Fallback: extract <title> tag if no og:title
function getTitle(): string | null {
const og = getMetaContent("og:title");
if (og !== null) return og;
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
return titleMatch?.[1]?.trim() ?? null;
}
// Fallback: extract meta description if no og:description
function getDescription(): string | null {
const og = getMetaContent("og:description");
if (og !== null) return og;
return getMetaContent("description");
}
return {
title: getTitle(),
description: getDescription(),
image: getMetaContent("og:image"),
siteName: getMetaContent("og:site_name"),
title: metaContent("og:title") ?? doc.querySelector("title")?.textContent?.trim() ?? null,
description: metaContent("og:description", "description"),
image: metaContent("og:image"),
siteName: metaContent("og:site_name"),
};
}
@@ -509,6 +509,19 @@ describe("parseOgTags", () => {
const meta = parseOgTags(html);
expect(meta.title).toBe("Spaced Title");
});
it("ignores meta-like strings inside comments and scripts (F7: real HTML parsing, not backtracking regex)", () => {
// A real HTML tokenizer treats these as a comment node and script text, not
// <meta> elements — so attacker-controlled preview HTML can neither smuggle a
// fake OG tag nor drive a polynomial-backtracking regex. A string-matching
// regex would (wrongly) pick up "Commented Out".
const html = `<html><head>
<!-- <meta property="og:title" content="Commented Out"> -->
<script>var s = '<meta property="og:title" content="In Script">';</script>
</head></html>`;
const meta = parseOgTags(html);
expect(meta.title).toBeNull();
});
});
describe("applyOgMeta", () => {