mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
fix: preserve validated embedded raster icons in SVG
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user