《Go 语言编程实战》17.1 输入校验与注入防护

多租户系统里,一个没校验的查询参数就可能让 A 租户读到 B 租户的数据。本节用真实 Postgres 容器跑通两段对照实验:字符串拼接的查询被注入读到 2 行(越权),参数化查询命中 0 行(挡下);再用 html/template 演示输出转义,并给出白名单校验、命令注入与路径穿越的防护清单。

17.1 输入校验与注入防护

前面几章一直在教「怎么把功能做对」,从这一章开始换一个问法:「如果输入是恶意的,这套代码会怎样?」TaskHub 是多租户系统,第 5 章已经用租户 ID 强制注入查询做隔离——但如果某个查询是把租户 ID 拼进 SQL 字符串的,那个隔离就形同虚设。

本节把 TaskHub 推进到「默认所有输入都不可信」:用真实 Postgres 容器跑通 SQL 注入的越权复现与参数化防御的对照,用 html/template 演示输出转义,并给出白名单校验、命令注入与路径穿越的防护清单。

17.1.1 信任边界:输入从哪来,信任到哪止

安全设计的第一步是画清信任边界:边界之外的一切数据都不可信。对 TaskHub 而言,边界之外包括:

来源例子为什么不可信
HTTP 请求URL 参数、body、header、cookie完全由客户端构造
消息队列上游服务投递的消息可能被伪造或格式漂移
数据库早期脏数据、被注入写入的值历史遗留
第三方 APIOAuth 回调、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 : &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;

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 传输与存储加密 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练