很多人第一次接触 Tauri 的文件系统 API,心里想的是:这不就是给前端开个口子让我随便读写文件吗?结果真把应用跑起来,才发现官方把权限卡得死死的。你写个 readTextFile 想读 /etc/hosts,它直接红字报错。这不是 Tauri 故意给你添堵,而是它默认你是一个不信任任何输入的开发者。换句话说,权限严格是好事,但如果你不理解它底层是怎么做校验的,很容易写出看起来能跑、实际上能被人绕过的命令。

这一篇我们就用 Rust 代码做演示,把 Tauri 的权限边界、路径校验的坑,以及怎么正确做防绕过,一次讲明白。所有示例都围绕 Tauri 的自定义命令展开,技术栈是 Rust。

一、官方 API 的权限边界

1.1 你以为的读写和实际的读写

在 Tauri 里,前端想要读文件,不是调一个方法就行。你需要先在后端命令里使用 Rust 的标准文件库,然后在配置文件里声明哪些路径是允许的。而且在运行时,Tauri 的 fs scope 还会偷偷把你的允许列表和真实请求做一次标准化比较。这个标准化不是简单的字符串拼接,它会拒绝任何带两个点的相对路径。比如你允许了 /home/user/data 目录,然后请求 /home/user/data/../secret.txt,官方 API 会直接拒绝。为什么?因为 Tauri 内部会使用类似 canonicalize 的逻辑把路径拆开,发现它实际指向 /home/user/secret.txt,不在允许列表内。

1.2 作用域到底限制了什么

作用域限制的是一个“真实路径”。你写的路径如果是个符号链接,Tauri 会追踪到链接的真实目的地再做判断。这也是很多人踩坑的地方:明明允许了某个目录,但目录里有链接指向外面,结果访问被拒绝。反过来,如果链接指向允许目录内部,就可能造成误放行。另外,Tauri 的作用域默认对读和写是分开的。你允许了读,不一定允许写。所以很多新手写保存文件功能时被权限卡住,其实是因为没有把写权限也加进去。这里说的配置,是 tauri.conf.json 里的 fs scope,你可以把它理解成一张白名单,但它的匹配规则比你想的复杂得多。它支持 glob 通配符,但不支持动态变量,更不会在运行时因为你换了环境变量就自动更新。

这么一搞,很多开发者就烦了:既然官方 API 这么严格,那我直接在后端用标准文件库自己读写不就行了?那安全责任就从 Tauri 转移到了你手上。这也是下一节要说的坑。

二、不设防的路径拼接有多危险

2.1 沙箱逃逸的常见姿势

最经典的逃逸方式,就是前端传进来一个文件名,后端直接拼接路径。看起来没什么问题,但如果用户传入一个带 ../ 的字符串,拼接就会把路径带出允许目录。也许你会想:不要紧,Tauri 官方 scope 会拦住它。但问题是,scope 管的是官方 API,你在自定义命令里用标准文件库,它可管不着。曾经有个真实案例,某个 Tauri 应用在自定义命令里拼了一个根目录加文件名,本来只打算让用户读取自己项目下的日志。结果测试人员传入了一个很长很长的相对路径,顺利读出了系统里的用户列表文件。这不是 Tauri 有漏洞,而是开发者绕过了它设置的安全网。

我们看一个危险版本:

// 危险:直接把前端传的路径拼进根目录
#[tauri::command]
fn read_file_v1(root: String, file_name: String) -> Result<String, String> {
    let base = Path::new(&root);
    let file_path = base.join(&file_name); // 攻击者可用 "../../etc/passwd"

    // 这里绕过了 Tauri 的 scope,因为是自己原生文件 IO
    let content = std::fs::read_to_string(file_path).map_err(|e| e.to_string())?;
    Ok(content)
}

这个命令在 Tauri 里注册后,前端只要把 file_name 设置为 ../../../../etc/passwd,就能读出系统里的用户信息。虽然现代系统密码哈希已经不放这里了,但用户名列表、用户 ID、shell 路径,这些信息会帮助攻击者做下一步渗透。所以这种拼接必须禁止。

2.2 被忽略的符号链接攻击

还有一种逃逸比路径遍历更隐蔽,叫符号链接攻击。攻击者如果能在允许目录里创建一个符号链接,指向 /etc,那你的程序跟着链接走,就出了圈。Tauri 官方 scope 对符号链接有处理,但你的自定义命令未必有。更糟的是,很多后端代码只检查路径的字符串前缀,比如判断它是不是以允许目录开头,然后直接打开文件。这种检查完全没有考虑符号链接。比如你允许的是 /home/user/data,攻击者在该目录下放了一个名为 link 的链接,指向 /etc。你检查 file_path.starts_with("/home/user/data"),它确实返回 true,然后你打开读取,实际上读的是 /etc/passwd。我们看一个危险版本:

// 危险:没有解析符号链接直接读取
#[tauri::command]
fn read_log(root: String, log_name: String) -> Result<String, String> {
    let base = Path::new(&root);
    let target = base.join(log_name); // log_name 可以是 "link_to_etc"

    let content = std::fs::read_to_string(target).map_err(|e| e.to_string())?;
    Ok(content)
}

这种问题在 Windows 上同样存在,只是载体从符号链接换成了目录联接或者快捷方式。攻击者一旦在其他环节获得了写文件的能力,就可以利用这个信任区里的假链接,把文件访问引到任意位置。所以在设计命令时,永远不要假设目录里没有链接。

三、正确打开方式:路径解析与校验

3.1 用 canonicalize 杀死亡路

面对这些坑,标准解法就是 canonicalize。它会把路径里的点、点点、符号链接全部归一化成绝对路径。然后你拿着这个真实路径,再跟允许根目录做一次前缀校验,就能做到滴水不漏。关键细节是,canonicalize 要求路径必须存在,所以用于读文件很合适;如果要写新文件,应该先解析父目录,再拼文件名。

下面是一个完整的安全读取命令:

// 安全:先解析真实路径,再校验是否落在允许目录内
use std::path::{Path, PathBuf};

const ALLOWED_ROOT: &str = "/home/user/data";

#[tauri::command]
fn safe_read(root: String, file_name: String) -> Result<String, String> {
    let base = Path::new(&root);

    // 用 canonicalize 解析真实路径(注意:目标必须存在,否则会报错)
    let candidate = base.join(&file_name);
    let real_path = std::fs::canonicalize(&candidate)
        .map_err(|e| format!("路径解析失败:{}", e))?;

    // 允许目录本身也要 canonicalize,获得规范化后的真实路径
    let allowed_root = std::fs::canonicalize(ALLOWED_ROOT)
        .map_err(|e| format!("允许目录解析失败:{}", e))?;

    // 检查真实路径是否以允许目录开头
    if !real_path.starts_with(&allowed_root) {
        return Err(format!("非法访问:{} 不在允许目录内", real_path.display()));
    }

    let content = std::fs::read_to_string(&real_path).map_err(|e| e.to_string())?;
    Ok(content)
}

这段代码已经能挡住大部分攻击。注意比较时用了 Path 的 starts_with 方法,它不是字符串比较,而是按路径组件比较。例如 /home/user/data2 不会错误地通过 /home/user/data 的检查。这是一个非常重要的细节,很多人直接用字符串前缀判断,结果放行了相似目录。

写文件场景要稍微复杂一点,因为目标文件可能不存在。你可以对父目录做 canonicalize,再校验父目录是否在允许范围内。看这个安全写入示例:

// 安全:写文件时先解析父目录,再拼文件名
#[tauri::command]
fn safe_write(root: String, file_name: String, content: String) -> Result<(), String> {
    let base = Path::new(&root);
    // 先解析根目录是否存在
    let parent_dir = base
        .canonicalize()
        .map_err(|e| format!("根目录解析失败:{}", e))?;

    let candidate = parent_dir.join(&file_name);

    // 对 candidate 的父目录做 canonicalize
    let parent = candidate.parent().ok_or("没有父目录")?;
    let real_parent = parent.canonicalize().map_err(|e| e.to_string())?;

    // 校验父目录必须在允许范围内
    let allowed_root = Path::new(ALLOWED_ROOT)
        .canonicalize()
        .map_err(|e| e.to_string())?;
    if !real_parent.starts_with(&allowed_root) {
        return Err("非法路径:父目录越界".into());
    }

    std::fs::write(&candidate, content).map_err(|e| e.to_string())
}

这个写法解决了一个常见问题:如果 file_name 里包含子目录,父目录可能本身就不存在。如果你想写 /home/user/data/new/log.txt,但 new 目录还不存在,canonicalize 会失败。所以实际生产代码里,你往往需要先判断目录是否存在,不存在就先用 create_dir_all 创建,然后再 canonicalize。为了演示路径校验,我这里没有展开那部分细节,但思路是一样的。

3.2 前端也要设置边界

后端做校验是必须的,但前端也不能裸奔。比如在浏览器端,你可以用文件选择框限制用户只能选择某个文件夹内的文件,然后在选择后做一个简单的名称过滤,防止用户把路径改乱。这样做的目的不是安全,而是体验,减少用户误操作。但要记住,前端过滤只是优化,真正的安全必须放在后端。攻击者完全可以绕开前端页面,直接通过开发者工具调用你的 tauri.invoke。所以后端校验永远不能省。

四、应用场景与优缺点

4.1 适合哪些场景

这种严格权限设计最适合对安全要求高的场景。比如一个本地笔记应用,只允许用户访问自己选择的文件夹,你可以在初始化时记录这个文件夹的 canonicalize 结果,之后所有文件操作都先过一遍校验。又比如一个 Markdown 编辑器,需要加载外部图片,你可以把图片目录做成一个白名单,防止用户无意识地把整个磁盘暴露给网页。再比如企业内部的文档管理工具,审计日志要求记录每次文件访问的完整真实路径,canonicalize 后的路径正好方便做审计。

4.2 现有方案的局限与改进

严格也有代价。首先,canonicalize 要求路径存在,这意味着很多创建新文件的场景你需要额外处理。其次,如果允许目录本身包含符号链接,canonicalize 会把它解析到真实路径,如果你的校验逻辑没跟上,可能误判。第三,Tauri 的静态 scope 无法在运行时动态调整,一旦用户切换工作目录,你就得在代码里重新计算允许根目录,这等于把 Tauri 的 scope 完全替换成了自己的权限中心。

改进方向包括:把允许根目录做成一个可动态增删的列表,每次新增时都做 canonicalize 并缓存;把路径校验封装成一个公共函数,避免每个命令各写一套;还可以引入 Rust 生态里的 path-clean 或 typed-path 这样的库,帮你处理平台差异和路径归一化。不过这些库也有学习成本,得根据团队情况选择。

五、注意事项与总结

5.1 注意事项

第一,不要偷懒在自定义命令里指望 allowlist。Tauri 的 scope 只对官方文件系统 API 生效,你自己写的标准文件库操作完全不受管。第二,路径比较别用字符串 starts_with,要使用 Path::starts_with,因为字符串比较会误判相似目录。第三,canonicalize 之后的路径仍然要小心,如果你的校验代码里先做了字符串拼接再比较,等于把归一化结果又污染了。第四,Windows 下路径大小写不敏感,建议统一用 lowercase 后再比较;Linux 下要小心根目录和波浪号,用户输入永远要假设是恶意的。第五,返回错误信息时不要暴露真实路径。攻击者可以利用路径解析失败来猜测目录结构。最好返回统一的“文件不存在”之类的提示。

还有一点值得提:如果你在 Tauri v2 里使用新的 ACL 系统,切记 ACL 只是进一步限制了插件和命令的调用权限,它不能替代你自己在后端做的路径校验。ACL 能做的是“允许谁调用什么命令”,而你的命令一旦被调用,内部如何处理路径仍然是你的事。所以 v1 和 v2 在这条原则上一模一样。

5.2 总结

Tauri 的文件系统 API 表面严格,实际上它把最核心的安全责任交到了你手上。官方 scope 解决了一部分手滑型错误,真正对抗恶意输入还得靠后端自己做好 canonicalize 和前缀校验。路径校验不是加一行 if 就完事,你要理解文件系统节点的真实含义,警惕符号链接,留意平台差异。只有把这些细节都补齐,你的 Tauri 应用才能在沙箱内侧安稳运行,在沙箱外也能挡住那些想越界的手。