fix(security): unblock app-dir downloads broken by transfer_file fs-scope guard (#4651)

#4639 added a strict `app.fs_scope().is_allowed()` check to download_file/
upload_file. On Android that returns false for the app's own storage, so every
download into the app dir (book covers, dictionaries, books, gloss packs, OPDS
books in the cache dir) failed with "permission denied: path not in filesystem
scope".

Root cause: download_file/upload_file are plain app commands using raw tokio::fs,
so Tauri does not scope file_path. The capability scope patterns that cover the
app's storage ($APPDATA/Readest/**, $APPCACHE/**, **/Readest/**/*) are
command-scoped and absent from the global fs_scope() FsExt exposes (it is
initialized FsScope::default() and only ever gains runtime dialog/persisted-scope
grants), so is_allowed() returns false for the app's own files.

Interim fix mirroring dir_scanner::read_dir: keep rejecting relative and `..`
paths, then accept the path if the fs scope allows it (persisted dialog grants
for custom/external roots) OR it lives inside the app's own storage — matched by
the `Readest` data folder or the app's bundle identifier (app.config().
identifier), which the Android sandbox (/data/user/0/<id>/…, cache dir included)
and the desktop identifier dirs always carry. The `..` rejection keeps the
GHSA-55vr-pvq5-6fmg hardening: foreign targets like ~/.ssh/id_rsa carry neither
segment and stay blocked.

Follow-up (tracked separately): replace the substring fallback with a
BaseDirectory + relative path resolved via app.path(), so targets are in-scope by
construction with no string markers.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-19 00:48:10 +08:00
committed by GitHub
parent 6c7c86f346
commit 446c2c72de
@@ -98,20 +98,34 @@ fn has_disallowed_components(file_path: &str) -> bool {
.any(|c| matches!(c, std::path::Component::ParentDir))
}
/// The app's own storage always carries either the `Readest` data folder or the
/// app's bundle identifier in its path — the Android sandbox
/// (`/data/user/0/<identifier>/…`, including the cache dir) and the desktop
/// identifier dirs (`…/<identifier>/…`). Those paths aren't in the global
/// `fs_scope()` (their capability patterns are command-scoped), so `is_allowed`
/// returns false for the app's own files. Accept these segments as a fallback,
/// the way `dir_scanner::read_dir` does. `..` is already rejected, so foreign
/// targets (e.g. `~/.ssh/id_rsa`) stay blocked.
fn is_within_app_storage(file_path: &str, app_identifier: &str) -> bool {
file_path.contains("Readest") || file_path.contains(app_identifier)
}
/// Validate a webview-supplied `file_path` before any `File::create`/`File::open`.
/// Without this, `download_file`/`upload_file` would write/read arbitrary local
/// paths (e.g. `~/.ssh/id_rsa`, autostart entries) from any JS running in the
/// privileged Tauri origin — see GHSA-55vr-pvq5-6fmg. We require an absolute,
/// traversal-free path that is inside the app's filesystem scope (the static
/// capability globs plus persisted dialog grants for custom/external roots).
/// traversal-free path that is either granted by the fs scope (persisted dialog
/// grants for custom/external roots) or lives inside the app's own storage.
fn ensure_path_allowed(app: &AppHandle, file_path: &str) -> Result<()> {
if has_disallowed_components(file_path) {
return Err(Error::Forbidden(file_path.to_string()));
}
if !app.fs_scope().is_allowed(std::path::Path::new(file_path)) {
return Err(Error::Forbidden(file_path.to_string()));
if app.fs_scope().is_allowed(std::path::Path::new(file_path))
|| is_within_app_storage(file_path, &app.config().identifier)
{
return Ok(());
}
Ok(())
Err(Error::Forbidden(file_path.to_string()))
}
impl Serialize for Error {
@@ -370,7 +384,30 @@ fn file_to_body(channel: Channel<ProgressPayload>, file: File, file_len: u64) ->
#[cfg(test)]
mod tests {
use super::has_disallowed_components;
use super::{has_disallowed_components, is_within_app_storage};
#[test]
fn app_storage_fallback_accepts_app_paths() {
let id = "com.bilingify.readest";
// Covers, dictionaries, books, gloss packs — under the `Readest` data dir.
assert!(is_within_app_storage(
"/data/user/0/com.bilingify.readest/Readest/Books/abc/cover.png",
id
));
assert!(is_within_app_storage(
"/data/user/0/com.bilingify.readest/Readest/Dictionaries/x/d.mdx",
id
));
// Cache-dir downloads (e.g. OPDS) carry no `Readest` segment but are still
// inside the app sandbox, matched via the bundle identifier.
assert!(is_within_app_storage(
"/data/user/0/com.bilingify.readest/cache/opds-book.epub",
id
));
// Foreign targets carry neither segment and stay blocked.
assert!(!is_within_app_storage("/home/user/.ssh/id_rsa", id));
assert!(!is_within_app_storage("/etc/passwd", id));
}
#[test]
fn rejects_relative_and_traversal_paths() {