html/template в Go: как автоэкранирование спасает от XSS
Пакет html/template экранирует данные сам, но только если вы даёте ему работать. Разбираем, где контекстное экранирование ломается и как не открыть дыру через template.HTML.

Строка <script>alert(1)</script>, пришедшая от пользователя и подставленная в HTML, — это классический XSS. В Go пакет html/template из стандартной библиотеки закрывает эту атаку по умолчанию: он экранирует данные в зависимости от того, куда они попадают — в текст, в атрибут, в URL или в JavaScript. Но у этой защиты есть чёткая граница. Как только вы оборачиваете значение в template.HTML или собираете HTML строками, экранирование выключается, и ответственность переходит на вас. Разберём, как именно работает контекстное экранирование, где программисты его случайно обходят и какие приёмы держат шаблоны безопасными. Материал актуален для Go 1.21+, поведение пакета стабильно много версий.
Два разных пакета с почти одинаковым API
В стандартной библиотеке есть text/template и html/template. У них совпадают синтаксис и почти весь публичный API, поэтому их путают. Разница одна, но критичная: text/template подставляет данные как есть, а html/template анализирует контекст и экранирует.
Если ваш шаблон генерирует HTML для браузера, а вы импортировали text/template — вы отдаёте пользователю XSS «из коробки». Проверьте импорты первым делом.
Минимальный пример, который экранирует по умолчанию:
package main
import (
"html/template"
"os"
)
func main() {
// name пришёл от пользователя
name := `<script>alert(1)</script>`
tmpl := template.Must(template.New("page").Parse(
`<p>Привет, {{ .Name }}</p>`,
))
// в вывод попадёт <script>..., а не рабочий тег
_ = tmpl.Execute(os.Stdout, struct{ Name string }{name})
}
В браузере пользователь увидит текст <script>alert(1)</script>, а не выполнит его. Никаких вызовов вроде ручного экранирования не нужно — движок сам определил, что .Name стоит в текстовом контексте.
Контекст решает, как экранировать
Ключевая идея html/template: одно и то же значение экранируется по-разному в зависимости от места вставки. Движок разбирает шаблон и понимает, где находится точка подстановки.
| Куда подставляется | Что происходит с данными |
|---|---|
| Текст между тегами | Экранируются < > & и другие HTML-символы |
Значение атрибута title="{{.}}" | Экранируются кавычки и HTML-сущности |
URL в href="{{.}}" | Проверяется схема, применяется URL-экранирование |
Внутри <script>{{.}}</script> | Значение кодируется как безопасный JS-литерал |
Внутри CSS style="{{.}}" | Применяется CSS-экранирование |
Пример с URL наглядно показывает разницу. Если пользователь подсунет javascript:alert(1) в ссылку, движок вырежет опасную схему:
tmpl := template.Must(template.New("link").Parse(
`<a href="{{ .URL }}">тык</a>`,
))
// .URL = "javascript:alert(1)"
// в выводе href станет "#ZgotmplZ" — сигнал, что схема заблокирована
Строка #ZgotmplZ в выводе — не баг, а маркер: движок обнаружил URL с небезопасной схемой и подменил его. Увидели такое в HTML — значит, в ссылку прилетело что-то подозрительное.
Где защита выключается
Автоэкранирование обходят три способа, и все три — осознанное решение разработчика, а не случайность движка.
1. Типы template.HTML, template.JS, template.URL
Если значение имеет тип template.HTML, движок считает его уже безопасным и вставляет без экранирования. Это дверь для XSS, если строка получена от пользователя:
import "html/template"
// ОПАСНО: userInput не экранируется
var userInput string = getFromRequest()
data := struct{ Body template.HTML }{template.HTML(userInput)}
Правило простое: в template.HTML оборачивайте только то, что сгенерировали сами или что прошло через санитайзер. Никогда — сырой пользовательский ввод.
2. Сборка HTML строками мимо шаблона
Конкатенация вроде "<p>" + userInput + "</p>" и передача результата как template.HTML сводит на нет всю пользу пакета. HTML должен собираться внутри шаблона, а не в коде на Go.
3. Некорректный контекст в самом шаблоне
Движок анализирует статический текст шаблона. Если вы подставляете данные в место, где начинается имя атрибута или тега, движок либо откажется выполнять шаблон, либо не сможет выбрать правильное экранирование. Например, подстановка в имя атрибута считается ошибкой парсинга:
// html/template откажется парсить: точка вставки в имени атрибута
`<div {{ .AttrName }}="x"></div>`
Это защитный механизм: движок не даёт данным влиять на структуру разметки.
Единственное, что вам нужно запомнить про безопасность html/template: не оборачивайте пользовательский ввод в template.HTML. Всё остальное движок делает за вас.
Когда безопасный HTML действительно нужен
Бывает законная задача: показать HTML, который пользователь ввёл через визуальный редактор или Markdown. Здесь нельзя просто экранировать — тогда разметка превратится в текст. И нельзя доверять вводу как есть. Решение — санитайзер, который вырезает опасные теги и атрибуты, а потом уже оборачивание в template.HTML.
В экосистеме Go для этого используют библиотеку github.com/microcosm-cc/bluemonday — она проверена временем и покрывает большинство случаев. Порядок такой:
- Пропустите пользовательский HTML через политику bluemonday (например,
bluemonday.UGCPolicy()). - Только очищенный результат оборачивайте в
template.HTML. - Никогда не меняйте порядок: сначала санитизация, потом доверие.
Точные имена политик и методов сверяйте с актуальной документацией bluemonday — API мог измениться между версиями.
Приёмы, которые упрощают жизнь
Функции через FuncMap
Свои функции регистрируются до парсинга. Важно: если функция возвращает string, результат экранируется как обычно; если возвращает template.HTML — нет. Это удобный способ явно пометить доверенный фрагмент.
funcs := template.FuncMap{
"upper": strings.ToUpper, // вернёт string — экранируется
}
tmpl := template.Must(
template.New("t").Funcs(funcs).Parse(`{{ .Name | upper }}`),
)
Наследование через define и template
Общий каркас выносится в базовый шаблон, страницы переопределяют блоки. Это стандартный способ не дублировать разметку — используйте {{ define "content" }} в дочерних файлах и {{ template "content" . }} в базовом.
Кеширование скомпилированных шаблонов
Парсинг шаблона — не бесплатная операция. В продакшене парсите один раз при старте приложения через template.Must(...) и переиспользуйте объект *template.Template. Он безопасен для конкурентного вызова Execute из нескольких горутин, пока вы не меняете сам шаблон.
Если не получилось
- В браузере видны
<вместо тегов, хотя HTML нужен. Значение экранировано как текст. Если фрагмент доверенный — оберните вtemplate.HTMLпосле санитизации. - Ссылка превратилась в
#ZgotmplZ. В URL попала небезопасная схема (обычноjavascript:). Проверьте источник значения. - Ошибка вида
cannot parse actionили про контекст. Вы подставляете данные в имя тега или атрибута. Переделайте разметку так, чтобы данные шли в значение, а не в структуру. - XSS проходит, хотя вы используете html/template. Проверьте импорт: вероятно, подключён
text/template, либо где-то стоитtemplate.HTML(userInput). - Изменения в файле шаблона не подхватываются. Шаблон распарсен один раз при старте. Для разработки перечитывайте файлы на каждый запрос, для прода оставьте кеш.
Что делать дальше
Пройдитесь по кодовой базе поиском template.HTML(, template.JS( и template.URL( — каждое место, где аргумент приходит от пользователя, потенциальная дыра. Настройте линтер или ревью-чеклист так, чтобы такие конверсии всегда сопровождались санитизацией. Если рендерите пользовательский Markdown или контент редактора — заложите bluemonday или аналог в конвейер. И убедитесь, что все веб-шаблоны импортируют именно html/template, а text/template оставлен только для генерации не-HTML (конфигов, писем в plain text, кода).
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Package html/template — Go standard library, Package text/template — Go standard library
Частые вопросы
Чем html/template отличается от text/template?
А если у меня Go старой версии, поведение то же?
Можно ли полностью отключить экранирование?
XSS проходит, хотя я использую html/template. Где ошибка?
Как безопасно показать HTML из визуального редактора?
Безопасно ли вызывать Execute из нескольких горутин?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.