Go HTML 模板安全入门:自动转义、模板函数和页面渲染边界

从一个小型页面开始讲 Go html/template 的自动转义、安全边界、模板函数和常见误用,适合刚开始写 Go Web 页面的读者。

很多人第一次用 Go 写 Web 页面,会在 text/templatehtml/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 执行。

继续阅读

探索更多技术文章

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

全部文章 返回首页