← 代码课堂

第 16 课:安全设计 —— 白名单、令牌、签名链接

难度:★★★(需要第 08、15 课基础)
教材:phone-remote/(手机遥控电脑,189 个测试)
预计时间:讲解 80 分钟 + 练习 50 分钟
学完你能:理解"最小权限"原则,会设计令牌认证、动作白名单、参数校验和签名链接


课前须知:安全 = 限制能做什么

phone-remote 让你用手机遥控电脑:截屏、开关机、启动游戏、跑脚本……
听起来很危险——万一同一个 WiFi 下有坏人,或者手机丢了/二维码被拍照了呢?

这个项目的答案不是"加密",而是限制:

你只能做我允许的那些事,参数也只能是允许的那些值。

本课就拆它的四道闸。这套思路是所有后端服务的通用安全底座,
也是面试高频话题("你的接口怎么防未授权访问?")。


第一步:跑起来(注意安全实验只在本机做)

cd phone-remote
py -3 -m venv .venv
.venv\Scripts\pip install -r requirements.txt
start.bat

启动后终端会打印局域网地址和配对二维码。手机连同一个 WiFi,扫码即可。
没手机就用浏览器访问 http://127.0.0.1:8000/#token=xxx(token 见终端)。


第二步:模块地图(四道闸)

phone-remote/app/
├── security.py    第 24-38 行  闸①「你是谁」:Bearer token(常量时间比较)
│                  第 70-86 行  闸②「你从哪来」:网段白名单中间件
│                  第 41-67 行  签名文件链接:给 <img> 用的有时效 URL
├── actions.py     第 66-75 行  动作的"登记表"结构(Action)
│                  第 537-554 行 闸③「你能做什么」:动作白名单 + 参数白名单
│                  第 42-63 行  闸④「参数合法吗」:正则白名单
├── intent.py      第 43 行     AI 也只能在白名单内选动作
├── config.py      第 59-98 行  局域网 IP 探测(排除虚拟网卡)
└── pair.py        第 16-24 行  配对二维码(token 放 URL fragment)

记住这个顺序:先证明身份 → 再限制来源 → 再限制动作 → 最后限制参数。
每一层都不依赖下一层,任意一层挡住就安全。


第三步:逐块精讲

闸① 令牌认证 —— 常量时间比较(security.py 第 24-38 行)

def verify_token(authorization: str | None = Header(default=None)) -> None:
    """校验 `Authorization: Bearer <token>`"""
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="missing or invalid authorization header")
    presented = authorization[len("Bearer ") :].strip()
    if not hmac.compare_digest(presented, settings.token):
        raise HTTPException(status_code=401, detail="invalid token")

require_token = Depends(verify_token)

两个细节:

  1. hmac.compare_digest 而不是 ==:普通的字符串比较"发现不同就立刻返回",

比较耗时和"前几位对不对"有关。攻击者可以通过测量响应时间逐位猜出令牌——
这叫时序侧信道(timing side channel)。
compare_digest 无论对不对都比完整个字符串,耗时恒定。

  1. Depends(verify_token):FastAPI 的依赖注入。任何接口只要声明

dependencies=[require_token](或参数里用 require_token),
框架就会先验令牌再进函数——不用每个接口手写校验。
(这就是第 08 课"中间件/依赖"思想的实战用法。)

面试可讲:为什么口令比较要用常量时间函数?—— 防时序攻击。
这句话能立刻区分"背过八股"和"真做过安全"的人。

闸② 网段白名单 —— 只服务局域网(security.py 第 70-86 行)

async def network_guard(request: Request, call_next):
    """网段白名单中间件:只保护 /api,页面本身可自由打开(页面不含任何数据)"""
    if request.url.path.startswith(API_PREFIX):
        client = request.client.host if request.client else ""
        try:
            addr = ipaddress.ip_address(client)
        except ValueError:
            return JSONResponse(status_code=403, content={"detail": "unable to resolve client address"})
        if not any(addr in network for network in settings.allowed_networks):
            return JSONResponse(status_code=403, content={"detail": "client network not allowed"})
    return await call_next(request)

真正敏感的是接口。这体现了"按敏感度分级保护"。

(支持 CIDR,比如 192.168.0.0/16)

换个配置就能改安全边界,代码不用动

闸③ 动作白名单 —— 你只能做登记过的事(actions.py 第 537-554 行)

def execute(action_id: str, params: dict | None = None) -> dict:
    """执行白名单动作。任何不在白名单里的请求都会在这里被挡下。"""
    params = params or {}
    action = ACTIONS.get(action_id)
    if action is None:
        raise KeyError(f"action not allowed: {action_id}")     # 不在清单 → 拒绝

    unknown = set(params) - set(action.params)
    if unknown:
        raise ValueError(f"unexpected params: {sorted(unknown)}")  # 多传了参数 → 拒绝

    started = time.perf_counter()
    result = action.run(params)
    return {"action_id": action.id, "result": result, "elapsed_ms": int((time.perf_counter() - started) * 1000)}

三条铁律(文件开头注释写的):

  1. 能做什么全部写死在代码里,调用方只能传"动作编号 + 有限参数"
  2. 绝不接收任意命令字符串
  3. 参数逐个校验,拿不准就拒绝(拒绝比放行安全)

ACTIONS 是一个大字典(第 252-457 行),每个动作是个 Action 数据类(第 66-75 行):

@dataclass(frozen=True)
class Action:
    id: str
    label: str          # 给用户看的名字
    hint: str           # 说明
    params: dict        # 这一步允许哪些参数(键就是白名单)
    run: Callable[[dict], dict]
    risk: str = "low"   # 风险等级(前端据此把高危分组默认收起)
    param_choices: dict = None   # 参数候选值(渲染成下拉框,省得手打)

对比"开放一个任意命令接口":
后者等于把电脑完全交出去;前者把攻击面收缩到几十个固定动作。
这就是安全领域常说的最小权限原则(Principle of Least Privilege)。

闸④ 参数校验 —— 正则白名单(actions.py 第 42-63、100-128 行)

# 进程名只允许这些字符,挡掉路径、引号、分号之类的东西
PROCESS_NAME_RE = re.compile(r"^[\w.\- ]{1,64}$")
# 刷日常只能选预设档位,不接受任意字符串
PRESET_RE = re.compile(r"^(daily|mirror|full|reward)$")
# 预设脚本白名单:编号 -> 参数列表(值是列表不是字符串,禁止拼命令行)
REGISTERED_SCRIPTS: dict[str, list[str]] = {
    # "backup": [r"C:\scripts\backup.bat"],
}
def _process_check(params: dict) -> dict:
    name = str(params.get("name", "")).strip()
    if not PROCESS_NAME_RE.match(name):
        raise ValueError(f"invalid process name: {name!r}")
    return {"name": name, "running": is_process_running(name)}

def _script_run(params: dict) -> dict:
    key = str(params.get("key", "")).strip()
    if key not in REGISTERED_SCRIPTS:
        raise ValueError(f"unknown script key: {key!r}(可用:{list(REGISTERED_SCRIPTS) or '无'})")
    argv = REGISTERED_SCRIPTS[key]
    completed = subprocess.run(
        argv, shell=False, timeout=SUBPROCESS_TIMEOUT,
        capture_output=True, text=True, errors="replace",
        cwd=str(settings.screenshot_dir.parent),
    )
    return {"key": key, "returncode": completed.returncode,
            "stdout": completed.stdout[-4000:], "stderr": completed.stderr[-2000:]}

三个"防注入"的关键点:

  1. 正则白名单:进程名只允许字母数字点横线下划线空格,长度 1-64。

..\..\windows\system32\cmd.exe、a; rm -rf 这类直接匹配失败。

  1. 脚本登记制:script.run 只能传"登记过的编号",且值是一个参数列表——

不是命令行字符串。参数列表天然免疫命令注入(没有 shell 来解释 |、;、&)。

  1. shell=False + timeout:不经过 shell 执行,超时强杀。

(面试常问:subprocess 为什么不要用 shell=True?—— 防止命令注入。)

注意 REGISTERED_SCRIPTS 默认是空的:
作者宁可"这个功能没启用",也不留一个默认宽松的口子。

签名文件链接 —— 给 <img> 用的临时通行证(security.py 第 41-67 行)

问题:截屏要显示给手机,<img src="/api/screenshot/xxx.png">——
但 <img> 发出的请求带不了 Authorization 头,走令牌校验必然 401。
把 token 放进 URL 查询参数?又会写进浏览器/服务器访问日志。怎么办?

def _file_signature(filename: str, expires: int) -> str:
    payload = f"{filename}:{expires}".encode("utf-8")
    return hmac.new(settings.token.encode("utf-8"), payload, hashlib.sha256).hexdigest()[:24]

def signed_file_url(filename: str, ttl_seconds: int = SIGNED_URL_TTL) -> str:
    expires = int(time.time()) + ttl_seconds
    return f"/api/screenshot/{filename}?exp={expires}&sig={_file_signature(filename, expires)}"

def verify_signed_file(filename: str, exp: int, sig: str) -> bool:
    if not exp or not sig:
        return False
    try:
        expires = int(exp)
    except (TypeError, ValueError):
        return False
    if expires < time.time():          # 过期了
        return False
    return hmac.compare_digest(_file_signature(filename, expires), sig)

思路是 HMAC 签名:

不出现 token

这就是云存储 "预签名 URL"(如 AWS S3 presigned URL)的同一套原理——
面试可以直接讲这个:怎么给静态资源做有时效的免 header 访问。

配对二维码的巧思:token 放 URL fragment(pair.py 第 1-24 行)

"""为什么 token 放在 # 后面而不是查询参数:
- URL fragment 不会随 HTTP 请求发给服务端,也就不会写进访问日志
- 前端取到后立刻用 replaceState 抹掉,浏览器历史里也不留
- 代价:这个二维码等同于 token 本身,截图别乱发
"""
def pair_url() -> str:
    host = settings.host
    if host in ("0.0.0.0", "127.0.0.1"):
        from app.config import lan_candidates
        host = lan_candidates()[0][0] if lan_candidates() else host
    return f"http://{host}:{settings.port}/#token={settings.token}"

所以 token 不会出现在服务端日志里

让手机扫一下就能用——体验和安全可以兼得

顺带看局域网 IP 探测(config.py 第 59-98 行)——很实用:

def lan_candidates() -> list[tuple[str, str]]:
    """列出真实网卡上的私有 IPv4,越靠前越可能是手机能连的那个
    排除虚拟网卡,因为 VMware / Radmin VPN 这类地址手机根本访问不到。"""
    ...
    score = 0 if any(k in low for k in ("wlan", "wi-fi", "wireless", "无线")) else 1
    if not str(ip).startswith("192.168."):
        score += 1
    if is_tailscale:
        score += 5          # Tailscale 排后面:在家走 WiFi 更快更稳
    found.append((score, str(ip), name))
    found.sort()
    return [(ip, name) for _, ip, name in found]

ipaddress.is_private 对 100.64.0.0/10 返回 False 的坑(第 50-55 行注释)

AI 也只能在笼子里(intent.py)

README 里提到的规则(第 43 行 AI_ALLOWED):
自然语言先走本地正则规则;没命中才问本地模型,
而模型只能返回白名单里的动作编号,script.run 永不向模型开放,
解析阶段不产生任何副作用(只解析,不执行)。

这是"AI + 系统权限"的正确姿势:模型再聪明,也只是一张"填表的手",
最终执行权永远在白名单手里。


第四步:对照开源,安全设计的通用原则

最小权限原则

危险做法本项目的做法
开放任意命令执行几十个固定动作 + 登记制脚本
token 放 URL 查询参数token 放 fragment / Bearer 头;文件用签名 URL
所有接口同一把锁按敏感度分级(页面开放、API 双闸)
字符串拼命令行参数列表 + shell=False
程序出错给详细堆栈认证失败只说"invalid token",不透露系统信息

对照:预签名 URL

AWS S3 / 阿里云 OSS 的"预签名 URL"、GitHub 的附件直链,都是同一套
"HMAC 签名 + 过期时间"思路。你这个 60 行的实现,是它们的教学版。

对照:隧道与暴露面

对照:认证方案

方案适用
静态 Bearer token(本项目)个人自用、单用户
账号密码 + Session多用户网站
OAuth / JWT第三方登录、多服务
双向证书 mTLS企业级服务间通信

动手练习(做完才算过关)

练习 1(热身):试试错误 token
用 curl 或浏览器(带 Authorization: Bearer wrong 的插件/脚本)请求 /api/actions,
确认返回 401。再用正确 token 请求,看返回的动作清单。

练习 2(必做):读懂一条正则
解释 PROCESS_NAME_RE = r"^[\w.\- ]{1,64}$" 为什么能挡住
..\..\Windows\System32\cmd.exe。如果去掉开头的 ^ 和结尾的 $ 会怎样?

练习 3(必做):加一个安全的动作
在 actions.py 里加一个动作 app.echo:接收参数 text(长度 ≤50),原样返回。
要求:写一个校验函数、登记到 ACTIONS、给出 params 声明。
然后在手机上调用它。体会"加功能 = 加白名单条目"的流程。

练习 4(必做):观察签名过期
把 SIGNED_URL_TTL 临时改成 5 秒,截一张图后等 6 秒再刷新图片,
观察是否被拒(403/401)。改回来。

练习 5(思考题):
为什么项目不提供"任意文件下载"和"任意命令执行"?
从"如果提供,攻击者能做什么"的角度写 3 行。

看答案

token 泄露 + 任意命令 = 电脑沦陷。

练习 6(选做,进阶):给签名 URL 绑定客户端 IP
把签名内容从 filename:exp 改成 filename:exp:client_ip,
校验时也带上请求 IP。这样即使链接泄露,换台机器也用不了。
(提示:改 _file_signature 的参数和 signed_file_url / verify_signed_file 的调用处;
想想 _screenshot 生成链接时怎么拿到 IP。)


本课小结

术语表

术语人话解释
最小权限原则只给完成工作必需的最小权限
认证 / 授权证明你是谁 / 决定你能做什么
时序侧信道通过响应时间差推断秘密信息
常量时间比较无论内容如何都花同样时间的比较
白名单只允许清单内的东西通过
命令注入把恶意命令混进输入让系统执行
shell=False不经过命令行解释器执行程序,防注入
HMAC用密钥+哈希做的消息签名
预签名 URL带签名和有效期的临时访问链接
URL fragment# 后面的部分,不会发给服务器
CIDR网段表示法,如 192.168.0.0/16
Tailscale基于 WireGuard 的虚拟局域网工具

下节预告

第 17 课:数据流水线:采集、去重、可解释打分与预算护栏。
教材是 paper-follower(定时追踪论文的机器人,172 个测试):
怎么从多个学术 API 抓数据、怎么判断"两篇其实是同一篇"、
怎么给论文打一个能被解释的分,以及怎么给 AI 花费装一个"钱包锁"。