🔐 fix: Restrict data: URLs to <img> in office HTML sanitizer

Codex review on #12934 caught that `data:` lived in the global
`allowedSchemes`, which meant a smuggled `<a href="data:text/html,
<script>...</script>">` would survive sanitization. The Sandpack
iframe sandbox does not gate `target="_blank"` navigations, so a
click would open attacker-controlled HTML in a new tab.

Scope `data:` to `<img src>` only via `allowedSchemesByTag` (mammoth
inlines DOCX images as base64 `data:image/...` URIs — that path still
works). Add a regression suite (`sanitizeOfficeHtml security`) with
8 cases covering: <script> stripping, event-handler removal,
javascript:/data: rejection on anchors, data:image preservation in
<img>, http/https/mailto allowance, target=_blank rel=noopener
enforcement, and <iframe> stripping.
This commit is contained in:
Danny Avila 2026-05-04 06:26:03 +09:00
parent 9ab4f040f3
commit fb4ac84250
2 changed files with 76 additions and 2 deletions

View file

@ -6,6 +6,7 @@ import {
csvToHtml,
excelSheetToHtml,
pptxToSlideListHtml,
sanitizeOfficeHtml,
wordDocToHtml,
} from './html';
@ -217,4 +218,70 @@ describe('Office HTML producers', () => {
expect(html!).toContain('lc-docx');
});
});
describe('sanitizeOfficeHtml security', () => {
test('strips <script> tags entirely', async () => {
const out = await sanitizeOfficeHtml('<p>before<script>alert(1)</script>after</p>');
expect(out).not.toMatch(/<script\b/i);
expect(out).not.toContain('alert(1)');
expect(out).toContain('before');
expect(out).toContain('after');
});
test('drops event-handler attributes from surviving tags', async () => {
const out = await sanitizeOfficeHtml('<img src="https://x.test/a.png" onerror="alert(1)">');
expect(out).not.toMatch(/onerror=/i);
expect(out).toContain('https://x.test/a.png');
});
test('strips javascript: URLs from anchors', async () => {
const out = await sanitizeOfficeHtml('<a href="javascript:alert(1)">click</a>');
expect(out).not.toMatch(/javascript:/i);
// The text survives even when the href is dropped.
expect(out).toContain('click');
});
test('rejects data: URLs in <a href> (only <img src> may use data:)', async () => {
/* The Sandpack iframe sandbox does NOT gate `target="_blank"`
* navigations. A surviving `<a href="data:text/html,...">` would
* open attacker-controlled HTML in a new tab on click. The
* sanitizer scopes `data:` to <img> only — global schemes are
* http/https/mailto. Regression guard for the Codex review on
* PR #12934. */
const out = await sanitizeOfficeHtml(
'<a href="data:text/html,<script>alert(1)</script>">click</a>',
);
expect(out).not.toContain('data:');
expect(out).not.toContain('script');
expect(out).toContain('click');
});
test('preserves data: URLs in <img src> (mammoth inlines DOCX images as base64)', async () => {
const tinyPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const out = await sanitizeOfficeHtml(`<img src="${tinyPng}" alt="dot">`);
expect(out).toContain(tinyPng);
expect(out).toContain('alt="dot"');
});
test('preserves http(s) and mailto links on anchors', async () => {
const out = await sanitizeOfficeHtml(
'<a href="https://example.com">site</a> <a href="mailto:a@b.test">mail</a>',
);
expect(out).toContain('https://example.com');
expect(out).toContain('mailto:a@b.test');
});
test('forces target=_blank rel=noopener on surviving anchors', async () => {
const out = await sanitizeOfficeHtml('<a href="https://example.com">link</a>');
expect(out).toContain('target="_blank"');
expect(out).toContain('rel="noopener noreferrer"');
});
test('strips <iframe> entirely', async () => {
const out = await sanitizeOfficeHtml('<p>x</p><iframe src="https://evil.test"></iframe>');
expect(out).not.toMatch(/<iframe\b/i);
expect(out).not.toContain('evil.test');
});
});
});

View file

@ -58,7 +58,7 @@ function stripColorStyles(style: string | undefined): string | undefined {
* The Sandpack iframe is itself a sandbox — this is defense in depth, not
* the sole barrier.
*/
async function sanitizeOfficeHtml(html: string): Promise<string> {
export async function sanitizeOfficeHtml(html: string): Promise<string> {
const sanitizeHtml = await getSanitizer();
return sanitizeHtml(html, {
allowedTags: [
@ -121,7 +121,14 @@ async function sanitizeOfficeHtml(html: string): Promise<string> {
h5: ['id'],
h6: ['id'],
},
allowedSchemes: ['http', 'https', 'data', 'mailto'],
/* `data:` is intentionally NOT in the global scheme list — it's only
* allowed for `<img src>` (mammoth inlines DOCX images as base64
* `data:image/...` URIs). Without this scoping, an `<a href="data:
* text/html,<script>…</script>">` smuggled through DOCX/PPTX would
* survive sanitization and open attacker-controlled HTML in a new
* tab when clicked — the Sandpack iframe sandbox doesn't gate
* `target="_blank"` navigations. */
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesByTag: {
img: ['http', 'https', 'data'],
},