Skip to content

Commit baf2eae

Browse files
authored
Sanitize extractor HTML (#326)
* Sanitize site-extractor HTML output (GHSA-jg4p-g6xj-4qmf) Site extractors build their output from template-literal strings, interpolating attacker-controlled DOM attribute values (image alt/src, og:image, video description) without escaping. That output is returned through buildExtractorResponse() which previously only resolved URLs, bypassing the DOM-based sanitization the main pipeline applies — so an injected event handler or javascript: URL could reach consumers that render contentHtml. Fix, defense in depth: - Route all extractor output through _sanitizeExtractorHtml(), which parses into a DOM and runs the same _stripUnsafeElements() pass used elsewhere (and resolves relative URLs in the same pass). This covers every extractor, present and future. - escapeHtml() the interpolated values at the confirmed sinks in x-article, substack, and youtube extractors. This is complementary, not redundant: it also blocks injection of attributes the central pass does not strip (style/class/etc.) and preserves data integrity. Add a regression test that re-parses extractor output and asserts no event-handler attributes or javascript: URLs survive. * Update CLAUDE.md
1 parent 19520ad commit baf2eae

6 files changed

Lines changed: 95 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Debug mode preserves class/id/data-* attributes and skips div flattening. Use `c
9595

9696
- **Never use `innerHTML` directly.** Always use `parseHTML()` from `src/utils/dom.ts` which parses via `<template>` elements (no script execution, no resource loading).
9797
- **Sanitize URLs**`javascript:` and `data:text/html` must be stripped from `href`/`src` attributes. `srcdoc` must be stripped from iframes. `on*` event handler attributes must be removed. See `sanitizeContent()` in `src/defuddle.ts`.
98+
- **Never interpolate page-derived values into HTML strings unescaped.** Extractors that build HTML from template literals (e.g. `` `<img src="${src}" alt="${alt}">` ``) must wrap every interpolated value in `escapeHtml()` from `src/utils/dom.ts`, or build the node via `createElement`/`setAttribute` + `serializeHTML()`. An unescaped `"` lets attacker-controlled values (image `alt`/`src`, `og:image`, descriptions) inject new attributes (XSS, e.g. GHSA-jg4p-g6xj-4qmf). Downstream sanitization is a backstop, not a substitute — escape at the point of interpolation. Cover new extractors with a poisoned-attribute case in `tests/extractor-xss.test.ts`.
9899

99100
### Common pitfalls
100101
- **UMD exports**: `export: 'default'` in webpack means named exports must be static properties on the default class (see `src/index.full.ts`).

src/defuddle.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,7 +1678,7 @@ export class Defuddle {
16781678
extractor: BaseExtractor,
16791679
pageMetaTags: MetaTagItem[]
16801680
): DefuddleResponse {
1681-
const contentHtml = this.resolveContentUrls(extracted.contentHtml);
1681+
const contentHtml = this._sanitizeExtractorHtml(extracted.contentHtml);
16821682
const variables = this.getExtractorVariables(extracted.variables);
16831683
return {
16841684
content: contentHtml,
@@ -1692,14 +1692,37 @@ export class Defuddle {
16921692
author: extracted.variables?.author || metadata.author,
16931693
site: extracted.variables?.site || metadata.site,
16941694
schemaOrgData: metadata.schemaOrgData,
1695-
wordCount: this.countHtmlWords(extracted.contentHtml),
1695+
wordCount: this.countHtmlWords(contentHtml),
16961696
parseTime: Math.round(Date.now() - startTime),
16971697
extractorType: extractor.constructor.name.replace('Extractor', '').toLowerCase(),
16981698
metaTags: pageMetaTags,
16991699
...(variables ? { variables } : {}),
17001700
};
17011701
}
17021702

1703+
/**
1704+
* Sanitize and finalize HTML produced by site extractors.
1705+
*
1706+
* Extractors build their output from template-literal strings, so unlike the
1707+
* main pipeline their output never passes through the DOM-based attribute
1708+
* sanitizer. Attacker-controlled attribute values (e.g. an image `alt` or
1709+
* `src` read straight off the page) could otherwise close an attribute and
1710+
* inject an event handler or a `javascript:` URL. Parsing the output into a
1711+
* DOM and running the same `_stripUnsafeElements` pass used elsewhere
1712+
* neutralizes any such injection regardless of which extractor produced it.
1713+
*
1714+
* Relative-URL resolution runs on the same parsed DOM so extractor output is
1715+
* parsed and serialized only once (resolveRelativeUrls no-ops without a URL).
1716+
*/
1717+
private _sanitizeExtractorHtml(html: string): string {
1718+
if (!html) return html;
1719+
const container = this.doc.createElement('div');
1720+
container.appendChild(parseHTML(this.doc, html));
1721+
this._stripUnsafeElements(container);
1722+
this.resolveRelativeUrls(container);
1723+
return serializeHTML(container);
1724+
}
1725+
17031726
/**
17041727
* Filter extractor variables to only include custom ones
17051728
* (exclude standard fields that are already mapped to top-level properties)

src/extractors/substack.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BaseExtractor } from './_base';
22
import { ExtractorResult } from '../types/extractors';
3-
import { parseHTML } from '../utils/dom';
3+
import { parseHTML, escapeHtml } from '../utils/dom';
44

55
const INJECTED_ATTR = 'data-defuddle-substack-post';
66

@@ -178,12 +178,12 @@ export class SubstackExtractor extends BaseExtractor {
178178
if (!this.noteImage) return '';
179179

180180
const ogImage = this.document.querySelector('meta[property="og:image"]')?.getAttribute('content');
181-
if (ogImage) return `<img src="${ogImage}" alt="" />`;
181+
if (ogImage) return `<img src="${escapeHtml(ogImage)}" alt="" />`;
182182

183183
const img = this.noteImage.querySelector('img');
184184
if (!img) return '';
185185
const src = this.getLargestSrc(img);
186-
return src ? `<img src="${src}" alt="" />` : '';
186+
return src ? `<img src="${escapeHtml(src)}" alt="" />` : '';
187187
}
188188

189189
private getLargestSrc(img: Element): string {

src/extractors/x-article.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BaseExtractor } from './_base';
22
import { ExtractorResult } from '../types/extractors';
3-
import { serializeHTML } from '../utils/dom';
3+
import { serializeHTML, escapeHtml } from '../utils/dom';
44

55
const SELECTORS = {
66
ARTICLE_READ_VIEW: '[data-testid="twitterArticleReadView"]',
@@ -111,7 +111,7 @@ export class XArticleExtractor extends BaseExtractor {
111111

112112
const alt = headerPhoto.getAttribute('alt')?.replace(/\s+/g, ' ').trim() || 'Image';
113113

114-
return `<img src="${this.upgradeImageSrc(src)}" alt="${alt}">`;
114+
return `<img src="${escapeHtml(this.upgradeImageSrc(src))}" alt="${escapeHtml(alt)}">`;
115115
}
116116

117117
private cleanContent(container: HTMLElement): void {

src/extractors/youtube.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ export class YoutubeExtractor extends BaseExtractor {
366366
}
367367

368368
private formatDescription(description: string): string {
369-
return `<p>${description.replace(/\n/g, '<br>')}</p>`;
369+
return `<p>${escapeHtml(description).replace(/\n/g, '<br>')}</p>`;
370370
}
371371

372372
private getVideoData(): any {

tests/extractor-xss.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, test, expect } from 'vitest';
2+
import { Defuddle } from '../src/node';
3+
import { parseLinkedomHTML } from '../src/utils/linkedom-compat';
4+
5+
// Regression test for GHSA-jg4p-g6xj-4qmf: XSS via unescaped attribute
6+
// interpolation in site extractors. Extractor output is built from template
7+
// strings and previously bypassed DOM-based sanitization, so attacker-
8+
// controlled attribute values (e.g. an image alt read off the page) could
9+
// close the attribute and inject an event handler or a javascript: URL.
10+
11+
const X_URL = 'https://x.com/testuser/article/123456789';
12+
13+
// Header image lives in the read view but OUTSIDE the article container, so
14+
// extractHeaderImage() emits it via a template string.
15+
function makeXArticleHTML(headerImgAttrs: string): string {
16+
return `
17+
<html><head><title>Test Article</title></head>
18+
<body>
19+
<div data-testid="twitterArticleReadView">
20+
<div data-testid="tweetPhoto"><img ${headerImgAttrs}></div>
21+
<div data-testid="twitterArticleRichTextView">
22+
<h1 data-testid="twitter-article-title">Test Article</h1>
23+
<div class="public-DraftStyleDefault-block">Body text</div>
24+
</div>
25+
</div>
26+
</body></html>
27+
`;
28+
}
29+
30+
// Re-parse the extractor output and assert no element carries an executable
31+
// attribute. This is the true security property: an escaped "onerror" living
32+
// inside an alt value is harmless; a real onerror attribute is not.
33+
function assertNoExecutableAttributes(html: string) {
34+
const doc = parseLinkedomHTML(`<body>${html}</body>`, X_URL);
35+
for (const el of Array.from(doc.querySelectorAll('*'))) {
36+
for (const attr of Array.from((el as Element).attributes)) {
37+
expect(attr.name.toLowerCase().startsWith('on')).toBe(false);
38+
if (['src', 'href'].includes(attr.name.toLowerCase())) {
39+
expect(attr.value.toLowerCase().replace(/\s+/g, '')).not.toContain('javascript:');
40+
}
41+
}
42+
}
43+
}
44+
45+
describe('Extractor output XSS sanitization (GHSA-jg4p-g6xj-4qmf)', () => {
46+
test('does not emit an event handler attribute from X header image alt', async () => {
47+
// alt contains a double-quote that, unescaped, would close the attribute
48+
// and turn the rest into a real onerror handler.
49+
const html = makeXArticleHTML('src="https://example.com/img.jpg" alt=\'x" onerror="alert(1)\'');
50+
const doc = parseLinkedomHTML(html, X_URL);
51+
const response = await Defuddle(doc, X_URL);
52+
53+
assertNoExecutableAttributes(response.content);
54+
});
55+
56+
test('strips javascript: URL injected via X header image src', async () => {
57+
const html = makeXArticleHTML('src="javascript:alert(1)" alt="ok"');
58+
const doc = parseLinkedomHTML(html, X_URL);
59+
const response = await Defuddle(doc, X_URL);
60+
61+
assertNoExecutableAttributes(response.content);
62+
});
63+
});

0 commit comments

Comments
 (0)