Оба типа атак растут из одной ошибки. Мы склеиваем строку из данных пользователя и отдаём её движку, который умеет исполнять код. База данных видит SQL, браузер видит HTML или JavaScript. Данные внезапно становятся командами.
Разберём, как этого не допускать на практике, и что спросят на собеседовании.
💉 SQL инъекции
Опасный код выглядит так:
query := fmt.Sprintf("SELECT id, email FROM users WHERE email = '%s'", email)
row := db.QueryRow(query)Если в
email придёт ' OR 1=1 --, условие развалится и вернётся вся таблица. Строка форматируется до того, как попадёт в базу, поэтому никакой защиты тут нет.Правильный вариант передаёт значения отдельно от текста запроса:
row := db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email = $1", email)
Драйвер отправляет запрос и параметры разными полями протокола. Значение уже не может изменить структуру запроса, потому что база разбирает синтаксис до подстановки данных. Синтаксис плейсхолдера зависит от драйвера, в
pgx и lib/pq это $1, в go-sql-driver/mysql это ?.Что нельзя параметризовать
Плейсхолдеры работают только для значений. Имя таблицы, имя колонки, направление сортировки так подставить не получится. Здесь помогает белый список:
var allowedSort = map[string]string{
"created": "created_at",
"email": "email",
}
column, ok := allowedSort[r.URL.Query().Get("sort")]
if !ok {
column = "created_at"
}
query := fmt.Sprintf("SELECT id FROM users ORDER BY %s DESC LIMIT 50", column)Форматирование строки тут безопасно, потому что в
column попадает только заранее известное значение из карты, а не пользовательский ввод.Отдельно стоит помнить про
IN. Список плейсхолдеров придётся собрать самому:placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := "SELECT id FROM users WHERE id IN (" + strings.Join(placeholders, ",") + ")"
rows, err := db.QueryContext(ctx, query, args...)
В
pgx проще передать массив через = ANY($1).Билдеры вроде
squirrel и ORM вроде gorm подставляют аргументы сами. Но у обоих есть методы для сырых кусков SQL, и туда пользовательский ввод класть нельзя. gorm спасает не сам по себе, а только пока мы не пишем Where(fmt.Sprintf(...)).Дополнительные меры, которые уменьшают ущерб при ошибке. Отдельный пользователь базы с минимальными правами, без
DROP и без доступа к чужим схемам. Логи запросов без подстановленных значений. Ограничение на размер входных полей.🔪 XSS
Та же логика, только теперь опасный движок это браузер. Если мы вставим пользовательский текст прямо в HTML, тег
<script> из комментария выполнится у всех, кто открыл страницу.Плохо:
fmt.Fprintf(w, "<div>Привет, %s</div>", name)
Хорошо:
tmpl := template.Must(template.New("page").Parse(
`<div>Привет, {{ .Name }}</div>`))
tmpl.Execute(w, data)Ключевое отличие в пакете.
html/template экранирует данные с учётом контекста, text/template не экранирует ничего. Импорт text/template в веб хендлере это готовая дыра, и на собеседовании про это спрашивают почти всегда.Контекстность означает, что шаблонизатор смотрит, куда именно попадает значение. Внутри тега это HTML экранирование, внутри атрибута другое, внутри
href проверка схемы, внутри блока <script> экранирование по правилам JavaScript. Один и тот же
{{ .Value }} даст разный вывод:tmpl := template.Must(template.New("x").Parse(`
<a href="{{ .URL }}" title="{{ .Title }}">{{ .Title }}</a>
<script>var user = {{ .Name }};</script>
`))Строка
javascript:alert(1) в href превратится в #ZgotmplZ, то есть ссылка просто не сработает.➡️ Где защита отключается
Типы
template.HTML, template.JS, template.URL говорят шаблонизатору доверять содержимому и не трогать его. Это законная возможность, но применять её к данным пользователя нельзя:// так делать нельзя
data.Bio = template.HTML(userInput)
Если пользователю действительно нужен HTML, например разметка в комментариях, содержимое надо чистить санитайзером:
import "github.com/microcosm-cc/bluemonday"
p := bluemonday.UGCPolicy()
safe := template.HTML(p.Sanitize(userInput))
bluemonday работает по белому списку тегов и атрибутов, всё остальное вырезает.➡️ API и заголовки
Для JSON ответов достаточно
encoding/json, он экранирует <, > и & в escape последовательности. Но заголовок ставить обязательно, иначе браузер может угадать тип и отрендерить ответ как HTML.w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
json.NewEncoder(w).Encode(resp)Хорошая практика добавить
Content-Security-Policy, который запрещает инлайновые скрипты. Он не заменяет экранирование, но снижает шанс, что случайная дыра превратится в кражу сессий. Куки сессии стоит помечать HttpOnly, тогда украденный скрипт до них не доберётся.📍 Навигация: Вакансии • Задачи • Собесы
🐸 Библиотека Go-разработчика
#GoDeep