难度:★★★(需要第 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)
两个细节:
hmac.compare_digest 而不是 ==:普通的字符串比较"发现不同就立刻返回",比较耗时和"前几位对不对"有关。攻击者可以通过测量响应时间逐位猜出令牌——
这叫时序侧信道(timing side channel)。compare_digest 无论对不对都比完整个字符串,耗时恒定。
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)
/api/*:页面(HTML/CSS/JS)可以随便打开,因为页面里没有任何数据——真正敏感的是接口。这体现了"按敏感度分级保护"。
ipaddress.ip_address(client) 把 IP 字符串转成对象,用 addr in network 判断是否在网段内(支持 CIDR,比如 192.168.0.0/16)
100.64.0.0/10 加进 allowed_networks 即可——换个配置就能改安全边界,代码不用动
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)}
三条铁律(文件开头注释写的):
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:]}
三个"防注入"的关键点:
..\..\windows\system32\cmd.exe、a; rm -rf 这类直接匹配失败。
script.run 只能传"登记过的编号",且值是一个参数列表——不是命令行字符串。参数列表天然免疫命令注入(没有 shell 来解释 |、;、&)。
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
TTL = 600 秒:够手机把图加载完,泄露也只影响这一张图、这点时间这就是云存储 "预签名 URL"(如 AWS S3 presigned URL)的同一套原理——
面试可以直接讲这个:怎么给静态资源做有时效的免 header 访问。
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}"
#fragment 部分浏览器不会发给服务器(只用在前端 JS 里读),所以 token 不会出现在服务端日志里
history.replaceState 抹掉,连浏览器历史都不留pair.py 还能把 URL 画成终端 ASCII 二维码和 PNG(第 36-75 行),让手机扫一下就能用——体验和安全可以兼得
顺带看局域网 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 行注释)
192.168.* 优先intent.py)README 里提到的规则(第 43 行 AI_ALLOWED):
自然语言先走本地正则规则;没命中才问本地模型,
而模型只能返回白名单里的动作编号,script.run 永不向模型开放,
解析阶段不产生任何副作用(只解析,不执行)。
这是"AI + 系统权限"的正确姿势:模型再聪明,也只是一张"填表的手",
最终执行权永远在白名单手里。
| 危险做法 | 本项目的做法 |
|---|---|
| 开放任意命令执行 | 几十个固定动作 + 登记制脚本 |
| token 放 URL 查询参数 | token 放 fragment / Bearer 头;文件用签名 URL |
| 所有接口同一把锁 | 按敏感度分级(页面开放、API 双闸) |
| 字符串拼命令行 | 参数列表 + shell=False |
| 程序出错给详细堆栈 | 认证失败只说"invalid token",不透露系统信息 |
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。)
hmac.compare_digest 防时序侧信道;FastAPI Depends 统一前置校验shell=False + 超时<img> 带不了 header → 用 HMAC 签名 URL(预签名 URL 的原理),带过期时间| 术语 | 人话解释 |
|---|---|
| 最小权限原则 | 只给完成工作必需的最小权限 |
| 认证 / 授权 | 证明你是谁 / 决定你能做什么 |
| 时序侧信道 | 通过响应时间差推断秘密信息 |
| 常量时间比较 | 无论内容如何都花同样时间的比较 |
| 白名单 | 只允许清单内的东西通过 |
| 命令注入 | 把恶意命令混进输入让系统执行 |
shell=False | 不经过命令行解释器执行程序,防注入 |
| HMAC | 用密钥+哈希做的消息签名 |
| 预签名 URL | 带签名和有效期的临时访问链接 |
| URL fragment | # 后面的部分,不会发给服务器 |
| CIDR | 网段表示法,如 192.168.0.0/16 |
| Tailscale | 基于 WireGuard 的虚拟局域网工具 |
第 17 课:数据流水线:采集、去重、可解释打分与预算护栏。
教材是 paper-follower(定时追踪论文的机器人,172 个测试):
怎么从多个学术 API 抓数据、怎么判断"两篇其实是同一篇"、
怎么给论文打一个能被解释的分,以及怎么给 AI 花费装一个"钱包锁"。