春江暮客

春江暮客的个人学习分享网站

Python 输出路径预检:用 pathlib 拒绝目录越界与误覆盖

2026-11-02 技术
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 有相同字符串前缀,但不是子目录。

本文的预检策略是:

  1. 输出根目录必须已存在,由调用者明确指定;先解析它的真实路径。
  2. 文件名必须是相对路径,拒绝任何 .. 分量,即使折叠后仍在根目录内也拒绝。
  3. 解析目标已有的符号链接,再检查目标是否在根目录下;允许解析后仍在内部的链接。
  4. 父目录必须提前创建,只使用独占创建模式,不覆盖已有文件。

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. 使用:验证正常写入和越界拒绝

  1. 查看帮助并写入含空格的文件名:
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。

  1. 验证三种拒绝分支:
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 提前结束。

  1. 自动化调用时,把输出根目录固定为绝对路径:
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 模式和外部文件不变断言,可减少误写与覆盖。面对不可信并发修改时,应改用专门的隔离和文件描述符方案,而不是继续堆叠字符串检查。

标签

1024 12306 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management filenames files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics multiprocessing mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv venv vhhbert vite web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它