Fix deep links on Windows (#13868)
* Fix deep links on Windows and keep an unusable one from breaking the app
`load_deep_link` built the state.json lookup with `Path`, so on Windows the
relative path reached `safe_join` as `deep_links\<id>\state.json`. `safe_join`
rejects the OS separator, so every deep link raised `HTTPException(403)` —
which `load_deep_link` did not catch, turning `GET /config?deep_link=...` into
an error response instead of a config. Build the path with forward slashes.
A missing or unreadable link had the same problem: `routes_safe_join` raises
`HTTPException(404)` for a path that does not exist, so the `deep_link_state =
"invalid"` branch was unreachable and the whole config request failed. Catch
`HTTPException` too, and fall back to the components the app would normally
load, so the frontend can report the bad link with a toast over a working app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Only accept a deep link that looks like a deep link
The previous fix built the lookup path as a POSIX string so that
`safe_join` would stop rejecting it on Windows, and relied on `safe_join`
to normalise away any traversal. It does not: `posixpath.normpath`
collapses `deep_links/../planted/state.json` down to
`planted/state.json`, which `safe_join` accepts, so `?deep_link=../x`
read any `state.json` sitting next to `deep_links/` -- i.e. anywhere in
the upload directory -- and returned its contents as the app's
components. The `../escaping` test passed only because that file did not
exist.
A deep link is the URL-safe base64 digest from `create_url_safe_hash`,
so validate it against that alphabet before building a path. That keeps
separators and `..` out of the lookup entirely rather than depending on
how they normalise, and fixes the Windows case as a side effect.
`safe_join` stays as a second line of defense.
Falling back to `config["components"]` on an unusable link handed out
the live `blocks.config` list. `/config` assigns it over its own copy,
and `update_root_in_config` rewrites file urls in place, so a single
`GET /config?deep_link=anything-invalid` baked that request's root url
into the config for every later response -- turning per-request Host
reflection into persistent, cross-user rewriting of every asset url.
Return `None` instead and let the caller serve the copy it already has.
Also require authentication to create a deep link, so an unauthenticated
request cannot snapshot a session's state to disk on an app with auth.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Confine a deep link to its own storage root
Anchor `safe_join` at `<upload dir>/deep_links` instead of at the upload
directory, so the base it refuses to escape is the one that actually
matters. With the upload directory as the base,
`deep_links/../<hash>/state.json` normalises to `<hash>/state.json`,
which escapes nothing and is accepted -- and `/gradio_api/upload` lets
anyone store a file named `state.json` and hands back the directory it
landed in. So the previous commit's token validation was the only thing
standing between an uploaded file and trusted component config, which is
executable: an `html` component's `js_on_load` is run through
`new Function`, so this was same-origin script execution on any public
app, not just a file read. Both controls now block it independently.
Also stop swallowing every `HTTPException`. Only 403/404 from
`routes_safe_join` mean the link does not name a readable file; anything
else is unexpected and should not quietly become an invalid link.
Test the real chain rather than a hand-placed file: upload a crafted
`state.json` through `/gradio_api/upload` and ask for it back as
`?deep_link=../<hash>`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Trim the comments and drop the hand-placed traversal test
Keep only the comments that explain something the code cannot: where the
token alphabet comes from, why the join is based at `deep_links` rather
than the upload directory, why the relative path is a POSIX string, and
why the components must not be aliased from the live config.
`test_deep_link_cannot_read_a_file_outside_its_own_directory` planted a
`state.json` by hand to stand in for an upload. The upload test covers
the same ground through the route an attacker would actually use, so
only its path aliases are worth keeping; they move to the fallback test,
which needs no file on disk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Abubakar Abid <islamrealm@gmail.com>