用户资料页里的“个人主页”链接,字段值完全来自用户表单提交。最开始的代码只做了基础HTML转义,测试输入https://example.com的时候一切正常;换成带脚本协议的字符串之后才发现,真正有风险的根本不是引号有没有转义,而是浏览器会不会把这个值当成可执行的链接协议来解析。
- 把用户输入先当成候选URL处理,先做解析再限制协议范围,只允许
http和https。 - 让
html/template负责自动做上下文转义,不要随便用template.URL给还没经过审核的字符串开权限绕过校验。 - 用表格驱动测试覆盖协议大小写、首尾空白、协议相对地址和解析失败的场景,不要只测一两个正常链接就完事。
href 的危险点不在引号,而在协议
html/template会根据输出的位置自动做上下文感知转义。把值放到普通文本节点里时,核心是字符编码防注入;把值放到href属性里时,模板还会额外判断这个值是不是符合安全要求的URL。这两种场景的处理逻辑完全不一样。
下面这个例子是非常常见的新手误区:
type Profile struct {
Name string
URL string
}
tmpl := template.Must(template.New("profile").Parse(
`{{.Name}}`,
))
当URL完全由用户可控时,不能觉得模板包自带转义就跳过业务层面的校验。安全边界要回答一个更具体的问题:这个链接允许指向哪些协议、哪些主机?我们这里只需要普通的外链导航,所以先把范围收紧到http、https,其余所有形式的输入全部直接拒绝。
先解析,再把协议和空值规则写死
过滤函数没必要硬拼出一个“看起来没问题”的结果。只要是无法解析、不带协议、协议不在白名单里的情况,直接返回空字符串就行,后续要不要展示链接完全交给上层逻辑判断。
package profile
import (
"net/url"
"strings"
)
func safeURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return ""
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
return u.String()
default:
return ""
}
}
这里有两个很容易被忽略的细节。第一,strings.ToLower只用来提取协议做比较,原始URL的路径和查询参数完全由解析结果原样返回;第二,要求u.Host非空,就是为了直接拒绝/settings这类相对路径。如果产品本身就需要支持站内相对链接,应该单独做一套规则,不要把两种不同用途的校验逻辑混在同一个过滤器里。
把安全边界放在模板渲染前
模板层只负责页面展示,不要让它承担判断业务字段是否可信的责任。渲染前就把候选URL转成“已经通过安全策略校验的URL”,链接值为空的时候直接不输出对应的a标签:
type ViewProfile struct {
Name string
SafeLink string
}
func toView(p Profile) ViewProfile {
return ViewProfile{
Name: p.Name,
SafeLink: safeURL(p.URL),
}
}
{{if .SafeLink}}个人主页{{end}}
不要为了省事把原始字符串直接转成template.URL来绕过模板自带的检查。这个类型的设计初衷是“调用方已经完成全部安全审核”,不是用来“帮我关掉校验”的,一旦上游误用了这个类型,模板层最后一道保护机制就直接失效了。
用表格驱动测试锁住协议白名单
安全规则最容易出现的问题就是“修了一个输入,漏了另一个输入”。下面的测试把允许通过、直接拒绝和边界异常情况全部放在一张表里,后续修改规则的时候可以直接看到所有影响范围:
func TestSafeURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{"https", "https://example.com/docs?q=go", "https://example.com/docs?q=go"},
{"http with spaces", " http://example.com ", "http://example.com"},
{"script scheme", "javascript:alert(1)", ""},
{"data scheme", "data:text/html,hello", ""},
{"relative path", "/account", ""},
{"missing host", "https:", ""},
{"uppercase scheme", "HTTPS://example.com", "HTTPS://example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := safeURL(tt.raw); got != tt.want {
t.Fatalf("safeURL(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
跑完go test ./...之后,还要检查最终渲染出来的页面效果:被拒绝的输入页面里不能出现空的href标签,被允许的输入对应的链接要完整保留原有的查询参数。如果后续业务要加更多允许的协议,比如mailto,要先单独评估这类协议的参数规则和客户端行为,确认没问题之后再把它加到白名单里,绝对不能图省事把过滤条件改成“只要不是空就放行”。
三个常见误区:转义、正则和 template.URL
误区一:HTML 转义等于 URL 安全
转义解决的是标签上下文里的字符正确表达问题,不会替你制定允许访问的协议策略。href的安全判断必须结合原生URL解析和业务侧的协议白名单才能完成。
误区二:用正则判断完整 URL
手写正则很容易漏掉大小写、首尾空白、端口号、查询串和各种解析边界场景。这种场景更适合让net/url负责底层的URL结构解析,再搭配少量明确的条件完成策略判断。
误区三:为了让测试通过直接使用 template.URL
如果测试用例全是正常输入,这种写法看不出任何风险;但只要字段值来自用户提交或者第三方接口,就必须把审核动作放在数据传入模板之前完成。
上线前的安全验收
- 允许
http、https外链,完整保留正常的路径、查询参数和端口号信息。 - 直接拒绝脚本协议、数据协议、无主机地址和解析失败的输入。
- 输入不符合规则时不输出空链接,也不要把原始用户字段直接转换成
template.URL。 - 修改白名单规则后重新跑一遍全部单元测试,再对最终生成的HTML做一次人工抽查。
相关问题
为什么不用 url.ParseRequestURI?
这个接口更适合校验服务端收到的请求URI格式;我们这里要判断外链的主机和协议,用url.Parse配合明确的业务条件写出来的代码更直观也好维护。
站内相对链接应该怎么处理?
单独写一个只接受固定前缀、禁止自定义协议和反斜杠的校验函数,和外链白名单的逻辑分开测试,避免两套规则互相放宽导致漏洞。
能不能只在前端过滤?
不能。前端校验可以用来优化用户输入提示,但服务端生成HTML之前必须重新做一次判断,因为用户可以直接构造请求完全绕过浏览器的前端脚本校验。
结语
Go的html/template已经帮你处理了很多输出层面的细节问题,但它不会替业务决定“哪些链接是值得信任的”。把URL解析、协议白名单和拒绝策略全部收拢到渲染前统一处理,再用一组覆盖恶意协议和边界输入的测试用例守住规则,页面展示逻辑和安全边界就不会纠缠在一起出问题。

Go database/sql 查完数据为什么还要检查 Rows.Err:连接中断、Close 与事务边界
