fix: apply the data: URI allowlist to srcset candidates too

DOMPurify URI-tests srcset as a single string, so an allowed first
candidate waved every later one through - an SVG data: URI could ride
in behind a raster candidate on <img srcset> and <picture><source>.
Check every data: occurrence against the raster allowlist and drop the
whole attribute on one disallowed hit, including the external blocker's
data-blocked-srcset stash, which shares the hook pass with no ordering
guarantee. The token is captured through the metadata-ending comma so
the plain (non-base64) raster form keeps the same verdict as in src.
This commit is contained in:
dealerweb
2026-08-12 17:47:51 +02:00
parent 3e58eb636c
commit b11c3e8932
2 changed files with 73 additions and 0 deletions
+43
View File
@@ -841,5 +841,48 @@ describe('email-sanitization', () => {
restrictDataUriResourcesOnNode(node);
expect(node.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL);
});
// DOMPurify URI-tests srcset as one string, so an allowed first candidate
// would wave an SVG second candidate through (#717 review finding). The
// guard re-checks every data: occurrence and drops the whole attribute.
it('drops a srcset that smuggles an svg candidate behind a raster one', () => {
const html = '<img srcset="data:image/gif;base64,R0lGODlh 1x, data:image/svg+xml,<svg onload=alert(1)></svg> 2x">';
expect(sanitizeEmailHtmlForIframe(html)).not.toContain('svg');
expect(sanitizeEmailHtml(html)).not.toContain('svg');
});
it('drops a <picture><source srcset> with a disallowed data: candidate', () => {
const html = '<picture><source srcset="data:image/png;base64,AAAA 1x, data:image/svg+xml,<svg/> 2x"><img src="data:image/png;base64,AAAA"></picture>';
const clean = sanitizeEmailHtmlForIframe(html);
expect(clean).not.toContain('svg');
expect(clean).toContain('data:image/png;base64,AAAA');
});
it('keeps an all-raster srcset and non-data candidates', () => {
const raster = '<img srcset="data:image/png;base64,AAAA 1x, data:image/gif;base64,R0lGODlh 2x">';
expect(sanitizeEmailHtmlForIframe(raster)).toContain('srcset');
const node = parseHtmlSafely('<img srcset="https://example.com/a.png 1x, https://example.com/b.png 2x">').querySelector('img')!;
restrictDataUriResourcesOnNode(node);
expect(node.getAttribute('srcset')).toContain('example.com/a.png');
});
// The plain (non-base64) data: form ends its metadata with a comma; the
// srcset verdict must match the src verdict for the same URI.
it('keeps the comma-form raster data: URI a src would allow', () => {
const node = parseHtmlSafely('<img srcset="data:image/gif,R0lGODlh 1x">').querySelector('img')!;
restrictDataUriResourcesOnNode(node);
expect(node.getAttribute('srcset')).toContain('data:image/gif,R0lGODlh');
});
// The external blocker stashes the whole srcset before this guard runs in
// the shared hook pass - the stash must not keep the disallowed candidate.
it('scrubs the data-blocked-srcset stash the external blocker leaves', () => {
const node = parseHtmlSafely('<img srcset="https://tracker.example/a.png 1x, data:image/svg+xml,<svg/> 2x">').querySelector('img')!;
blockExternalResourcesOnNode(node);
expect(node.getAttribute('data-blocked-srcset')).toContain('svg');
restrictDataUriResourcesOnNode(node);
expect(node.hasAttribute('data-blocked-srcset')).toBe(false);
expect(node.hasAttribute('srcset')).toBe(false);
});
});
});
+30
View File
@@ -261,6 +261,25 @@ const DATA_URI_BYPASS_TAGS = new Set(['IMG', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK'
const RASTER_IMAGE_DATA_URI =
/^data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)[;,]/i;
/**
* True if a `srcset` value carries a `data:` URI that isn't an allowed inline
* raster image. srcset candidates are comma-separated, but a data: URI itself
* contains commas, so the value can't be split into candidates reliably.
* Instead every `data:` occurrence is checked where it stands: its MIME type
* must be on the raster allowlist. The token is captured through the first
* comma so the plain form (`data:image/gif,...`) keeps the `[;,]` terminator
* the raster regexp requires - same verdict as the src path for the same URI.
* One disallowed hit condemns the whole attribute. No whitespace
* pre-normalization here: whitespace splits srcset tokens before the URL
* parser ever runs, so unlike in `src` it cannot hide a scheme inside a
* candidate.
*/
function srcsetHasDisallowedDataUri(srcset: string): boolean {
const dataUris = srcset.match(/data:[^,\s]*,?/gi);
if (!dataUris) return false;
return dataUris.some((uri) => !RASTER_IMAGE_DATA_URI.test(uri));
}
/**
* Drop `data:` URIs that aren't inline raster images from media elements.
* DOMPurify cannot inspect the bytes inside a data: URI, so an SVG payload can
@@ -283,6 +302,17 @@ export function restrictDataUriResourcesOnNode(node: Element): void {
node.removeAttribute(attr);
}
}
// DOMPurify URI-tests srcset as one string, so an allowed first candidate
// waves every later one through - re-check per candidate. The external
// blocker's data-blocked-srcset stash gets the same treatment: it shares
// this hook pass with no ordering guarantee, and a stashed disallowed
// candidate must not outlive the live attribute.
for (const attr of ['srcset', 'data-blocked-srcset']) {
const value = node.getAttribute(attr);
if (value && srcsetHasDisallowedDataUri(value)) {
node.removeAttribute(attr);
}
}
}
/**