CVE-2026-108258
Shiny for Python — Path traversal in bookmark restore
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:NSummary
Shiny for Python's bookmark-restore path accepts a client-supplied _state_id_ query value and joins it into the server-side bookmark directory without validating it. Parent-directory segments or an absolute path make the server open input.json and values.json from outside the bookmark store.
Technical analysis
The restore handler builds <cwd>/shiny_bookmarks/<id> from the _state_id_ query-string value with no check that the id is a single safe path segment, so .. segments or an absolute path escape the store. On a default application the restore is attempted whenever the client supplies a query string, including with bookmark_store="disable", so no opt-in configuration is required. Applications configured with bookmark_store="server" and using ui.input_file() are affected more seriously: the file-input restore handler copies files out of the restore directory, so the contents of an attacker-selected file in an attacker-selected directory can be read into the application.
The sink joins the id with no check. _local_dir(id) in shiny/bookmark/_bookmark_state.py returns Path(os.getcwd()) / "shiny_bookmarks" / id with no containment check, so an id of ../.. walks straight out of the bookmark store.
The id is request-controlled. RestoreContext._load_state_qs takes _state_id_ from the query string and passes it to the restore-dir function unchanged. On websocket init the session hands the browser's URL query string (.clientdata_url_search) straight to RestoreContext.from_query_string, so the attacker's _state_id_ reaches the sink verbatim. shiny/bookmark/_restore_state.py
What the restore reads. On restore the server opens input.json and values.json under the resolved directory and JSON-parses them into the restored state. A traversal id therefore reads those two filenames from a directory outside the store — a read-only effect (confidentiality), with no write and no code execution. CVSS 4.0 scores it VC:L, 6.9 Medium.
shiny_bookmarks_folder_name = "shiny_bookmarks"
def _local_dir(id: str) -> Path:
# Try to save/load from current working directory as we do not know
# where the app file is located
return Path(os.getcwd()) / shiny_bookmarks_folder_name / id
async def local_restore_dir(id: str) -> Path:
return _local_dir(id)Proof of concept
Renesoft confirmed this against the real package in an isolated environment, with no live systems. The attack path, then the recorded test output:
- The target runs with server-side bookmarking (
bookmark_store="server"). Saved states live under./shiny_bookmarks/<id>/relative to the process working directory, each holding aninput.jsonand an optionalvalues.json. - On connection the client sends its URL query string as
.clientdata_url_search. If it contains_state_id_,RestoreContext._load_state_qsloads that state from disk. - The id is used verbatim:
Path(os.getcwd()) / "shiny_bookmarks" / id. A request with_state_id_=../<elsewhere>resolves toos.getcwd()/<elsewhere>, outside the store. - If that directory holds an
input.json(and/orvalues.json), the server reads and JSON-parses them into the restored state — reading those files from a location the attacker chose. - The effect is confidentiality, limited to the two bookmark filenames reachable by traversal from the working directory.
# shiny 1.6.3 — VULNERABLE
[control] _state_id_ = 'legit-0001' (normal, in-bounds)
resolved dir : <sandbox>/shiny_bookmarks/legit-0001
restored input : {'txt': 'hello from a legit bookmark'}
[attack] _state_id_ = '../OUT_OF_BOUNDS_secret' (path traversal)
resolved dir : <sandbox>/OUT_OF_BOUNDS_secret
escaped store? : True
restored input : {'txt': 'SECRET-MARKER-9f2a: this file is OUTSIDE shiny_bookmarks'}
RESULT : TRAVERSAL SUCCEEDED - read input.json OUTSIDE the store
# shiny 1.6.4 — FIXED (same payload)
[attack] _state_id_ = '../OUT_OF_BOUNDS_secret'
Could not restore bookmarked state: Invalid bookmark id: '../OUT_OF_BOUNDS_secret'
active : False
resolved dir : None
escaped store? : False
RESULT : traversal did NOT leak out-of-bounds contentImpact
On a default application the result is an unauthenticated, server-side probe for the existence and JSON-validity of attacker-chosen paths, constrained to the two file names input.json and values.json. With server-side bookmark storage and a file input, an attacker-chosen file's contents can be read into the application.
Affected versions
- Affected
- 1.4.0 – 1.6.3
- Fixed
- 1.6.4
- CVSS
- 6.9 (4.0)
- Weakness
- CWE-22
- Ecosystem
- Python · PyPI
- Disclosed
- 2026-09-03
Remediation
Fixed in 1.6.4 (pip install --upgrade shiny). The fix validates bookmark ids against an allowlist of a single path segment matching [A-Za-z0-9_-]+, which cannot express a separator, a .. parent reference, or an absolute path, and gates restore on the bookmark_store setting at a single point. Bookmark ids generated by Shiny are unaffected by the restriction.
Timeline
- Published by the maintainer with the fix in 1.6.4; GHSA-47c3-hpmg-7j6p.
- CVE-2026-108258 record published at cve.org.