本节目标:把「不信任任何输入」落成代码——用 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,异常不回传堆栈——堆栈会泄露路径、库版本,正是攻击者的地图。 - 日志脱敏:令牌、密码、身份证号绝不进日志;结构化日志尤其要过滤敏感字段。
延伸阅读
- Python 安全编程:输入验证、加密与常见漏洞防护 —— SQL 注入、反序列化、命令注入的完整防御清单
- 依赖解析与锁文件 —— 锁文件的生成与复现细节
- 多阶段 Dockerfile 与镜像瘦身 —— 非 root 运行与只读文件系统落地
小结
- 输入校验用白名单不用黑名单,并在边界处一次性校验成可信类型(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 与消息协议 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。