VYPR
Moderate severityNVD Advisory· Published Dec 9, 2025· Updated Dec 9, 2025

NiceGUI Stored/Reflected XSS in ui.interactive_image via unsanitized SVG content

CVE-2025-66470

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.

PackageAffected versionsPatched versions
niceguiPyPI
< 3.4.03.4.0

Affected products

1

Patches

1
58ad0b36e199

Merge commit from fork

https://github.com/zauberzeug/niceguiEvan ChanDec 8, 2025via ghsa
4 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

News mentions

0

No linked articles in our index yet.