From 0a06a81871f93977b2c79ba51d6560a962b4e102 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:57:00 +0000 Subject: [PATCH] fix: preserve validated embedded raster icons in SVG --- AppImage/scripts/local_app_icons.py | 39 +++++++++++++++++-- .../scripts/tests/test_local_app_icons.py | 36 +++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) 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(('' + image * 2 + '').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)