diff --git a/AppImage/scripts/local_app_icons.py b/AppImage/scripts/local_app_icons.py
index edb576db..b6e185bd 100644
--- a/AppImage/scripts/local_app_icons.py
+++ b/AppImage/scripts/local_app_icons.py
@@ -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')
diff --git a/AppImage/scripts/tests/test_local_app_icons.py b/AppImage/scripts/tests/test_local_app_icons.py
index 2d187339..50b79281 100644
--- a/AppImage/scripts/tests/test_local_app_icons.py
+++ b/AppImage/scripts/tests/test_local_app_icons.py
@@ -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''.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'').decode()
+ for raw in [b'', b'',
+ b'',
+ b'',
+ f''.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''
+ with self.assertRaises(icons.IconError):
+ icons.normalize(('').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)