Python 输出路径预检:用 pathlib 拒绝目录越界与误覆盖
任务明明设置了 exports/ 输出目录,为什么报告却写到了旁边的目录?root / name 只是拼接路径,不会替你限制写入范围;绝对路径、.. 和指向外部的符号链接,都需要单独处理。
本文面向初级到中级读者,用 Python 标准库给本地导出脚本增加路径预检,并用测试确认越界请求不会创建文件。前提是输出目录树由你控制,检查和写入期间没人替换目录或链接。它不是面向恶意并发修改的安全沙箱,也不是文件上传处理器。
1. 方法:检查真实路径,不比较字符串前缀
先看一个不读写文件的反例:
python3 - <<'PYCODE'
from pathlib import Path
root = Path("/tmp/exports")
other = Path("/tmp/exports-old/report.txt")
print(str(other).startswith(str(root)))
try:
other.relative_to(root)
except ValueError:
print("Not a child directory")
PYCODE
输出是 True 和 Not a child directory。exports-old 与 exports 有相同字符串前缀,但不是子目录。
本文的预检策略是:
- 输出根目录必须已存在,由调用者明确指定;先解析它的真实路径。
- 文件名必须是相对路径,拒绝任何
..分量,即使折叠后仍在根目录内也拒绝。 - 解析目标已有的符号链接,再检查目标是否在根目录下;允许解析后仍在内部的链接。
- 父目录必须提前创建,只使用独占创建模式,不覆盖已有文件。
Python pathlib 文档说明 resolve() 会解析符号链接;strict=False 允许尚不存在的末端。随后使用 relative_to()检查路径分量的包含关系,不用 startswith()。
2. 准备:创建隔离的演示目录
使用已有的 Python 3.9+ 和 Linux shell,不安装第三方包。以下命令在同一个终端执行;重复实验请换一个新目录名:
python3 --version
mkdir output-path-demo
cd output-path-demo
mkdir -p exports/daily outside
ln -s ../outside exports/escape
exports/escape 故意指向同级的 outside/,用于复现链接越界。不要拿系统目录或生产导出目录做实验。若已有链接目标丢失,先参考用 Python 检查断开的符号链接区分链接自身和目标。
3. 实现:预检路径,再独占创建
将下面完整代码保存为当前目录的 safe_output.py:
import argparse
import json
from pathlib import Path
import sys
def output_path(root_name, name):
root = Path(root_name).resolve(strict=True)
if not root.is_dir():
raise ValueError("output root must be a directory")
relative = Path(name)
if not name or relative == Path("."):
raise ValueError("a file name is required")
if relative.is_absolute() or ".." in relative.parts:
raise ValueError("use a relative path without '..'")
target = (root / relative).resolve(strict=False)
try:
local = target.relative_to(root)
except ValueError:
raise ValueError("resolved path leaves output root") from None
if local == Path("."):
raise ValueError("target must not be the output root")
if not target.parent.is_dir():
raise ValueError("output parent directory must already exist")
return root, target
def main():
parser = argparse.ArgumentParser(
description="Create a demo file inside a trusted Linux output tree."
)
parser.add_argument("root", help="existing output directory")
parser.add_argument("name", help="relative output filename")
args = parser.parse_args()
try:
root, target = output_path(args.root, args.name)
with target.open("x", encoding="utf-8", newline="\n") as stream:
stream.write("demo output\n")
except (OSError, ValueError, RuntimeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps({"status": "created", "path": target.relative_to(root).as_posix()}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
示例只写入固定内容 demo output,便于核对行为;实际脚本可把写入部分换成自己的导出逻辑,但要保留路径检查和错误处理。文件名按 Linux 本地路径解释,不做 URL 解码,也不把反斜杠当 Windows 分隔符;不要直接套用到跨平台归档解包或网络上传。
open() 的官方说明规定 x 在文件已存在时失败,避免用 w 截断旧输出。程序成功返回 0,路径或写入错误返回 1,缺少命令参数由 argparse 返回 2。写入中途出错仍可能留下不完整的新文件;需要原子交付时,再接入临时文件与原子替换方案,不能把本例当成事务写入器。
4. 使用:验证正常写入和越界拒绝
- 查看帮助并写入含空格的文件名:
python3 safe_output.py --help
python3 safe_output.py exports 'daily/report 01.txt'
cat 'exports/daily/report 01.txt'
最后两条命令输出:
{"status": "created", "path": "daily/report 01.txt"}
demo output
再次运行相同写入命令应返回 1,因为文件已经存在。不要为了让测试通过而改用 w。
- 验证三种拒绝分支:
for name in '../outside/no.txt' '/tmp/no.txt' 'escape/no.txt'; do
if python3 safe_output.py exports "$name"; then
printf 'Unexpected success: %s\n' "$name"
else
printf 'Rejected as expected: %s\n' "$name"
fi
done
三次都应在 stderr 输出 ERROR:,随后打印 Rejected as expected;outside/no.txt 不应被创建。这里用 if 处理预期失败,避免启用 set -e 的 shell 提前结束。
- 自动化调用时,把输出根目录固定为绝对路径:
python3 safe_output.py "$PWD/exports" 'daily/from-job.txt'
这条命令仍在演示目录执行,只用于展示绝对根目录参数。真实任务从其他目录启动时也要传同一个绝对根目录,并用脚本绝对路径;参见Linux 定时任务的解释器与工作目录排查。本教程不修改调度器。
5. 自动测试:确认外部文件和旧输出不变
将下面保存为与 safe_output.py 同级的 verify_output.py。测试在自己的临时目录中创建夹具并自动清理,子进程等待上限为 10 秒:
import json
from pathlib import Path
import subprocess
import sys
import tempfile
script = Path(__file__).with_name("safe_output.py").resolve()
def run(args, cwd):
return subprocess.run(
[sys.executable, str(script), *args], cwd=cwd,
capture_output=True, text=True, timeout=10, check=False,
)
with tempfile.TemporaryDirectory(prefix="output-path-test-") as tmp:
base = Path(tmp).resolve()
root = base / "exports"
(root / "daily").mkdir(parents=True)
outside = base / "exports-old"
outside.mkdir()
sentinel = outside / "keep.txt"
sentinel.write_bytes(b"unchanged\n")
(root / "escape").symlink_to(outside, target_is_directory=True)
(root / "dangling").symlink_to(outside / "new.txt")
(root / "alias").symlink_to(root / "daily", target_is_directory=True)
(root / "loop").symlink_to("loop")
def call(name):
return run([str(root), name], base)
good = call("daily/report 01.txt")
assert (good.returncode, good.stderr) == (0, ""), good
assert json.loads(good.stdout) == {"status": "created", "path": "daily/report 01.txt"}
saved = root / "daily/report 01.txt"
assert saved.read_bytes() == b"demo output\n"
alias = call("alias/second.txt")
assert alias.returncode == 0 and not alias.stderr, alias
assert json.loads(alias.stdout)["path"] == "daily/second.txt"
bad_names = [
"", ".", "daily/..", "../exports-old/new.txt", str(sentinel),
"escape/keep.txt", "escape/new.txt", "dangling", "loop/new.txt",
"missing/new.txt", "daily/report 01.txt", "daily",
]
for name in bad_names:
result = call(name)
assert result.returncode == 1 and not result.stdout, (name, result)
assert result.stderr.startswith("ERROR:"), (name, result)
for bad_root in [base / "absent", sentinel]:
result = run([str(bad_root), "new.txt"], base)
assert result.returncode == 1 and result.stderr.startswith("ERROR:"), result
help_result = run(["--help"], base)
assert help_result.returncode == 0 and "trusted Linux" in help_result.stdout
missing = run([], base)
assert missing.returncode == 2 and "required" in missing.stderr
assert sentinel.read_bytes() == b"unchanged\n"
assert not (outside / "new.txt").exists()
assert saved.read_bytes() == b"demo output\n"
assert sorted(p.name for p in (root / "daily").iterdir()) == ["report 01.txt", "second.txt"]
print("OK: contained writes, aliases, traversal, symlinks, loops, existing files, roots, CLI")
python3 verify_output.py
通过时输出:
OK: contained writes, aliases, traversal, symlinks, loops, existing files, roots, CLI
测试覆盖正常写入、内部链接、绝对路径、..、外部链接、悬空链接、循环链接、父目录缺失、拒绝覆盖、无效根目录、帮助和缺少参数。它还断言外部哨兵文件与旧输出字节不变;没有模拟检查与使用之间的竞态或磁盘故障。
6. 常见错误与边界
| 现象 | 具体处理 |
|---|---|
use a relative path without '..' |
传 daily/report.txt,不要传根目录前缀、绝对路径或上级跳转。 |
resolved path leaves output root |
用 readlink 检查链接;修正导出配置,别删除包含关系检查。 |
output parent directory must already exist |
在可信根目录内显式执行 mkdir -p exports/daily,不让程序按未检查的名字建目录。 |
File exists |
检查旧结果,换新文件名;是否替换应由独立且明确的业务规则决定。 |
Permission denied |
用实际运行账号核对目录权限,不用 chmod 777 或管理员运行掩盖问题。 |
| 链接循环或根目录不存在 | 修正链接或先创建可信根目录;错误信息可能随 Python 版本变化。 |
resolve() 到 open() 之间仍有时间窗口,其他进程可以替换目录。多用户可写目录、恶意输入和文件上传需要不同设计,例如基于目录文件描述符逐级遍历并控制链接跟随;Python os 文件描述符参数说明提供底层接口,但给最后一级加一个标志并不能自动保护所有祖先目录。这里不声称解决这个问题。
7. 小结
输出根目录不是自动生效的边界:先拒绝绝对路径和 ..,再解析链接并检查路径包含关系。对自己控制且不被并发替换的目录树,这能发现常见的导出路径配置错误;配合 x 模式和外部文件不变断言,可减少误写与覆盖。面对不可信并发修改时,应改用专门的隔离和文件描述符方案,而不是继续堆叠字符串检查。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-output-path-containment.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。