fix: preserve validated embedded raster icons in SVG

This commit is contained in:
VAIO73
2026-10-18 18:57:00 +00:00
parent 6c269bf7a5
commit 0a06a81871
2 changed files with 71 additions and 4 deletions
+35 -4
View File
@@ -166,7 +166,7 @@ def _svg(body):
raise IconError('invalid SVG') from exc
ns = 'http://www.w3.org/2000/svg'
tags = {'svg', 'g', 'defs', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline',
'polygon', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'title', 'desc'}
'polygon', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'title', 'desc', 'image'}
attrs = {'viewBox', 'width', 'height', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy',
'r', 'rx', 'ry', 'd', 'points', 'fill', 'stroke', 'stroke-width', 'opacity',
'fill-opacity', 'stroke-opacity', 'fill-rule', 'clip-rule', 'stroke-linecap',
@@ -175,20 +175,40 @@ def _svg(body):
if original.tag not in ('svg', '{' + ns + '}svg'):
raise IconError('invalid SVG root')
count = 0
images = 0
def clean(node, depth=0):
nonlocal count
nonlocal count, images
count += 1
if depth > 32 or count > 2048:
raise IconError('SVG complexity limit')
name = node.tag.removeprefix('{' + ns + '}')
if name not in tags:
return None
embedded = None
if name == 'image':
refs = {value for key, value in node.attrib.items()
if key in ('href', '{http://www.w3.org/1999/xlink}href')}
if len(refs) != 1:
return None
match = re.fullmatch(r'data:(image/(?:png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon));base64,([A-Za-z0-9+/=]+)', refs.pop())
if not match:
return None # No external resource or recursive SVG data URI.
images += 1
if images > 1:
raise IconError('SVG embedded image limit')
try:
raster = base64.b64decode(match[2], validate=True)
except ValueError as exc:
raise IconError('invalid embedded image') from exc
raster, _mime = normalize(raster, match[1])
embedded = 'data:image/png;base64,' + base64.b64encode(raster).decode()
out = xml.Element('{' + ns + '}' + name)
for key, value in node.attrib.items():
if key not in attrs or len(value) > 32768:
continue
# Only local fragment paint/clip references; no CSS, href, events,
# foreign namespaces, entities, animation, images, filters or use.
# foreign namespaces, entities, animation, filters or use.
# A single validated raster data URI is installed separately below.
if re.search(r'url\s*\(', value, re.I):
if not re.fullmatch(r'url\(#[A-Za-z_][\w.-]*\)', value):
continue
@@ -198,6 +218,8 @@ def _svg(body):
if not re.fullmatch(r'\d+(?:\.\d+)?(?:px)?', value) or not 0 < float(value.removesuffix('px')) <= MAX_DIMENSION:
continue
out.set(key, value)
if embedded:
out.set('href', embedded)
if name in ('title', 'desc'):
out.text = (node.text or '')[:512]
for child in node:
@@ -206,6 +228,13 @@ def _svg(body):
out.append(cleaned)
return out
result = clean(original)
def has_drawing(node):
name = node.tag.removeprefix('{' + ns + '}')
if name in ('defs', 'clipPath'):
return False
return name in {'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'image'} or any(has_drawing(child) for child in node)
if not has_drawing(result):
raise IconError('SVG contains no supported drawing')
xml.register_namespace('', ns)
output = xml.tostring(result, encoding='utf-8')
if len(output) > MAX_BYTES:
@@ -230,7 +259,9 @@ def normalize(body, mime):
raise IconError('image dimensions too large')
picture.load()
out = io.BytesIO()
picture.convert('RGBA').save(out, format='PNG')
converted = picture.convert('RGBA')
converted.info.clear()
converted.save(out, format='PNG')
result = out.getvalue()
if len(result) > MAX_BYTES:
raise IconError('normalized icon too large')
@@ -98,6 +98,42 @@ class Icons(unittest.TestCase):
with self.assertRaises(icons.IconError):
icons.normalize(data, 'image/svg+xml')
def test_svg_embedded_raster_is_validated_and_preserved_without_external_fetch(self):
from xml.etree import ElementTree as xml
embedded = base64.b64encode(picture()).decode()
raw = f'<svg width="88" height="88" viewBox="0 0 88 88"><image x="4" y="4" width="80" height="80" href="data:image/png;base64,{embedded}"/></svg>'.encode()
with patch.object(icons, '_open') as network:
data, mime = icons.normalize(raw, 'image/svg+xml')
network.assert_not_called()
image = xml.fromstring(data).find('{http://www.w3.org/2000/svg}image')
self.assertIsNotNone(image, 'sanitizing must not report an empty SVG as a found icon')
self.assertEqual(image.get('x'), '4')
self.assertEqual(image.get('width'), '80')
decoded = base64.b64decode(image.get('href').split(',', 1)[1], validate=True)
self.assertEqual(icons.normalize(decoded, 'image/png')[0], decoded)
self.assertEqual(icons.normalize(data, mime), (data, mime))
def test_raster_normalization_discards_source_metadata(self):
source = io.BytesIO()
Image.new('RGB', (2, 2), 'red').save(source, format='PNG', icc_profile=b'fixture-private-metadata')
data, _mime = icons.normalize(source.getvalue(), 'image/png')
with Image.open(io.BytesIO(data)) as result:
self.assertNotIn('icc_profile', result.info)
def test_svg_empty_invalid_or_recursive_embedded_images_are_rejected(self):
nested = base64.b64encode(b'<svg onload="bad()"/>').decode()
for raw in [b'<svg/>', b'<svg><script>bad()</script></svg>',
b'<svg><image href="https://outside.test/icon"/></svg>',
b'<svg><image href="data:image/png;base64,bm90IGFuIGltYWdl"/></svg>',
f'<svg><image href="data:image/svg+xml;base64,{nested}"/></svg>'.encode()]:
with self.assertRaises(icons.IconError), patch.object(icons, '_open') as network:
icons.normalize(raw, 'image/svg+xml')
network.assert_not_called()
embedded = base64.b64encode(picture()).decode()
image = f'<image href="data:image/png;base64,{embedded}"/>'
with self.assertRaises(icons.IconError):
icons.normalize(('<svg>' + image * 2 + '</svg>').encode(), 'image/svg+xml')
def test_explicit_lookup_derives_address_rechecks_and_never_writes_apps(self):
with patch.object(icons, '_run', side_effect=self.resolve) as run, patch.object(icons, '_worker', return_value=(self.png, 'image/png')) as worker:
result = icons.discover(101, self.payload)