17.1 输入校验与注入防护
前面几章一直在教「怎么把功能做对」,从这一章开始换一个问法:「如果输入是恶意的,这套代码会怎样?」TaskHub 是多租户系统,第 5 章已经用租户 ID 强制注入查询做隔离——但如果某个查询是把租户 ID 拼进 SQL 字符串的,那个隔离就形同虚设。
本节把 TaskHub 推进到「默认所有输入都不可信」:用真实 Postgres 容器跑通 SQL 注入的越权复现与参数化防御的对照,用
html/template演示输出转义,并给出白名单校验、命令注入与路径穿越的防护清单。
17.1.1 信任边界:输入从哪来,信任到哪止
安全设计的第一步是画清信任边界:边界之外的一切数据都不可信。对 TaskHub 而言,边界之外包括:
| 来源 | 例子 | 为什么不可信 |
|---|---|---|
| HTTP 请求 | URL 参数、body、header、cookie | 完全由客户端构造 |
| 消息队列 | 上游服务投递的消息 | 可能被伪造或格式漂移 |
| 数据库 | 早期脏数据、被注入写入的值 | 历史遗留 |
| 第三方 API | OAuth 回调、webhook | 可能被中间人篡改 |
| 文件名 / 路径 | 上传文件的原始文件名 | 可含 ../ 与特殊字符 |
「校验」不是在某一个 handler 里做一次,而是在每个信任边界上做。从 HTTP 进来要校验,从队列消费要校验,从数据库读出来再当命令用也要校验。Go 的类型系统能帮一部分忙(把 string 收进 type TenantID string),但真正的防线是不把不可信数据当代码/语法用。
17.1.2 复现 SQL 注入:一次越权读取
我用真实 Postgres 容器跑了一个对照实验。先起库、建表、插两行属于不同租户的任务:
docker run -d --name gb16-pg \
-e POSTGRES_PASSWORD=secret -e POSTGRES_USER=taskhub -e POSTGRES_DB=taskhub \
-p 127.0.0.1:55471:5432 postgres:17-alpine
建表与数据(在 Go 里用 db.ExecContext 执行):
CREATE TABLE tasks (id int primary key, tenant_id text, title text);
INSERT INTO tasks VALUES (1,'t1','公开任务'),(2,'t2','t2 私密任务');
Go 侧用 pgx 的 database/sql 适配器连库(sslmode=disable 仅因为这是本机容器):
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
)
const dsn = "postgres://taskhub:secret@127.0.0.1:55471/taskhub?sslmode=disable"
db, _ := sql.Open("pgx", dsn)
现在假设有个查询接口接收 tenant_id,但用字符串拼接构造 SQL:
evil := "t1' OR '1'='1" // 攻击载荷
// 危险:把用户输入直接拼进 SQL
naive := "SELECT id, tenant_id, title FROM tasks WHERE tenant_id = '" + evil + "'"
rows, err := db.QueryContext(ctx, naive)
本机真实输出:
拼接 SQL : SELECT id, tenant_id, title FROM tasks WHERE tenant_id = 't1' OR '1'='1'
拼接查询命中 2 行 <-- 越权读到别的租户
结果触目惊心:本该只返回 t1 的 1 行,却因为 OR '1'='1' 恒真,返回了全部 2 行——t2 的私密任务被越权读走。第 5 章辛苦做的租户隔离,被一行拼接彻底绕开。这就是为什么「隔离靠应用层拼 WHERE」是危险的:只要有一处忘了转义,整个隔离模型就破了。
17.1.3 参数化查询:让输入只当数据
同样的输入,改用参数化(占位符)写法:
param := "SELECT id, tenant_id, title FROM tasks WHERE tenant_id = $1"
rows, err := db.QueryContext(ctx, param, evil)
本机真实输出:
参数化 SQL: SELECT id, tenant_id, title FROM tasks WHERE tenant_id = $1 参数: t1' OR '1'='1
参数化查询命中 0 行 <-- 注入被挡下
命中 0 行。原因是参数化查询把 SQL 的结构与数据彻底分开:数据库先编译好 SELECT ... WHERE tenant_id = $1 的执行计划,$1 的位置永远只被当成一个「字符串值」,无论它包含多少引号、OR、分号。攻击载荷在这里退化成一句「没有任何租户叫 t1' OR '1'='1」,于是查不到东西——防御是数据库协议层面保证的,不依赖你写得多小心。
这就是安全工程里最有用的一条原则:不要靠「正确转义」来防注入,要靠「不让数据和语法相遇」。转义永远可能漏,参数化不会。
在 database/sql 里还有几个容易踩的点:
db.Query的占位符风格随驱动而变:Postgres(pgx/lib/pq)用$1、$2,MySQL 用?。写跨库代码时不要硬编码。fmt.Sprintf拼 SQL 是重灾区:它看起来「只是格式化」,实际就是拼接,同样会被注入。IN子句不能直接IN ($1):要按参数个数动态生成IN ($1,$2,...),参数本身仍是参数化的。- 动态表名/列名无法参数化:这类必须用白名单(见 17.1.5),不能把用户输入当标识符。
17.1.4 输出编码:XSS 是「注入到 HTML」
注入不止 SQL 一种。任何「把数据放进某种语法结构」的地方都有注入风险,HTML 就是典型。TaskHub 的任务标题会被渲染到页面,如果模板用的是 text/template 或手工字符串拼接,标题里的 <script> 就会执行。对照实验:
import (
htmltemplate "html/template"
"strings"
texttemplate "text/template"
)
payload := `<script>alert('xss')</script>`
ctx := map[string]string{"Title": payload}
var raw strings.Builder
_ = texttemplate.Must(texttemplate.New("t").Parse("{{.Title}}")).Execute(&raw, ctx)
var safe strings.Builder
_ = htmltemplate.Must(htmltemplate.New("t").Parse("{{.Title}}")).Execute(&safe, ctx)
本机真实输出:
输入 : <script>alert('xss')</script>
text/template : <script>alert('xss')</script>
html/template : <script>alert('xss')</script>
text/template 原样输出,脚本直接生效;html/template 按 HTML 上下文自动转义成实体,浏览器只会显示文本,不会执行。关键在于 html/template 是上下文感知的:同一个值放在 HTML 文本、属性、URL、JS 里,转义规则不同,它会分别处理。
由此得到两条纪律:
- 面向 HTML 的渲染一律用
html/template,绝不用text/template或字符串拼接。 - 转义发生在输出侧,而不是输入侧:不要试图在入库时「清洗」掉
<script>,因为同一个值可能出现在 HTML、JSON、日志、SQL 等不同上下文里,各需不同编码。存原文,输出时按上下文编码。
一个容易忽略的变体是存储型 XSS:恶意脚本被存进数据库(比如任务标题),之后每个查看该任务的用户都会被攻击。它比反射型更危险,因为不需要诱导受害者点击特制链接。防御的关键仍然是输出侧编码——只要渲染用 html/template,存进库里的 <script> 在输出时会被转义。反之,若在入库时「过滤」而输出时不编码,一旦过滤规则有疏漏(编码绕过、大小写、注释符),存储型 XSS 就成立了。
17.1.5 白名单校验:数字、枚举、长度、编码
参数化挡得住 SQL 注入,挡不住「业务上的非法值」。输入校验的目标是只放行符合预期的形状,也就是白名单——只允许已知合法的集合,其余一律拒绝。四种最常用的校验:
// 1) 数值范围:分页参数必须有界,否则 limit=1e9 会拖垮数据库
limit := 20
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 100 {
http.Error(w, "limit must be 1..100", http.StatusBadRequest)
return
}
limit = n
}
// 2) 枚举:状态只能是已知的几个值
var validStatus = map[string]bool{"open": true, "done": true, "archived": true}
if s := r.URL.Query().Get("status"); s != "" && !validStatus[s] {
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
另外两类:
- 长度:标题、描述要有最大长度,否则一个 10MB 的字符串能撑爆内存与存储。
- 编码:URL 参数、body 要用
utf8.ValidString确认是合法 UTF-8,否则后续处理会出现替换字符。
校验失败要返回明确的 400,而不是静默截断或忽略——静默处理会让攻击者探测到边界。校验必须发生在进入业务逻辑之前,越早越好。
一个常被问的问题:「校验一次够不够?」答案是在每个边界都校验。HTTP handler 校验过的数据,如果经队列转了一圈再回来,从队列读出时仍要再校验——因为队列本身也是一个信任边界,投递方可能不是你以为的那个服务,消息也可能被中间人篡改。校验是廉价的(几微秒),漏掉一次的代价却可能是越权。
17.1.6 命令注入与路径穿越
TaskHub 有文件上传与导出功能,这两处各有一类经典注入:
命令注入:如果为了生成缩略图或调用外部工具而拼 shell 命令,用户可控的文件名会变成命令。危险写法:
// 危险:文件名里带 `; rm -rf /` 就完了
cmd := exec.Command("sh", "-c", "convert "+name+" out.png")
正确写法是不经过 shell,直接传参数列表:
// 安全:name 只作为一个独立参数,不参与 shell 解析
cmd := exec.Command("convert", name, "out.png")
exec.Command 本身不经过 shell,所以只要不写 sh -c、不把用户输入拼进一个命令字符串,命令注入就不成立。
路径穿越:上传或下载时,若把用户提供的文件名直接拼到目录后面,../../etc/passwd 就能读到系统文件。防御是「规范化后确认仍在允许目录内」:
func safeJoin(base, name string) (string, error) {
clean := filepath.Clean("/" + name) // 先归一化,去掉 ../
full := filepath.Join(base, clean)
rel, err := filepath.Rel(base, full)
if err != nil || strings.HasPrefix(rel, "..") {
return "", errors.New("path escapes base dir")
}
return full, nil
}
更稳妥的做法是不信任用户提供的文件名:服务端生成随机名(如 UUID),把原始文件名只当元数据存起来。
17.1.7 防护清单
- 所有 SQL 用参数化,绝不
fmt.Sprintf/ 字符串拼接 - 动态表名/列名/排序字段用白名单枚举,不参数化
- HTML 渲染用
html/template,不用text/template - 转义在输出侧按上下文做,不在输入侧清洗
- 数值有范围、枚举有集合、字符串有长度上限、确认是合法 UTF-8
- 外部命令用
exec.Command传参数列表,不经 shell - 文件路径规范化后校验是否越界,或直接用服务端生成的随机名
- 校验失败返回明确 400,不静默截断
小结
- 信任边界之外的一切数据都不可信;校验要在每个边界做,不只在 handler 入口。
- 本机真实复现:字符串拼接的查询被
' OR '1'='1注入,返回 2 行(越权读到别的租户);参数化查询命中 0 行(注入被挡下)。 - 防注入的原则是不让数据与语法相遇(参数化),而不是靠正确转义。
- 动态标识符(表名、列名、排序字段)无法参数化,必须走白名单。
- 本机真实复现:
text/template原样输出<script>,html/template转义为实体;转义在输出侧按上下文做。 - 命令注入靠「不经 shell、传参数列表」防;路径穿越靠「规范化后校验越界」防。
下一节把防线从「应用层」下沉到「传输与存储」:17.2 传输与存储加密 会用真实握手演示如何强制 TLS 1.3,并用 AES-GCM 与 bcrypt 把「数据即使被拖走也读不出」落成可运行的代码。
阅读导航:上一节:16.3 告警与 on-call · 下一节:17.2 传输与存储加密 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。