Имя пользователя стало HTML в письме Go: выбираем html/template

Письмо с подтверждением содержит имя пользователя и ссылку на профиль. После перехода с конкатенации на шаблон разработчик использует text/template: текст собирается удобнее, но пользовательский тег всё ещё попадает в HTML как разметка. Шаблонизация и экранирование — разные свойства, и выбор пакета здесь меняет результат.

Выбрать контекстное экранирование

html/template использует похожий синтаксис, но анализирует контекст подстановки. Значение внутри текстового узла требует одного представления, значение атрибута и URL — другого. В имени символы угловых скобок должны стать текстом. В href опасная схема URL должна быть отвергнута, даже если кавычки удалось правильно экранировать.

В примере вход содержит HTML-тег в имени и javascript в ссылке. Оба значения передаются обычными строками. Результат должен содержать экранированное имя и специальное заблокированное значение URL. Код также строит текстовый шаблон с тем же именем, чтобы показать причину ошибки: там пользовательский тег остаётся буквальной разметкой.

Граница доверия проходит по автору шаблона. Исходник шаблона контролирует приложение, а пользователь поставляет только значения полей. Если разрешить пользователю писать сам шаблон, контекстное экранирование уже не превращает его в безопасный произвольный документ. Для конструктора писем понадобятся собственные ограничения на доступные действия и разметку.

Проверить имя и ссылку

package main

import (
	"bytes"
	"fmt"
	htmltemplate "html/template"
	"strings"
	texttemplate "text/template"
)

func main() {
	data := struct {
		Name string
		URL  string
	}{Name: "<b>Alice</b>", URL: "javascript:alert(1)"}
	safe := htmltemplate.Must(htmltemplate.New("mail").Parse(`<p>{{.Name}}</p><a href="{{.URL}}">profile</a>`))
	var output bytes.Buffer
	if err := safe.Execute(&output, data); err != nil {
		panic(err)
	}
	rendered := output.String()
	if !strings.Contains(rendered, "\x26lt;b\x26gt;Alice\x26lt;/b\x26gt;") || !strings.Contains(rendered, "#ZgotmplZ") {
		panic("unsafe HTML output")
	}
	plain := texttemplate.Must(texttemplate.New("plain").Parse(`{{.Name}}`))
	var unsafe bytes.Buffer
	if err := plain.Execute(&unsafe, data); err != nil {
		panic(err)
	}
	if unsafe.String() != "<b>Alice</b>" {
		panic("text template behavior changed")
	}
	fmt.Println(rendered)
}

Типы template.HTML и template.URL сообщают движку, что значение уже доверенное для соответствующего контекста. Приведение пользовательского ввода к такому типу снимает важную проверку и не является очисткой. Если часть письма действительно поступает как разрешённый HTML, сначала нужна отдельная политика допустимой разметки и её обработка, а лишь затем осознанное использование доверенного типа.

Не повышать доверие к входным строкам

Экранирование не подтверждает полезность ссылки. Для реального письма сформируйте профильный URL на стороне приложения из известного основания и проверенного ID. Если получатель может задавать адрес, ограничьте допустимые схемы и назначение. Безопасное представление строки и правильный бизнес-адрес — отдельные требования.

При HTTP-выдаче сначала рендерьте шаблон в буфер, затем выставляйте статус и записывайте результат. Ошибка Execute может появиться после частичной записи. Если писать сразу в ResponseWriter, после отправки заголовков уже нельзя заменить ответ на чистую ошибку. Для большого документа потребуется другая явная политика потоковой выдачи; маленькое письмо удобно подготовить целиком.

Шаблоны можно разобрать при старте и затем выполнять с разными данными. Не изменяйте общий объект данных одновременно с рендерингом и не используйте один bytes.Buffer между независимыми вызовами. Буфер в примере принадлежит одной операции, поэтому результаты разных писем не смешиваются.

Проверьте имя с амперсандом, кавычками и угловыми скобками, допустимый HTTPS-адрес и запрещённую схему. Эти входы защищают реальные контексты подстановки. Проверка только того, что результат содержит имя Alice, не обнаружит появившийся HTML-тег или опасный href.

Продолжить практику

Подготовка письма — один этап обработки события. В курсе «Микросервисы на Go» вы разберёте доставку событий, повторы consumer и восстановление через outbox/inbox. Откройте программу и продолжите с вопроса, когда уведомление считается обработанным и как повтор не создаёт второго результата.

Продолжить на практике

  • Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.