NiceGUI Stored/Reflected XSS in ui.interactive_image via unsanitized SVG content
Description
NiceGUI is a Python-based UI framework. Versions 3.3.1 and below are subject to a XSS vulnerability through the ui.interactive_image component of NiceGUI. The component renders SVG content using Vue's v-html directive without any sanitization. This allows attackers to inject malicious HTML or JavaScript via the SVG <foreignObject> tag whenever the image component is rendered or updated. This is particularly dangerous for dashboards or multi-user applications displaying user-generated content or annotations. This issue is fixed in version 3.4.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
niceguiPyPI | < 3.4.0 | 3.4.0 |
Affected products
1- Range: < 3.4.0
Patches
14 files changed · +45 −13
examples/image_mask_overlay/main.py+1 −1 modified@@ -11,7 +11,7 @@ ui.label('+').style('font-size: 18em') ui.image(mask_src).style('width: 25%') ui.label('=').style('font-size: 18em') - image = ui.interactive_image(img_src).style('width: 25%') + image = ui.interactive_image(img_src, sanitize=False).style('width: 25%') image.content = f''' <image xlink:href="{mask_src}" width="100%" height="100%" x="0" y="0" filter="url(#mask)" /> <filter id="mask">
nicegui/elements/interactive_image.py+36 −5 modified@@ -1,12 +1,13 @@ from __future__ import annotations import time +from collections.abc import Callable from pathlib import Path -from typing import cast +from typing import Literal, cast from typing_extensions import Self -from .. import optional_features +from .. import helpers, optional_features from ..events import GenericEventArguments, Handler, MouseEventArguments, handle_event from ..logging import log from .image import pil_to_base64 @@ -31,6 +32,7 @@ def __init__(self, on_mouse: Handler[MouseEventArguments] | None = None, events: list[str] = ['click'], # noqa: B006 cross: bool | str = False, + sanitize: Callable[[str], str] | Literal[False] | None = None, ) -> None: """Interactive Image @@ -50,13 +52,20 @@ def __init__(self, You can also pass a tuple of width and height instead of an image source. This will create an empty image with the given size. + Note that since NiceGUI 3.4.0, you need to specify how to ``sanitize`` the SVG content (if any). + Especially if you are displaying user input, you should sanitize the content to prevent XSS attacks. + We recommend ``Sanitizer().sanitize`` which requires the html-sanitizer package to be installed. + If you are not displaying user input, you can pass ``False`` to disable sanitization. + :param source: the source of the image; can be an URL, local file path, a base64 string or just an image size :param content: SVG content which should be overlaid; viewport has the same dimensions as the image :param size: size of the image (width, height) in pixels; only used if `source` is not set :param on_mouse: callback for mouse events (contains image coordinates `image_x` and `image_y` in pixels) :param events: list of JavaScript events to subscribe to (default: `['click']`) :param cross: whether to show crosshairs or a color string (default: `False`) + :param sanitize: a sanitize function to be applied to the content, ``False`` to deactivate sanitization (default ``None``: warns if content is provided, *added in version 3.4.0*) """ + self._sanitize = sanitize super().__init__(source=source, content=content) self._props['events'] = events[:] self._props['cross'] = cross @@ -107,27 +116,49 @@ def add_layer(self, *, content: str = '') -> InteractiveImageLayer: *Added in version 2.17.0* """ with self: - layer = InteractiveImageLayer(source=self.source, content=content, size=self._props['size']) \ - .classes('nicegui-interactive-image-layer') + layer = InteractiveImageLayer( + source=self.source, + content=content, + size=self._props['size'], + sanitize=self._sanitize, + ).classes('nicegui-interactive-image-layer') self.on('loaded', lambda e: layer.run_method('updateViewbox', e.args['width'], e.args['height'])) return layer + def _handle_content_change(self, content: str) -> None: + if content and self._sanitize is None: + helpers.warn_once('ui.interactive_image: content provided but no explicit sanitize function set; ' + 'to avoid XSS vulnerabilities, please provide a sanitize function or set sanitize=False') + return super()._handle_content_change(self._sanitize(content) if self._sanitize else content) + class InteractiveImageLayer(SourceElement, ContentElement, component='interactive_image.js'): CONTENT_PROP = 'content' PIL_CONVERT_FORMAT = 'PNG' - def __init__(self, *, source: str, content: str, size: tuple[float, float] | None) -> None: + def __init__(self, *, + source: str, + content: str, + size: tuple[float, float] | None, + sanitize: Callable[[str], str] | Literal[False] | None = None, + ) -> None: """Interactive Image Layer This element is created when adding a layer to an ``InteractiveImage``. *Added in version 2.17.0* """ + self._sanitize = sanitize super().__init__(source=source, content=content) self._props['size'] = size def _set_props(self, source: str | Path | PIL_Image) -> None: if optional_features.has('pillow') and isinstance(source, PIL_Image): source = pil_to_base64(source, self.PIL_CONVERT_FORMAT) super()._set_props(source) + + def _handle_content_change(self, content: str) -> None: + if self._sanitize is None: + helpers.warn_once('ui.interactive_image layer: content provided but no explicit sanitize function set; ' + 'to avoid XSS vulnerabilities, please provide a sanitize function or set sanitize=False') + return super()._handle_content_change(self._sanitize(content) if self._sanitize else content)
tests/test_interactive_image.py+4 −3 modified@@ -45,7 +45,7 @@ async def page(): def test_with_cross(screen: Screen, cross: bool): @ui.page('/') def page(): - ui.interactive_image(URL_PATH1, content='<circle cx="100" cy="100" r="15" />', cross=cross) + ui.interactive_image(URL_PATH1, content='<circle cx="100" cy="100" r="15" />', cross=cross, sanitize=False) screen.open('/') screen.find_by_tag('svg') @@ -110,8 +110,9 @@ def page(): def test_add_layer(screen: Screen): @ui.page('/') def page(): - ii = ui.interactive_image(URL_PATH1, content='<rect x="0" y="0" width="100" height="100" fill="red" />') - ii.add_layer(content='<circle cx="100" cy="100" r="15" />') + ii = ui.interactive_image( + URL_PATH1, content='<rect x="0" y="0" width="100" height="100" fill="red" />', sanitize=False) + ii.add_layer(content='<circle cx="100" cy="100" r="15" />', sanitize=False) screen.open('/') screen.find_by_tag('svg')
website/documentation/content/interactive_image_documentation.py+4 −4 modified@@ -13,7 +13,7 @@ def mouse_handler(e: events.MouseEventArguments): ui.notify(f'{e.type} at ({e.image_x:.1f}, {e.image_y:.1f})') src = 'https://picsum.photos/id/565/640/360' - ii = ui.interactive_image(src, on_mouse=mouse_handler, events=['mousedown', 'mouseup'], cross=True) + ii = ui.interactive_image(src, on_mouse=mouse_handler, events=['mousedown', 'mouseup'], cross=True, sanitize=False) @doc.demo('Adding layers', ''' @@ -37,7 +37,7 @@ def mouse_handler(e: events.MouseEventArguments): highlight.content = f'<circle cx="{e.image_x}" cy="{e.image_y}" r="28" fill="yellow" opacity="0.5" />' src = 'https://picsum.photos/id/674/640/360' - image = ui.interactive_image(src, on_mouse=mouse_handler, cross=True) + image = ui.interactive_image(src, on_mouse=mouse_handler, cross=True, sanitize=False) highlight = image.add_layer() @@ -69,7 +69,7 @@ def force_reload(): ''') def blank_canvas(): ui.interactive_image( - size=(800, 600), cross=True, + size=(800, 600), cross=True, sanitize=False, on_mouse=lambda e: e.sender.set_content(f''' <circle cx="{e.image_x}" cy="{e.image_y}" r="50" fill="orange" /> '''), @@ -124,7 +124,7 @@ def svg_content(): ui.interactive_image('https://picsum.photos/id/565/640/360', cross=True, content=''' <rect id="A" x="85" y="70" width="80" height="60" fill="none" stroke="red" pointer-events="all" cursor="pointer" /> <rect id="B" x="180" y="70" width="80" height="60" fill="none" stroke="red" pointer-events="all" cursor="pointer" /> - ''').on('svg:pointerdown', lambda e: ui.notify(f'SVG clicked: {e.args}')) + ''', sanitize=False).on('svg:pointerdown', lambda e: ui.notify(f'SVG clicked: {e.args}')) doc.reference(ui.interactive_image)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-2m4f-cg75-76w2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-66470ghsaADVISORY
- github.com/zauberzeug/nicegui/commit/58ad0b36e19922de16bbc79ea3ddd29851b1a3e3ghsax_refsource_MISCWEB
- github.com/zauberzeug/nicegui/security/advisories/GHSA-2m4f-cg75-76w2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.