《Python编程实战》9.3 输入校验、依赖供应链与漏洞加固

回到最前线的防御:用 Pydantic 与 pathlib 把不可信输入挡在门外并真跑路径遍历拦截,讲透密码哈希、危险函数禁用、依赖供应链的威胁模型、锁文件加 hash 校验、pip-audit 扫描与 SBOM 思路,最后落到最小权限加固清单。

本节目标:把「不信任任何输入」落成代码——用 Pydantic 与 pathlib 真跑通路径遍历拦截,理解依赖供应链的威胁模型,掌握锁文件 + hash、pip-audit 漏洞扫描与 SBOM 的做法,最后收束成一份可执行的加固清单。
适用版本:Python 3.12+(实测 3.14.6);pydantic 2.13.5、uv 0.12.23

9.3 输入校验、依赖供应链与漏洞加固

9.1 认证、9.2 授权之后,很多人以为安全做完了。但绝大多数真实漏洞不在「谁能进来」,而在进来之后你信了什么——信了用户传来的文件名、信了 requirements.txt 里那个包名、信了「默认配置应该没问题」。这一节回到最前线:输入与依赖。

9.3.1 输入校验的第一原则:白名单而非黑名单

新手校验输入常写「如果包含 .. 或 / 就拒绝」——这是黑名单,永远漏。攻击者用 ..%2f、....//、Unicode 同形字符、超长路径绕过去,你补一个他换一个。

正确姿势是白名单:只允许「明确合法」的字符,其余一律拒。白名单写死一个字符集,攻击者没有可乘之机。第二个原则是在边界处校验,且只校验一次:数据一进系统(API 入口、文件上传口)就校验成可信类型,之后内部传递的就不必反复校验——这本质上是「解析,不要验证」(Parse, don’t validate)。

9.3.2 Pydantic 字段校验 + 文件名白名单

把上传元数据建成模型,用 Field 约束长度/范围,用 field_validator 做字符白名单:

from pydantic import BaseModel, Field, field_validator

class UploadMeta(BaseModel):
    filename: str = Field(min_length=1, max_length=64)
    size: int = Field(ge=1, le=5_000_000)

    @field_validator("filename")
    @classmethod
    def safe_name(cls, v: str) -> str:
        if not all(c.isalnum() or c in "._-" for c in v):   # 白名单字符集
            raise ValueError("文件名含非法字符")
        if ".." in v or v.startswith("."):                  # 挡掉路径成分
            raise ValueError("文件名含路径成分")
        return v

真跑四种输入(实测):

== 字段校验 ==
  'report.csv'     -> OK
  '../etc/passwd'  -> 拒绝: Value error, 文件名含非法字符 [type=value_error, ...]
  'a/b.txt'        -> 拒绝: Value error, 文件名含非法字符 [type=value_error, ...]
  'ok.txt'         -> 拒绝: Input should be greater than or equal to 1 [type=greater_than_equal, ...]

注意 Pydantic 把错误一次性报全,且每条都带 type 与 input_value——/、.. 被字符白名单挡下,size=0 被 ge=1 挡下,互不干扰。

9.3.3 路径遍历防御:resolve() 后判断归属

文件名校验是第一层,但拼路径时仍要再兜一层。用户传相对路径读文件时,../../../etc/passwd 会跳出上传目录。防线是「先 resolve() 成绝对路径,再判断它是否还在基准目录内」:

from pathlib import Path

def safe_join(base: Path, user_path: str) -> Path:
    target = (base / user_path).resolve()     # 解析 .. 与符号链接
    base = base.resolve()
    if base not in target.parents and target != base:
        raise ValueError(f"越界路径: {user_path!r}")
    return target

真跑:

== 路径遍历 ==
  'ok.txt'               -> /private/tmp/python_book/scratch/09/uploads/ok.txt
  '../jwt_hs256.py'      -> 拒绝: 越界路径: '../jwt_hs256.py'
  '../../etc/hosts'      -> 拒绝: 越界路径: '../../etc/hosts'

关键在 resolve() 先于判断——它把 .. 和符号链接都展开成真实绝对路径,再比 parents 才可靠。用字符串 startswith 比较是错的(/data-evil 会以 /data 开头)。更稳的写法是用 os.path.commonpath 或直接 Path.relative_to() 抛异常来判。

9.3.4 密码哈希:永远不要存明文

只要系统存密码,第一铁律就是只存哈希、绝不存明文或可逆密文,且必须用慢哈希 + 每用户随机盐。Python 3.11+ 的 hashlib.scrypt 是标准库自带的合格方案(本机无 bcrypt/argon2,未安装;scrypt 已实测):

import hashlib, hmac, secrets

def hash_password(pw: str) -> str:
    salt = secrets.token_bytes(16)                       # 每用户独立随机盐
    dk = hashlib.scrypt(pw.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32)
    return f"scrypt$16384$8$1${salt.hex()}${dk.hex()}"   # 参数+盐+哈希一起存

def verify_password(pw: str, stored: str) -> bool:
    algo, n, r, p, salt_hex, hash_hex = stored.split("$")
    dk = hashlib.scrypt(pw.encode(), salt=bytes.fromhex(salt_hex),
                        n=int(n), r=int(r), p=int(p), dklen=32)
    return hmac.compare_digest(dk, bytes.fromhex(hash_hex))

真跑:

存储串: scrypt$16384$8$1$38c288a7c2d497c9a8ab69c73ffc6cac$4058f0a6b0104ba3039a71a19e97d2f0f4f9991c4ae98dda77436bb6e44b32dd
正确密码: True
错误密码: False
单次哈希耗时: 36.0 ms

三个设计点:慢(n=2**14 让单次约 36 ms,攻击者暴力破解成本被放大几万倍;也正因此登录接口要防被刷);加盐(同一密码不同用户哈希不同,挡住彩虹表与「撞库看谁密码一样」);参数随哈希一起存(将来调高 n 也不影响老用户校验)。校验用 hmac.compare_digest 防时序攻击。

9.3.5 危险函数:eval / shell / pickle

有一批「用错就致命」的函数,防御方式不是「小心用」,而是从代码库里禁掉(用 ruff 规则或 CI grep 拦):

import ast, subprocess

# ✅ 解析字面量用 ast.literal_eval,它只认字面量,拒绝一切代码
ast.literal_eval("[1, 2, 3]")                       # -> [1, 2, 3]
# ast.literal_eval("__import__('os').system('x')")  # -> ValueError,被拒

# ✅ 调外部命令用参数列表,绝不用 shell=True
r = subprocess.run(["echo", "hello; rm -rf /"], capture_output=True, text=True)

真跑:

literal_eval 列表: [1, 2, 3]
literal_eval 拒绝代码: malformed node or string on line 1: Call(func=Attribute(...), attr='system', ...)
subprocess 无 shell: hello; rm -rf /

注意最后一行:hello; rm -rf / 被当成一个普通字符串参数原样输出,没有执行——因为没走 shell,; 没有特殊含义。而 eval(user_input) 能执行任意 Python 代码,pickle.loads(untrusted) 能在反序列化时执行任意代码,两者对不可信输入一律禁用(pickle 只在完全可信、且做过签名校验的数据上用)。原则:能不用 shell 就不用,能用 literal_eval 就别用 eval。

9.3.6 依赖供应链的威胁模型

你的代码没漏洞,不代表系统没漏洞——你依赖的 84 个包(本机实测 importlib.metadata 统计)里任何一个都可能出事。供应链攻击有几类典型:

威胁手法防御
抢注/仿冒包用近似名(reqeusts)冒充锁文件固定来源 + hash
投毒版本合法包被入侵,发恶意版本锁版本 + hash 校验 + 及时扫描
传递依赖你没直接依赖的深层包出事SBOM 摸清全量依赖树
构建脚本执行setup.py 安装时执行任意代码隔离环境安装、审核新依赖

核心认知:依赖是一种「信任的延伸」——你信任 requests,就等于信任它依赖的每一层。所以要能回答「我到底依赖了哪些包、什么版本、有没有已知漏洞」。

9.3.7 锁文件 + hash 校验

requirements.txt 只写版本号不够——PyPI 上同名版本被替换(理论上可被供应链攻击)你也发现不了。锁文件要同时固定「版本 + 内容哈希」。用 uv 真跑一遍(uv export 默认带 hash):

uv export --format requirements-txt

真实输出(本机实测):

# This file was autogenerated by uv via the following command:
#    uv export --format requirements-txt
tenacity==9.2.1 \
    --hash=sha256:9e56f17539296baab7beabb08b92f6ee3d7be92d8be72d763360677c2ad6580e \
    --hash=sha256:a606b5c808d0cded4a359d5b9932d867ff2a6a6b64d37350260fd01bbdf83839
    # via demo

每行 --hash 是该 wheel 的 SHA256(同一版本可能有多平台的多个 wheel,所以列多个 hash)。安装时用 --require-hashes 强制校验,内容对不上就装不了:

pip install --require-hashes -r requirements.lock

--require-hashes 要求每个包都带 hash 且都锁死版本,任何「偷偷改了包内容」或「引入未声明的传递依赖」都会被拦下。这是 CI 里最值得加的一条——它把「安装依赖」从「盲信」变成「校验」。

9.3.8 漏洞扫描:pip-audit

光锁住版本还不够,锁的版本本身可能有已知 CVE。pip-audit 会对照漏洞库(OSV / PyPI Advisory)检查你的依赖。本机未安装(pip show pip-audit 报 Package(s) not found),所以下面是命令与方法,未实测:

# 扫描当前环境
pip-audit
# 扫描锁文件,可接入 CI 并在发现漏洞时非零退出
pip-audit -r requirements.lock --strict
# 只报告、不自动修,输出 JSON 便于流水线消费
pip-audit -f json

pip-audit 最新版本为 2.10.1(PyPI 实查)。接入方式:CI 里加一个步骤,pip-audit --strict 发现任何漏洞就让流水线红,把「依赖有没有洞」变成每次提交都自动回答的问题。同类工具还有 safety、GitHub 的 Dependabot,原理一致。

9.3.9 SBOM:先能列出全量依赖

SBOM(Software Bill of Materials,软件物料清单) 就是「这个软件由哪些组件、什么版本构成」的清单——出了 Log4Shell 那种事,你得能在几分钟内回答「我有没有用到」。Python 里最小可用的做法是用标准库 importlib.metadata 枚举当前环境:

import importlib.metadata as md

pkgs = sorted({(d.metadata["Name"], d.version) for d in md.distributions()})
print("已安装包总数:", len(pkgs))
for name, ver in pkgs[:8]:
    print(f"  {name}=={ver}")

真跑(本机虚拟环境):

已安装包总数: 84
  aiosqlite==0.22.1
  alembic==1.20.0
  annotated-doc==0.0.5
  annotated-types==0.8.0
  anyio==4.15.1
  ast_serialize==0.12.1
  beautifulsoup4==4.15.0
  build==1.6.1

这就是一份「运行环境的 SBOM 雏形」。生产做法是用标准格式输出(CycloneDX 或 SPDX,本机未安装对应工具,未实测),uv、pip、CI 插件都能生成。SBOM 的价值在事发时:不用手忙脚乱地翻依赖,一条命令就能定位受影响的部署。

9.3.10 最小权限与运行加固

最后一条防线在运行环境,原则是「即使被攻破,损失也最小」:

  • 容器里别用 root 跑:Dockerfile 里 USER 切到非特权用户(第 15 章细讲)。
  • 只读文件系统:read_only=True + 显式挂载可写目录,被写入 webshell 也没处落。
  • 密钥不进镜像、不进代码:走环境变量或密钥管理服务;SECRET_KEY 绝不硬编码(9.1 的示例串是演示用的)。
  • 最小依赖面:能不加的依赖就不加,少一个包少一个攻击面。
  • 关掉调试信息:生产禁 debug=True,异常不回传堆栈——堆栈会泄露路径、库版本,正是攻击者的地图。
  • 日志脱敏:令牌、密码、身份证号绝不进日志;结构化日志尤其要过滤敏感字段。

延伸阅读

小结

  • 输入校验用白名单不用黑名单,并在边界处一次性校验成可信类型(Parse, don’t validate)。
  • 路径遍历防御的关键是 resolve() 后再判断归属,别用字符串 startswith;文件名先过字符白名单。
  • 依赖是「信任的延伸」:锁文件必须同时锁版本和 hash,CI 用 --require-hashes 强制校验。
  • pip-audit(本机未装,最新 2.10.1) 对照漏洞库扫依赖,接入 CI 让「依赖有没有洞」每次提交都自动回答。
  • SBOM 是「有哪些组件」的清单,标准库 importlib.metadata 就能列全量依赖;生产用 CycloneDX/SPDX 格式。
  • 运行加固靠最小权限:非 root、只读文件系统、密钥外置、关调试、日志脱敏。

第 9 章到这里收尾:9.1 确定身份、9.2 划清权限、9.3 守住输入与依赖。安全不是加一个中间件就完事,而是这三层各自守住边界。下一章我们转向实时通信——WebSocket 与消息协议,把「请求-响应」的单向模型扩展到双向长连接。

阅读导航:上一节:权限模型与多租户隔离 · 下一节:WebSocket 与消息协议 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时