mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user