很多人第一次用 Go 写 Web 页面,会在 text/template 和 html/template 之间犹豫。两个包 API 很像,名字也很像,但用途不一样。只要输出的是 HTML 页面,通常就应该使用 html/template。它最重要的能力是自动上下文转义:用户输入出现在 HTML 文本、属性、JavaScript 或 URL 位置时,模板引擎会按位置做不同处理,降低 XSS 风险。
本文不把模板安全讲成一堆抽象规则,而是从一个真实的小页面开始。假设我们要做一个文章列表页,页面展示标题、作者和摘要。数据来自数据库或用户输入,因此默认都不可信。我们的目标是让页面能正常显示,同时不把用户输入当成 HTML 执行。
从 html/template 开始
一个最小示例:
package main
import (
"html/template"
"log"
"net/http"
)
type Article struct {
Title string
Author string
Summary string
}
var page = template.Must(template.New("list").Parse(`
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>文章</title></head>
<body>
<h1>文章列表</h1>
{{range .}}
<article>
<h2>{{.Title}}</h2>
<p>作者:{{.Author}}</p>
<p>{{.Summary}}</p>
</article>
{{end}}
</body>
</html>
`))
func handler(w http.ResponseWriter, r *http.Request) {
articles := []Article{
{Title: "<script>alert(1)</script>", Author: "小李", Summary: "一篇测试文章"},
}
if err := page.Execute(w, articles); err != nil {
http.Error(w, "render page", http.StatusInternalServerError)
log.Println(err)
}
}
浏览器里不会执行标题里的脚本,因为 html/template 会把 <、> 等字符转义。你看到的会是文本,而不是脚本。这就是默认安全边界:数据默认当作文本,不当作 HTML。
不要为了显示富文本随便关闭转义
有时产品会要求“摘要里支持加粗和链接”。初学者可能会搜索到 template.HTML:
type ArticleView struct {
Title string
Summary template.HTML
}
template.HTML 的意思是:我保证这段内容已经安全,可以原样输出。它不是“帮我变安全”,而是“请不要再转义”。如果你把用户提交的富文本直接转成 template.HTML,就相当于绕过了模板保护。
更稳的做法是先用可信的 HTML 清洗库过滤富文本,只允许少量标签和属性,然后再作为安全 HTML 输出。对于刚入门的项目,最简单的选择是先不支持富文本。很多后台系统、个人工具和内部页面,用纯文本摘要已经足够。安全功能不要因为“看起来需要”而仓促上线。
模板函数只做展示转换
Go 模板可以注册函数:
funcMap := template.FuncMap{
"short": func(s string) string {
if len([]rune(s)) <= 20 {
return s
}
return string([]rune(s)[:20]) + "..."
},
}
tmpl := template.Must(template.New("list").Funcs(funcMap).Parse(`
{{range .}}
<h2>{{.Title}}</h2>
<p>{{short .Summary}}</p>
{{end}}
`))
模板函数适合做展示层转换,比如截断、格式化时间、格式化金额、选择 CSS class。不要把数据库查询、权限判断、业务流程放进模板函数。模板应该是渲染层,不应该偷偷改变系统状态。
一个实用原则是:模板函数应该是纯函数。同样输入得到同样输出,没有网络请求,没有写数据库,没有读取全局配置。如果函数里开始传 context.Context、查缓存、写日志,就说明它可能不该在模板层。
数据结构要为页面服务
很多初学者会把数据库模型直接传给模板。短期能跑,长期会让页面和数据表绑得太紧。更推荐定义页面专用 view model:
type ArticlePage struct {
Title string
Articles []ArticleItem
}
type ArticleItem struct {
Title string
AuthorName string
Summary string
URL string
}
Handler 负责把业务对象转换成页面对象:
func toArticlePage(items []Article) ArticlePage {
page := ArticlePage{Title: "文章列表"}
for _, item := range items {
page.Articles = append(page.Articles, ArticleItem{
Title: item.Title,
AuthorName: item.Author,
Summary: item.Summary,
URL: "/articles/" + item.Slug,
})
}
return page
}
这样模板只关心显示什么,不关心数据库字段怎么命名。以后页面要增加作者头像、标签、阅读时间,也不用把数据库模型到处暴露。
URL 和属性上下文
html/template 的自动转义不仅处理文本,也会处理属性和 URL:
<a href="{{.URL}}" title="{{.Title}}">{{.Title}}</a>
这比字符串拼接安全得多。但你仍然应该在业务层校验 URL。比如站内链接应该以 / 开头,外部链接要限制协议,避免把 javascript: 这类危险内容塞进 href。模板转义是最后一道防线,不是输入校验的替代品。
可以写一个简单校验:
func safeArticleURL(slug string) string {
if slug == "" || strings.Contains(slug, "/") {
return "/articles"
}
return "/articles/" + slug
}
这里没有试图支持所有 URL,只处理自己项目需要的站内路径。入门阶段越是边界清楚,越不容易把安全问题复杂化。
模板解析放在哪里
小项目可以在包级变量里 template.Must,启动时解析失败就直接暴露:
var templates = template.Must(template.ParseGlob("templates/*.html"))
服务型项目更常见的做法是在启动阶段解析模板,然后注入 handler:
type Server struct {
templates *template.Template
}
func NewServer(templates *template.Template) *Server {
return &Server{templates: templates}
}
这样测试时可以传入测试模板,生产环境传入真实模板。不要在每个请求里重复读取和解析模板,除非你明确是在开发模式下做热更新。模板解析通常应该发生在启动阶段,请求阶段只执行模板。
测试页面渲染
模板也可以测试。你不一定要启动浏览器,先用 buffer 执行模板,检查关键内容:
func TestArticleTemplateEscapesTitle(t *testing.T) {
var buf bytes.Buffer
err := page.Execute(&buf, []Article{
{Title: "<script>alert(1)</script>", Author: "test", Summary: "hello"},
})
if err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), "<script>") {
t.Fatal("title was not escaped")
}
}
这个测试很朴素,但能防止有人把 html/template 换成 text/template,或者错误地把字段标成 template.HTML。安全相关的测试不需要炫技,能覆盖关键风险就有价值。
小结
写 HTML 页面时优先使用 html/template,让用户输入默认按上下文转义。不要把未经清洗的用户内容转成 template.HTML,模板函数保持简单纯粹,页面数据结构最好和数据库模型分开。
模板安全不是模板包一个人的责任。输入校验、URL 生成、富文本清洗、模板测试都要各守边界。初学阶段只要记住一句话:外部数据默认不可信,能当文本显示就不要当 HTML 执行。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。