🛡
Защита от SQL инъекций и XSS в GoОба типа атак растут из одной ошибки. Мы склеиваем строку из данных пользователя и отдаём её движку, который умеет исполнять код. База данных видит 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