10.4Безопасность и защита сервиса
Разбери реальные уязвимости на границе сервиса: SQL injection, mass assignment, SSRF, IDOR, CSRF, XSS и CORS. Для каждой определи, кто контролирует вход, какой слой обязан его проверять и чем именно исправляется конкретный дефект.
Одна мысль, из которой выводится вся глава
Почти каждая уязвимость из этого списка сводится к стёртой границе между данными и кодом или к доверию к тому, что контролирует не сервер. В SQL-инъекции пользовательская строка стала куском SQL, в XSS стала куском HTML, в SSRF превратилась в адрес, куда сервер сам пошёл. Остальное про доверие. При CSRF сервер поверил, что раз кука пришла, значит запрос осознанный. При IDOR он поверил идентификатору из URL, при mass assignment поверил набору полей во входном JSON. Если держать в голове эту рамку, ответы перестают быть списком заклинаний.
- Полностью наш: инъекции всех видов, авторизация и владение объектом, mass assignment, rate limiting, идемпотентность, валидация, конфигурация CORS и заголовков.
- Наш, но исполняет браузер: CSRF (
SameSite, токены), XSS (экранирование при рендере,Content-Type, CSP), CORS. Правила задаём мы, а соблюдает их чужая программа на чужом компьютере. - Не наш вовсе: что делает злоумышленник своим curl. Если «защиту» снимает отключённый JavaScript или запрос, собранный руками, это не защита.
«Мы валидируем на фронте, поэтому инъекция невозможна» и «CORS защищает наш API от чужих сайтов». Первая ошибка в том, что фронт вообще не граница доверия. Вторая в том, что CORS ослабляет политику браузера, а не защищает сервер; подробности ниже.
SQL-инъекция
Классика, которую всё ещё находят в проде - обычно не в основном CRUD, а в отчётах, поиске,
админке и там, где «плейсхолдер не подставляется»: сортировка, имя таблицы,
IN (...) с динамическим числом элементов. Механика всегда одна: строка
пользователя попадает в текст запроса, СУБД разбирает этот текст целиком и не может
отличить, какая часть была задумана как данные.
// Уязвимо. Такое и находят на пентестах.
func (r *Repo) UserByID(ctx context.Context, raw string) (*User, error) {
q := "SELECT id, email, is_admin FROM users WHERE id = " + raw
row := r.db.QueryRowContext(ctx, q)
...
}
При raw = "1" всё работает, тесты зелёные. При
raw = "1 OR 1=1 --" получается
WHERE id = 1 OR 1=1 --: условие тождественно истинно, а -- отрезает
всё, что шло следом. Дальше атака развивается по накатанной:
- Расширение выборки. С
OR 1=1вываливается вся таблица. - UNION-инъекция подставляет в ту же форму ответа чужие колонки:
1 UNION SELECT id, password_hash, NULL FROM users(типы колонок должны сойтись, отсюдаNULLна местеis_admin). - Blind / boolean-based. Ответа не видно, зато видно, изменился ли он:
1 AND (SELECT substr(password_hash,1,1) FROM users WHERE id=1)='a', и хеш вытаскивается посимвольно. - Time-based обходится без ответа вовсе, ему хватает времени:
1 AND (SELECT 1 FROM pg_sleep(5)) = 1. Работает даже там, где ответ всегда 200 и пустой. - Stacked queries проходят, если драйвер разрешает несколько операторов в одном сообщении:
1; DROP TABLE users; --. В PostgreSQL расширенный протокол такое запрещает, аExecс простым протоколом или MySQL сmultiStatements=trueпропустят спокойно.
Почему параметризация работает, а экранирование - нет
Плейсхолдер не «умный escape». В расширенном протоколе PostgreSQL клиент шлёт два разных
сообщения: Parse с текстом запроса и Bind со значениями. Когда
сервер видит значение, синтаксическое дерево уже построено, и оно ляжет в конкретный
лист этого дерева. Никакая строка не «дорастёт» до нового предиката: грамматика уже
разобрана. Поэтому параметризация риск не смягчает, а устраняет.
Ручное экранирование решает ту же задачу с другого конца - пытается угадать, что именно опасно. И проигрывает сразу по нескольким причинам:
- Правила зависят от диалекта и настроек. В MySQL при
NO_BACKSLASH_ESCAPESобратный слеш перестаёт экранировать; в PostgreSQL поведениеE'...'иstandard_conforming_stringsразличается. Функция, написанная под одну конфигурацию, дырявая в другой. - Кодировки. Классическая атака на GBK/SJIS: байт
0x5Cоказывается второй половиной многобайтового символа, и добавленный слеш «съедается», освобождая кавычку. Экранирование, не знающее charset соединения, ломается. - Числовой контекст. Экранируют кавычки, но если значение подставляется без них
(
WHERE id = 1 OR 1=1), атакующему кавычки и не нужны. - Это ручная работа. Хватит одного забытого места - и всё остальное экранирование теряет смысл. Параметризация ошибается «в безопасную сторону»: забыл плейсхолдер, и запрос обычно просто не компилируется или падает на несовпадении числа аргументов.
Пользовательские данные попадают в запрос только через плейсхолдеры. Если значение нельзя передать плейсхолдером - значит, это не значение, а часть синтаксиса, и её надо выбирать из заранее заданного списка на стороне сервера, а не принимать от клиента.
ORDER BY и имена таблиц - куда плейсхолдер не ставится
Плейсхолдер подставляет значение. А имя колонки, направление сортировки, имя таблицы или
схемы относятся к идентификаторам, к грамматике запроса, и параметр на их месте не сработает:
имя таблицы СУБД не примет, а ORDER BY $1 молча отсортирует по константе, то есть
никак. LIMIT $1 и OFFSET $2, наоборот, обычные значения.
В зрелых проектах реальные инъекции чаще всего находят именно тут: везде параметризовано, а
сортировка склеена. Рабочее решение одно: маппинг из белого списка. Клиент присылает
ключ, а сервер превращает его в заранее написанный фрагмент SQL.
// Клиент присылает sort=created_at&order=desc, но в SQL уезжает только то,
// что мы написали сами. Строки пользователя в тексте запроса нет.
var sortable = map[string]string{
"created_at": "p.created_at",
"title": "p.title",
"views": "p.views_count",
}
func orderClause(q url.Values) (string, error) {
col, ok := sortable[q.Get("sort")]
if !ok {
if q.Get("sort") == "" {
return "p.created_at DESC, p.id DESC", nil // детерминированный дефолт
}
return "", fmt.Errorf("%w: неизвестное поле сортировки", ErrValidation)
}
dir := "ASC"
if strings.EqualFold(q.Get("order"), "desc") {
dir = "DESC"
}
// tie-breaker по id обязателен, иначе keyset-пагинация «дребезжит»
return col + " " + dir + ", p.id " + dir, nil
}
Бывает, что без динамического имени таблицы или схемы не обойтись (мультитенантность
«схема на клиента», админский конструктор отчётов). Тогда идентификатор не склеивают,
а цитируют библиотечной функцией и всё равно проверяют по списку:
pgx.Identifier{schema, table}.Sanitize() в pgx или
quote_ident() на стороне PostgreSQL. Список существующих тенантов проверяй
заранее - цитирование спасает от синтаксической инъекции, но не от «покажи мне
схему чужого клиента».
IN (...)
Склеивать любят и IN с переменным
числом элементов. В Go для него есть два безопасных способа. Можно сгенерировать нужное количество
плейсхолдеров ($1,$2,…$N) в цикле - список формирует код, а значения всё равно уходят
аргументами. Или передать массив одним параметром:
WHERE id = ANY($1) с pq.Array(ids) или срезом в pgx. Второй вариант
лучше, потому что не плодит уникальных текстов запроса и не выбивает кэш планов.
Что ещё говорят про инъекции на собеседовании
- ORM сама по себе не защищает. Склеить строку можно и в GORM:
Where("name = '" + s + "'"),Raw,Exec, любой конструктор «сырых» фрагментов. Защищает плейсхолдер, а не ORM. - Least privilege у роли БД. Приложение ходит под ролью без
DROP, безCREATE, без доступа к чужим схемам и без суперправ. Тогда даже успешная инъекция прочитает только то, что и так доступно сервису. Для миграций заводят отдельную роль. - Ошибки наружу не отдавать. Текст ошибки СУБД подсказывает атакующему имя
таблицы, тип колонки, версию сервера. Клиент получает обезличенный
500/400иtrace_id, детали уходят в лог. - Инъекции бывают и помимо SQL. Тот же класс: NoSQL (оператор
$neв JSON-фильтре Mongo), LDAP, command injection черезexec.Command("sh", "-c", user), шаблонизаторы, XPath, заголовки писем. Ответ везде один: не собирать команду строкой. В Go с процессами это особенно просто:exec.Command(name, args...)вообще не запускает shell, если явно не позватьsh -c. - Обнаружение: линтеры (
gosec, правила G201/G202 про конкатенацию в SQL; самgo vetтакого не ловит), запретfmt.Sprintfрядом сdb.Queryна ревью, WAF как второй эшелон, а не как первый.
XSS: что из этого реально может сделать бэкенд
XSS тоже инъекция, только в HTML: строка пользователя оказывается в разметке, и браузер
исполняет её как код. Исполняет у другого пользователя и в контексте нашего origin,
то есть с доступом ко всему, что там есть: DOM, localStorage, куки без
httpOnly. И, что хуже всего, скрипт может слать запросы к нашему же API
от имени жертвы, уже с её сессией.
| Тип | Где живёт полезная нагрузка | Кто чинит |
|---|---|---|
| Stored (persistent) | в нашей БД: комментарий, имя профиля, название файла; отдаётся всем, кто откроет страницу | бэкенд - на выводе; самый опасный тип |
| Reflected | в самом запросе: ?q=<script>…, сервер вернул это в HTML ответа | бэкенд - на выводе |
| DOM-based | сервер отдал чистый HTML, но JS сам записал данные из location.hash в innerHTML | фронтенд; бэкенд помогает только CSP |
Отсюда и ответ на вопрос «что тут делает бэкенд». Если фронт написан как SPA, а бэкенд отдаёт
только JSON, классический XSS чинится на фронте: React/Vue экранируют по умолчанию, и дыра
появляется через dangerouslySetInnerHTML/v-html. Но у бэкенда
остаётся четыре рычага, и их надо назвать.
1. Экранирование при рендере - если рендерим мы
В Go разница между двумя пакетами стоит инцидента. text/template подставляет
строку как есть. html/template учитывает контекст: парсит шаблон, понимает,
куда попадает значение (в текст, в атрибут, в href, внутрь
<script>, в CSS), и в каждом месте экранирует по-своему.
Контекст нужен потому, что экранировать < и > мало,
если значение попадает в атрибут без кавычек или в URL со схемой
javascript:.
import "html/template" // не text/template
var page = template.Must(template.New("p").Parse(`
<p>Привет, {{.Name}}</p> <!-- HTML-контекст -->
<a href="{{.Site}}">сайт</a> <!-- URL-контекст -->
<script>var u = {{.Name}};</script> <!-- JS-контекст -->
`))
// Name = `<img src=x onerror=alert(1)>` → в HTML станет <img …>,
// а в <script> станет корректным JS-литералом "\u003cimg src=x …\u003e".
// Site = `javascript:alert(1)` → html/template подставит #ZgotmplZ.
Обратная сторона: тип template.HTML отключает экранирование. Нужен он в одном
случае: когда вставляем HTML, которому доверяем. Пользовательскому HTML доверять нельзя
никогда. Если продукту нужно форматирование (комментарии с разметкой), такой HTML пропускают через
санитайзер с белым списком тегов и атрибутов -
bluemonday в Go. Чёрный список (вырезать <script>) не
работает, обходов десятки: <img onerror>,
<svg onload>, <iframe srcdoc>, экзотические кодировки,
<scr<script>ipt> для наивной однопроходной замены.
2. Content-Type и X-Content-Type-Options
Браузер решает, исполнять ли содержимое как HTML, по заголовку Content-Type.
Если его нет или он неправильный, срабатывает MIME-sniffing: браузер угадывает по
первым байтам. Загруженный «аватар», который на самом деле начинается с
<html><script> и отдаётся без Content-Type, вполне может исполниться
как страница на нашем домене. Отсюда два обязательных пункта:
- всегда выставлять точный
Content-Type-application/json; charset=utf-8для API, реальный тип для файлов, а для скачиваемого пользовательского контента ещё иContent-Disposition: attachment; X-Content-Type-Options: nosniff, который запрещает угадывание. Один заголовок закрывает целый класс атак.
Мера посерьёзнее: отдавать пользовательские файлы с другого домена
(usercontent.example.net, не поддомен основного). Тогда даже скрипт, который там
исполнился, окажется в чужом origin и до сессии на основном домене не дотянется.
3. JSON-эндпоинт как вектор - почему это не паранойя
На фразе «У нас только JSON, XSS невозможен» любят ловить. Превратить JSON-ручку в XSS можно несколькими способами:
- Неверный Content-Type. Тот же JSON, отданный как
text/html(или вообще без типа, с включённым sniffing), исполняется. Хватит того, что в теле есть отражённое значение из запроса:{"error":"неизвестный параметр <script>…</script>"}. Классический сценарий: обработчик ошибок отражаетerr.Error()в ответ. В Gohttp.Errorтут безопасен: он ставитtext/plainиX-Content-Type-Options: nosniff. Опасен самописный хелпер безContent-Type: тип тогда угадываетnet/httpпо первым байтам, и<script>в начале тела сделает ответtext/html. - Отражение в ответе плюс скачивание. Если ручка отдаёт файл с именем, взятым из запроса, и браузер открывает его как документ, контекст снова становится HTML.
- JSONP, легаси-механизм обхода SOP: ответ оборачивается в вызов функции, имя
которой пришло в query. Выходит сразу XSS (имя коллбэка попадает в исполняемый код)
и полный обход CORS, потому что
<script src>уходит куда угодно и с куками. Ответ простой: JSONP не использовать, для кросс-домена есть CORS. - Ответ, который потом кладут в DOM. Даже идеальный JSON становится XSS, если фронт
вставит поле через
innerHTML. Формально виноват фронт, но проектная вина общая: не хранить в БД то, что не прошло валидацию, и не отдавать HTML там, где по контракту текст.
// Ответ API: тип выставляем явно и до записи тела, sniffing запрещаем.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status) // строго после Header().Set
enc := json.NewEncoder(w)
enc.SetEscapeHTML(true) // дефолт: < > & → \u003c \u003e \u0026
if err := enc.Encode(v); err != nil {
// статус и заголовки уже ушли, поменять их нельзя: только логируем
slog.Error("encode response", "err", err)
}
}
Про SetEscapeHTML: по умолчанию encoding/json
экранирует <, > и & в юникод-последовательности,
чтобы JSON можно было безопасно встроить в HTML. Иногда это отключают ради читаемости -
и заодно убирают одну из подстраховок.
4. CSP - второй эшелон
Заголовком Content-Security-Policy сервер сообщает браузеру, откуда странице можно грузить скрипты, стили и картинки и разрешён ли инлайн. Экранирование CSP не заменяет, а страхует: если XSS всё-таки просочился, полезная нагрузка не исполнится или не сможет утечь наружу.
# Строгая политика для отрендеренной сервером страницы: nonce на каждый запрос,
# никакого 'unsafe-inline', запрет фреймов и base-тега.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
connect-src 'self' https://api.example.com;
object-src 'none'; base-uri 'none'; frame-ancestors 'none';
form-action 'self'
# Чистому JSON-API хватит жёсткого минимума, страниц там нет:
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; sandbox
'unsafe-inline'вscript-srcпочти полностью обесценивает CSP - XSS как раз и исполняется инлайном. Вместо него ставят nonce или хеш скрипта.- Внедрять политику разумно через
Content-Security-Policy-Report-Onlyплюсreport-to: собрать нарушения на проде, вычистить, потом включить в блокирующем режиме. frame-ancestors 'none'пришёл на сменуX-Frame-Options: DENYи защищает от clickjacking. Старый заголовок оставляют для совместимости.
httpOnly-кука действительно не читается из JS, и это
правильный дефолт. Но говорить, что она «защищает от XSS», нельзя: скрипт на
нашем origin может просто слать запросы - куку браузер приложит сам. Токен не украден,
зато перевод денег выполнен. httpOnly снижает ущерб (нельзя унести сессию на
другой хост и пользоваться ей месяц), но при XSS всё, что доступно пользователю, доступно и
атакующему.
CSRF: браузер отправляет запрос за пользователя
Cross-Site Request Forgery держится на автоматическом прикреплении учётных данных. Браузер прикладывает куки к запросу, глядя на адрес назначения, а не на того, кто этот запрос инициировал. Значит, чужая страница может заставить браузер жертвы сходить на наш домен - и запрос придёт полностью аутентифицированным.
Прочитать ответ злоумышленник при этом не может. Same-Origin Policy не даст чужому скрипту увидеть тело, статус или заголовки нашего ответа. Поэтому CSRF бьёт по изменяющим операциям: перевод денег, смена email, смена пароля, удаление, выдача прав. Это «слепой» вызов, результат которого атакующему не нужен.
SameSite меняет именно это правило, и
кука перестаёт быть «автоматическим предъявителем» для запросов, которые инициировал чужой сайт.
Токен в заголовке Authorization в эту схему вообще не попадает - его никто не
проставляет автоматически, и шаг 3 приходит анонимным.Почему кука уязвима, а Authorization: Bearer - нет
Всё решает то, кто прикрепляет учётные данные. Куку прикрепляет браузер по
правилу «домен совпал - приложил», и инициатор запроса ему при этом не важен (без
SameSite). Заголовок Authorization проставляет наш JavaScript,
который живёт на нашем origin. Чужая страница до него не дотянется - не прочитает токен из
localStorage нашего домена и не заставит браузер добавить заголовок.
К тому же любой запрос с кастомным заголовком перестаёт быть «простым» и
требует CORS-preflight: браузер сначала спросит у сервера разрешения, чужому
Origin мы его не дадим, и настоящий запрос просто не уйдёт.
- Куки как транспорт сессии (классическое веб-приложение, SSR, BFF) → нужна защита
от CSRF:
SameSite, поверх него штатныйhttp.CrossOriginProtection(Go 1.25, разбор ниже), а токен оставляем для критичных операций и для браузеров без Fetch metadata. - Токен в заголовке (SPA на другом домене, мобильное приложение, межсервисные вызовы) → CSRF структурно невозможен, отдельная защита не нужна. Зато дороже обходится XSS - токен лежит там, куда дотягивается JS.
- Гибрид «кука + токен в заголовке» (кука хранит, JS дублирует в заголовок) называется double submit, см. ниже.
SameSite в деталях
| Значение | Когда кука прикрепляется к cross-site запросу | Практика |
|---|---|---|
Strict | никогда | для refresh-куки и админок. Побочка: перешёл по ссылке из почты - сайт встретил гостем, потому что на первой навигации куки нет |
Lax | только на top-level навигацию «безопасным» методом (клик по ссылке, GET). Не прикрепляется к POST, fetch, iframe, img, XHR | рабочий дефолт для сессионной куки. Без явного атрибута его применяют Chrome и другие браузеры на Chromium, Firefox и Safari - нет, так что ставь явно |
None | всегда | требует Secure. Нужен для честно кросс-доменных сценариев (виджет, SSO, SPA на другом домене) - и возвращает необходимость CSRF-токена |
- «Same-site» ≠ «same-origin». Сравнивается registrable domain (eTLD+1), а не полный
origin:
app.example.comиapi.example.comсчитаются одним site, схема тоже учитывается. ПоэтомуSameSiteне спасает от атаки с захваченного соседнего поддомена. - Lax защищает не полностью. Форму и
fetchон закрывает, а изменяющую операцию, повешенную наGET, нет. Если у нас естьGET /account/delete, атака сводится к простой ссылке или редиректу: top-level навигациюGET-ом Lax пропускает. А где кука безSameSiteи браузер не ставит Lax сам, хватит и<img src=…>. Отсюда прямая связь: семантика методов тоже защищает от CSRF. - Старые браузеры атрибут игнорируют, поэтому для по-настоящему критичных операций
SameSiteостаётся первым эшелоном, но не единственным.
CSRF-токены: два рабочих паттерна
Идея у обоих одна: потребовать от запроса значение, которое чужой сайт узнать не может. Читать наш ответ он не умеет (SOP), так что любой секрет, выданный на странице, ему недоступен.
- Synchronizer token. Сервер генерирует случайный токен, кладёт его в состояние сессии и отдаёт в HTML (скрытое поле формы или мета-тег), а на изменяющем запросе сверяет пришедшее значение с сохранённым. Надёжнее некуда, но нужно состояние на сервере.
- Double submit cookie обходится без состояния: сервер кладёт случайное значение в отдельную
не-
httpOnlyкуку, фронт читает её из JS и дублирует в заголовокX-CSRF-Token, а сервер сравнивает куку и заголовок. Схема работает, потому что чужой сайт не может прочитать куку и тем более проставить заголовок. Слабое место: захваченный поддомен может записать куку на родительский домен. Поэтому в проде берут signed double submit - значение куки привязано к идентификатору сессии через HMAC, и подставить произвольную пару не выйдет.
// Middleware: пропускаем безопасные методы, для остальных сверяем токен.
var safe = map[string]bool{http.MethodGet: true, http.MethodHead: true, http.MethodOptions: true}
func CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if safe[r.Method] {
next.ServeHTTP(w, r)
return
}
// CSRF грозит только сессии в куке. Bearer-запросы пропускаем.
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
next.ServeHTTP(w, r)
return
}
c, err := r.Cookie("csrf_token")
hdr := r.Header.Get("X-CSRF-Token")
// Сравниваем за постоянное время, иначе токен подберут побайтово.
if err != nil || hdr == "" ||
subtle.ConstantTimeCompare([]byte(c.Value), []byte(hdr)) != 1 {
problem(w, http.StatusForbidden, "csrf_failed", "CSRF-токен не совпал")
return
}
next.ServeHTTP(w, r)
})
}
// Кука с токеном: httpOnly=false, иначе наш же JS её не прочитает.
http.SetCookie(w, &http.Cookie{
Name: "csrf_token", Value: randomToken(32),
Path: "/", Secure: true, HttpOnly: false, SameSite: http.SameSiteLaxMode,
})
Go 1.25: http.CrossOriginProtection - защита без токенов и кук
Выше была классика: своё состояние, своя кука, свой заголовок, свой код на фронте.
В Go 1.25 в net/http появился CrossOriginProtection, готовый
middleware из стандартной библиотеки, который решает ту же задачу вообще без токенов.
Из-за него сдвинулась рекомендация по умолчанию, и на собесе это хороший свежий
ответ: «для современных браузеров хватает штатного middleware, токены остаются страховкой».
Механика держится на Fetch metadata - служебных заголовках, которые к каждому запросу
на HTTPS (и на localhost) добавляет сам браузер. Страница-инициатор не может их ни подделать, ни убрать
(в отличие от Referer, который вырезается политикой, и от кастомных заголовков,
которые ставит JS). Главный из них, Sec-Fetch-Site, отвечает как раз на тот
вопрос, которого не хватало серверу: откуда запрос инициирован. Значения:
same-origin, same-site, cross-site и
none (пользователь сам ввёл адрес или открыл закладку). Само правило умещается в
строку: небезопасный метод при Sec-Fetch-Site, не равном
same-origin или none, получает 403. Если заголовка
нет вовсе, в ход идёт запасной вариант: хост из Origin сверяется с
Host, и при несовпадении запрос тоже отклоняется.
cop := http.NewCrossOriginProtection()
// Свой фронт на другом origin добавляем в белый список.
_ = cop.AddTrustedOrigin("https://admin.example.com")
// Ручки, куда браузер не ходит (вебхуки платёжки, колбэки), пускаем в обход.
cop.AddInsecureBypassPattern("/webhooks/")
// Дефолтный отказ: 403 с текстом. Приводим к своему формату ошибок.
cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
problem(w, http.StatusForbidden, "cross_origin_denied", "межсайтовый запрос отклонён")
}))
srv := &http.Server{Addr: ":8080", Handler: cop.Handler(mux)}
// Если решение нужно принимать самому (например, логировать, но пропускать на канарейке):
if err := cop.Check(r); err != nil { /* это межсайтовый небезопасный запрос */ }
Полная таблица решений (проверено запуском на go1.27.0, POST на bank.example):
Sec-Fetch-Site: cross-site -> 403 "cross-origin request detected from Sec-Fetch-Site header"
Sec-Fetch-Site: same-site (соседний поддомен) -> 403 тоже отказ: проверка по ORIGIN, а не по site
Sec-Fetch-Site: same-origin -> 200
Sec-Fetch-Site: none (адрес ввели руками) -> 200
нет Sec-Fetch-Site и нет Origin -> 200 считаем, что это не браузер: curl, мобилка, сервис
нет Sec-Fetch-Site, Origin != Host -> 403 "...and/or browser is out of date"
cross-site, но Origin в AddTrustedOrigin -> 200
cross-site, но путь в AddInsecureBypassPattern -> 200
GET / HEAD / OPTIONS при любом Sec-Fetch-Site -> 200 безопасные методы не проверяются вообще
CrossOriginProtection закрывает, а что нет
- Запрос без Fetch metadata проходит насквозь. Если нет ни
Sec-Fetch-Site, ниOrigin, запрос считается небраузерным, и его пропускают. Компромисс осознанный: иначе сломались бы все мобильные клиенты, серверные интеграции иcurl. Платит за него браузер, который не шлёт ни одного из двух заголовков (совсем старый или экзотический): он получает нулевую защиту. Браузер безSec-Fetch-Site, но сOriginGo проверит запасным способом, поHost. Если у тебя банк или админка, где нужно покрыть и такие браузеры, synchronizer token остаётся обязательным. GET,HEAD,OPTIONSпропускаются всегда - по определению, как безопасные методы. Изменяющая операция наGETтак и остаётся дырой. Ещё один довод соблюдать семантику методов.- Он не заменяет
SameSite, а работает вторым эшелоном - и в одном месте строже.SameSiteне даёт куке уехать,CrossOriginProtectionотбивает уже дошедший запрос. В имени стоит cross-origin, а не cross-site, и это не оговорка -Sec-Fetch-Site: same-siteон тоже отвергает. Значит, он закрывает ту самую дыру, в которую проваливаетсяSameSite: атаку с захваченного поддомена, для которого запрос формально same-site. Платишь за это тем, что собственный фронт на соседнем поддомене придётся явно внести черезAddTrustedOrigin. - Это не CORS. CORS решает, отдать ли ответ чужому скрипту;
CrossOriginProtectionрешает, выполнять ли запрос. Разные задачи, и одно другое не заменяет.
«Механика прежняя: браузер прикладывает куку по адресу назначения, поэтому чужая страница
может выполнить изменяющую операцию от имени пользователя. Базой служит
SameSite=Lax плюс аккуратная семантика методов. Дальше, если сессия в куке, беру
http.CrossOriginProtection из стандартной библиотеки. Он штатный с Go 1.25
и режет межсайтовые небезопасные запросы по Sec-Fetch-Site без
состояния, без токенов и без правок на фронте. Токен (signed double submit или synchronizer)
оставляю для браузеров без Fetch metadata и для по-настоящему опасных
операций - на них же добавляю повторный ввод пароля или 2FA. Для API на
Authorization: Bearer CSRF структурно невозможен».
- Проверка
Origin/Sec-Fetch-Site. Современные браузеры шлютOriginна всех изменяющих запросах, аSec-Fetch-Site: cross-siteпрямо сообщает намерение. Линия дешёвая, но не-браузерный клиент её подделает, поэтому она идёт в дополнение к токену, а не вместо него. Руками её писать больше не надо: именно эту проверку и оформили вhttp.CrossOriginProtection- см. выше. - Требовать
Content-Type: application/json. HTML-форма умеет отправить толькоform-urlencoded,multipartиtext/plain; JSON без preflight она отправить не может. Поэтому415на всё остальное работает как барьер (но не для эндпоинтов, которые принимают формы). - Повторный ввод пароля или 2FA на действительно опасных операциях: смена пароля, смена email, вывод средств. Такая проверка не ломается ни при CSRF, ни при XSS.
- Токен привязывать к сессии и ротировать при логине, иначе получишь session fixation через подставленный CSRF-токен.
SSRF: сервер идёт туда, куда ему сказал клиент
При Server-Side Request Forgery злоумышленник управляет адресом, по
которому наш сервис делает исходящий запрос. Атакующему это ценно, потому что наш сервер
стоит внутри периметра: он видит приватную сеть, служебные порты и облачные
метаданные, недоступные снаружи. Типовые точки входа: «загрузить картинку по URL», вебхуки,
импорт из внешнего источника, генерация PDF/превью по ссылке, прокси-ручка, парсинг
og:-разметки для красивого превью ссылки.
// Уязвимо: адрес полностью контролирует клиент.
func fetchAvatar(ctx context.Context, rawURL string) ([]byte, error) {
resp, err := http.Get(rawURL) // http://169.254.169.254/latest/meta-data/…
... // http://localhost:6379/ → Redis по HTTP
} // file:///etc/passwd → зависит от клиента
- Облачные метаданные. По адресу
169.254.169.254в AWS/GCP/Azure лежат временные учётные данные роли инстанса. Хватит одного удачного SSRF, и у атакующего ключи от облака. IMDSv2 (обязательный PUT-токен иX-aws-ec2-metadata-token) появился как раз поэтому. - Внутренние сервисы - админки без аутентификации «потому что они не торчат наружу», Kubernetes API, Consul, Elasticsearch, Prometheus, панель самого приложения.
- Сканирование сети. По времени ответа и коду ошибки атакующий картирует внутренние адреса и открытые порты, даже если тело ответа ему не возвращают (blind SSRF).
- Смена протокола через
file://,gopher://,dict://. Если библиотека поддерживает эти схемы, из HTTP-запроса можно собрать произвольный TCP-пакет и, например, отдать команду Redis.
Как защищаться - по убыванию надёжности
- Allowlist. В идеале сервис вообще не ходит по произвольным URL: есть список разрешённых хостов или заранее зарегистрированные интеграции, всё остальное отвергается. Только эта защита не проигрывает гонку с обходами.
- Отдельный egress-прокси. Весь исходящий трафик сервиса идёт через прокси, у которого своя политика; сам под сидит в сетевой политике, запрещающей прямые соединения во внутренние подсети. Тогда даже ошибка в коде не даёт доступа.
- Запрет приватных диапазонов на этапе соединения. Проверять надо не строку URL, а
IP, к которому реально подключается сокет. Проверка «распарсили URL → отрезолвили →
сравнили» уязвима к DNS rebinding: между проверкой и соединением DNS отдаёт другой
адрес (TOCTOU). В Go это решает хук
Controlу диалера - его вызывают уже с готовым адресом, прямо передconnect.
// Диалер, который отказывается соединяться с приватными и служебными адресами.
// Control зовут после резолва и до connect: DNS rebinding не пройдёт.
var blocked = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"), netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("::/128"), // [::] ядро соединит с loopback
}
func safeControl(network, address string, _ syscall.RawConn) error {
if network != "tcp4" && network != "tcp6" {
return fmt.Errorf("ssrf: сеть %s запрещена", network)
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip, err := netip.ParseAddr(host)
if err != nil {
return err
}
ip = ip.Unmap() // ::ffff:169.254.169.254 → 169.254.169.254
ip = ip.WithZone("") // для fe80::1%lo0 Contains всегда false
for _, p := range blocked {
if p.Contains(ip) {
return fmt.Errorf("ssrf: адрес %s запрещён", ip)
}
}
return nil
}
var safeClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 3 * time.Second, Control: safeControl}).DialContext,
},
// Редирект даёт новый URL, выбранный не нами: проверяем каждый прыжок.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("ssrf: слишком много редиректов")
}
return checkScheme(req.URL) // только http/https, порт из разрешённых
},
}
- Редиректы. Внешний хост из allowlist отвечает
302наhttp://169.254.169.254/- и проверка, сделанная один раз на входе, обойдена. Валидировать надо каждый прыжок (или запретить редиректы вовсе). ХукControlтем и хорош, что срабатывает и на редиректах. - Обходы парсинга URL.
http://127.0.0.1,http://[::1],http://2130706433(десятичная запись),http://0177.0.0.1(восьмеричная),http://localtest.me(публичный домен, резолвящийся в 127.0.0.1),http://user@evil.com@internal. Поэтому проверка по строке заведомо проигрывает, а проверка по итоговомуnetip.Addrнет. - Ответ наружу. Не возвращать клиенту тело, статус и время ответа стороннего ресурса как есть, иначе даже «безопасный» фетч превращается в сканер. Отдавать своё обезличенное сообщение.
- Ограничить размер и время. Таймаут,
io.LimitReader, проверкаContent-Type. Без них SSRF заодно становится DoS против нас самих.
IDOR и broken access control - самая частая реальная дыра
В OWASP Top 10 первое место (A01) занимает не инъекция, а сломанный контроль доступа. Причина простая: инъекцию ловит линтер, и выглядит она везде одинаково, а «этот заказ принадлежит этому пользователю» остаётся бизнес-правилом. Его пишут руками в каждом обработчике, и достаточно один раз забыть.
Самый узнаваемый частный случай называется IDOR: идентификатор объекта приходит из запроса и используется без проверки прав на этот конкретный объект.
// Уязвимо: middleware проверил, что пользователь аутентифицирован,
// но никто не проверил, что заказ принадлежит именно ему.
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
order, err := h.repo.GetByID(r.Context(), id) // ← /v1/orders/1235 вместо 1234
...
}
// Правильно: владение проверяется в самом запросе, отдельную проверку
// легко забыть. Условие уезжает в WHERE.
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
uid := auth.UserID(r.Context())
order, err := h.repo.GetForUser(r.Context(), id, uid) // WHERE id=$1 AND user_id=$2
if errors.Is(err, sql.ErrNoRows) {
problem(w, http.StatusNotFound, "not_found", "Заказ не найден")
return
}
...
}
- Проверять владение в том же запросе к БД, а не отдельным
ifпосле чтения. Так забыть проверку структурно труднее, и гонок нет. Для сложных правил пишут явную функциюcan(ctx, subject, action, object)и вызывают её в сервисном слое, где объект уже известен. - Отдавать
404, а не403, если даже факт существования объекта выдаёт информацию.403подтверждает, что заказ с таким id есть. - Непредсказуемые идентификаторы смягчают риск, но не защищают. UUIDv4 вместо автоинкремента убирает тривиальный перебор, но id утекают в логи, в реферер, в шаринг ссылок. «Секретный URL» работает как контроль доступа только там, где так задумано (одноразовые ссылки с TTL).
- Массовые операции и вложенные ресурсы.
PATCH /v1/ordersсо списком id,GET /v1/users/{uid}/orders/{oid}, экспорт в CSV, GraphQL-резолвер вложенного поля: здесь проверку владения забывают чаще всего, потому что она нужна для каждого элемента. - Fail closed. По умолчанию роутер требует авторизацию, а публичные маршруты помечаются явно. Полезный тест: пройтись по всем зарегистрированным маршрутам и убедиться, что у каждого есть политика, иначе новый эндпоинт однажды приедет голым.
- Функциональный уровень. Во втором частом случае защищать надо не объект, а действие:
/admin/*открыт без роли, потому что «ссылки на него нет в интерфейсе». Скрытая кнопка доступ не контролирует.
Mass assignment: почему входной JSON не биндят в доменную модель
Соблазн понятен: структура уже есть, теги json тоже, зачем писать вторую.
Но json.NewDecoder(r.Body).Decode заполняет те поля, которые прислали, а
прислать могут любые - включая те, о которых клиент знать не должен.
// Доменная модель, она же модель БД, она же тело запроса. Так делать нельзя.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"` // ← {"is_admin": true}
Balance int64 `json:"balance"` // ← {"balance": 999999}
CreatedAt time.Time `json:"created_at"`
}
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
var u User
json.NewDecoder(r.Body).Decode(&u) // клиент задаёт любое поле
h.repo.Update(r.Context(), &u) // и оно уезжает в UPDATE
}
Правильно заводить отдельный тип на каждое направление: DTO запроса, доменная модель, DTO ответа. Это явное описание контракта, а не «лишний слой ради слоя»: какие поля клиент может задать и какие мы готовы показать.
// Клиенту разрешено прислать ровно два поля.
type updateProfileReq struct {
DisplayName string `json:"display_name" validate:"required,min=1,max=64"`
Bio string `json:"bio" validate:"max=500"`
}
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
var req updateProfileReq
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
dec.DisallowUnknownFields() // "is_admin" → явная ошибка 400
if err := dec.Decode(&req); err != nil {
problem(w, http.StatusBadRequest, "malformed_json", "Тело запроса неразборчиво")
return
}
if err := h.validate.Struct(req); err != nil { ... }
// Из DTO в домен поля переносим явно. Про забытое новое поле компилятор
// не напомнит: это ловят тест или линтер exhaustruct.
err := h.svc.UpdateProfile(r.Context(), auth.UserID(r.Context()),
service.ProfilePatch{DisplayName: req.DisplayName, Bio: req.Bio})
...
}
DisallowUnknownFieldsпомогает, но не защищает. Она превращает лишнее поле в ошибку, что удобно при разработке, но ломает совместимость: клиент чуть новее - и вся интеграция падает. Защищает сам факт, что в DTO нет полейis_adminиbalance.- Отличить «поле не прислали» от «прислали нулевое значение». Для
PATCHэто принципиально:bio: ""значит «очистить», а отсутствие поля значит «не трогать». Решается указателями (*string), обёрткойOptional[T]илиjson.RawMessageс ручным разбором. - Модель ответа тоже отдельная. Иначе однажды в JSON уедет
password_hash, внутренний комментарий модератора илиdeleted_at. Пометкаjson:"-"работает, пока кто-то не добавит поле без тега. - Та же ошибка в обратную сторону: отдавать доменную модель наружу. Тогда любое внутреннее переименование поля ломает API.
CORS: что это на самом деле
Из всей секции CORS понимают хуже всего. Первым делом стоит сказать:
CORS ослабляет Same-Origin Policy в браузере и сервер ни от чего не защищает.
По умолчанию скрипт со страницы app.example.com не может
прочитать ответ от api.example.com. Через CORS сервер говорит
браузеру: «этому origin читать мой ответ можно». Запрещать CORS не умеет, только разрешать.
- От curl, Postman и любого другого не-браузера CORS не защищает. Заголовки
Access-Control-*читает только браузер. Закрывать API должны аутентификация и авторизация. - SOP блокирует чтение, отправку она пропускает. Запрос без preflight обычно уже дошёл до сервера и выполнился, браузер лишь не отдал ответ в JS. Поэтому от CSRF одна SOP не спасает: «слепая» отправка и есть CSRF.
- Origin складывается из схемы, хоста и порта.
http://x.comиhttps://x.comсчитаются разными origin, как иx.com:80сx.com:8080. Отсюда половина «а почему у меня локально не работает».
Simple request против preflight
Браузер сначала решает, «простой» ли запрос. Простой уходит сразу, а отдавать ли ответ
скрипту, браузер решает уже потом. Перед непростым он шлёт отдельный запрос
OPTIONS (preflight), и, если политика не подошла, настоящий запрос не уходит
вовсе. На практике разница такая: preflight предотвращает побочный эффект,
а simple его не останавливает.
| Simple request - уходит сразу | Preflighted - сначала OPTIONS | |
|---|---|---|
| Метод | GET, HEAD, POST | всё остальное: PUT, PATCH, DELETE |
| Content-Type | только text/plain, application/x-www-form-urlencoded, multipart/form-data | любой другой, в том числе application/json |
| Заголовки | только «безопасный список» (Accept, Accept-Language, Content-Language, Range…) | любой кастомный: Authorization, X-Request-Id, X-CSRF-Token |
| Что видит сервер | сразу боевой запрос, побочный эффект уже случился | OPTIONS, боевого запроса может не быть вовсе |
Выходит, что почти любой современный JSON-API всегда идёт с
preflight - Content-Type: application/json и заголовок
Authorization выводят запрос из «простых». Поэтому HTML-форма не подделает
JSON-запрос, если сервер строго проверяет Content-Type: форма с
text/plain умеет прислать тело, похожее на JSON. А токен в заголовке не подвержен
CSRF по другой причине: браузер не прикладывает его сам, как куку.
Allow-Origin нужен и на боевом ответе - без него браузер
скроет уже полученный ответ.Заголовки: кто за что отвечает
| Заголовок | Кто шлёт | Смысл |
|---|---|---|
Origin | браузер, на всех cross-origin запросах | кто спрашивает. Проставляется браузером, из JS не подделывается |
Access-Control-Request-Method | браузер, только в preflight | каким методом собираюсь |
Access-Control-Request-Headers | браузер, только в preflight | какие заголовки собираюсь проставить |
Access-Control-Allow-Origin | сервер, в preflight и в боевом ответе | конкретный origin либо *. Список нескольких значений не поддерживается |
Access-Control-Allow-Methods | сервер, в preflight | разрешённые методы |
Access-Control-Allow-Headers | сервер, в preflight | разрешённые к отправке заголовки запроса |
Access-Control-Allow-Credentials | сервер | true - разрешить куки и Basic. Только это значение, false писать бессмысленно |
Access-Control-Expose-Headers | сервер, в боевом ответе | какие заголовки ответа JS разрешено читать. Без него доступны лишь семь базовых - свои X-Request-Id, X-RateLimit-*, Location надо перечислять явно |
Access-Control-Max-Age | сервер, в preflight | сколько секунд кэшировать решение. Браузеры режут сверху (Chrome - 2 часа) |
Vary: Origin | сервер | сообщает кэшам, что ответ зависит от Origin |
Типичные ошибки настройки
Allow-Origin: *вместе сAllow-Credentials: true. Спецификация это прямо запрещает, и браузер отклонит ответ. Логика простая: «любой сайт» плюс «с куками пользователя» дают произвольной странице право читать личные данные жертвы. Выход один: отдавать конкретный origin.- Отражение
Originбез проверки. Классическая «починка» предыдущего пункта:w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))плюсAllow-Credentials: true. По сути это тот же*с куками, только спецификацию удалось обмануть: любой сайт получает право читать ответы с сессией жертвы. Одна из самых частых находок на пентесте. - Слабое сравнение origin.
strings.HasPrefix(o, "https://example.com")пропускаетhttps://example.com.evil.net, аstrings.Contains(o, "example.com")пропустит ещё иhttps://evil-example.com. Сравнивать нужно полный origin, на точное совпадение с заранее заданным множеством. - Забытый
Vary: Origin. Если ответ кэшируется (CDN, обратный прокси, кэш браузера), безVaryответ сAllow-Origin: https://a.example.comуйдёт клиенту с originb.example.com- и наоборот. Смотря в какую сторону промахнулся кэш, получится либо неработающий фронт, либо утечка разрешения. Ставить его надо на всех ответах, гдеAllow-Originвычисляется динамически. nullв белом списке. Originnullприсылают песочницыiframe sandbox, локальные файлы и некоторые редиректы, так что разрешить его значит впустить кого угодно.- Preflight не доходит до обработчика. На
OPTIONSнужно отвечать до аутентификации: браузер не прикладывает к нему ни кук, ниAuthorization, поэтому auth-middleware вернёт401, и весь запрос упадёт. Выглядит это как «CORS-ошибка, хотя всё настроено». - CORS вместо авторизации. Для публичного API разрешить всё (
*без credentials) совершенно нормально. Ошибка в другом: считать API «закрытым», раз в CORS перечислены только свои домены. См. первый пункт про curl.
// Аккуратный CORS-middleware: точное сравнение, Vary всегда, OPTIONS до auth.
func CORS(allowed map[string]bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Vary ставим всегда, даже когда origin не разрешён и заголовков не будет:
// иначе кэш смешает ответы для разных origin.
w.Header().Add("Vary", "Origin")
if origin != "" && allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin) // конкретный, не *
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Expose-Headers",
"X-Request-Id, X-RateLimit-Remaining, Retry-After")
}
if r.Method == http.MethodOptions &&
r.Header.Get("Access-Control-Request-Method") != "" {
w.Header().Add("Vary", "Access-Control-Request-Method")
w.Header().Add("Vary", "Access-Control-Request-Headers")
if origin != "" && allowed[origin] {
w.Header().Set("Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE")
w.Header().Set("Access-Control-Allow-Headers",
"Authorization, Content-Type, X-Request-Id, Idempotency-Key")
w.Header().Set("Access-Control-Max-Age", "600")
}
w.WriteHeader(http.StatusNoContent) // отвечаем до auth-middleware
return
}
next.ServeHTTP(w, r)
})
}
}
Rate limiting
У ограничения частоты несколько задач, и на собеседовании их полезно развести. Оно не даёт перебирать пароли и токены, мешает скрейпить и выкачивать базу, спасает от случайного DoS собственным клиентом с ретраями, честно делит ёмкость между тенантами и, наконец, бережёт деньги, если за нами платный внешний API. От «настоящего» DDoS на канал приложение не защитит - это уровень CDN и провайдера.
Все алгоритмы решают одно: пропустить запрос или отклонить. Различаются они на всплеске, и проще всего это увидеть, если прогнать через все четыре один и тот же трафик.
В двух из четырёх названий стоит слово bucket - «ведро». Это метафора счётчика, и она честная ровно до дна. Token bucket: в ведро фиксированной ёмкости с постоянной скоростью капают разрешения (их и называют «токенами», к токенам аутентификации из главы 10.3 они отношения не имеют). Каждый запрос забирает одно разрешение; ведро пусто - отказ. Пока клиент молчал, ведро наполнялось - отсюда и право на всплеск размером ровно в ёмкость ведра. Leaky bucket: тут ведро наполняют сами запросы, а вытекает оно с постоянной скоростью, поэтому на выходе трафик всегда идеально ровный; переполнилось ведро - отказ. Физически «ведро» хранится в Redis как два числа: сколько в нём сейчас и когда его трогали в прошлый раз.
| Алгоритм | Как устроен | Память на ключ | Всплески | Где уместен |
|---|---|---|---|---|
| Fixed window | INCR ключа rl:{user}:{minute} с TTL | O(1), одна команда | до 2× лимита на стыке окон | грубые лимиты, где двукратный перелёт неважен; самый дешёвый |
| Sliding window log | zset с меткой времени каждого запроса; чистим старое, считаем оставшееся | O(лимит) - запись на запрос | нет, лимит точен на любом интервале | небольшие лимиты, где важна честность (логин, оплата) |
| Sliding window counter | два счётчика (текущее и прошлое окно), взвешенная сумма по доле окна | O(1), два числа | погрешность единицы процентов | рабочий компромисс для больших лимитов; так делает Cloudflare |
| Token bucket | ведро ёмкости B, доливается со скоростью r; запрос забирает токен | O(1): токены + время последнего долива | разрешает всплеск до B - намеренно | дефолт для API: средняя скорость плюс явный бюджет на burst |
| Leaky bucket | очередь фиксированного размера, из которой запросы вытекают с постоянной скоростью | O(размер очереди) | сглаживает: на выходе всплесков нет | защита хрупкого downstream, шейпинг очередей, отправка в сторонний API с жёстким лимитом |
- Token bucket и leaky bucket не синонимы. Их часто называют «одним и тем же наоборот», но ведут они себя по-разному. Token bucket отвечает мгновенно «да/нет» и разрешает накопленный всплеск. Leaky bucket ставит в очередь и выпускает ровно - всплесков на выходе нет вообще, зато появляется задержка. Первый отвечает за допуск, второй за шейпинг. Есть и вариант leaky bucket «as a meter»: лишнее он сразу отвергает, без очереди, и тогда ведёт себя как token bucket с ёмкостью, равной размеру очереди.
- Sliding window counter считает приблизительно. Он предполагает, что запросы в прошлом окне шли равномерно, поэтому может отклонить легальный запрос или пропустить лишний. На больших лимитах ошибка теряется в шуме, а на лимите «5 попыток входа» уже заметна.
Локальный лимитер: golang.org/x/time/rate
Готовый token bucket лежит в стандартном расширении. Конструктор
rate.NewLimiter(r, b) принимает скорость долива и ёмкость, те самые две
ручки из таблицы выше. Спросить его о решении можно тремя способами, и путать их не стоит:
// r - токенов в секунду, b - ёмкость ведра (максимальный всплеск).
lim := rate.NewLimiter(rate.Every(time.Second/10), 20) // 10 rps, burst 20
lim.Allow() // не ждать: true/false сразу, для HTTP-хендлера
lim.Wait(ctx) // ждать токен до дедлайна ctx, для фонового воркера
lim.Reserve() // забронировать и узнать, сколько ждать: r.Delay()
В HTTP почти всегда нужен Allow. Wait внутри хендлера копит
ждущие горутины, а за ними и таймауты: вместо быстрого
429 клиент получает зависший запрос. Wait хорош там, где мы сами
исходящая сторона, например когда обходим сторонний API с лимитом 100 rps.
// Лимитер на ключ с вытеснением: без уборки map растёт бесконечно, это утечка.
type Limiters struct {
mu sync.Mutex
m map[string]*entry
r rate.Limit
b int
}
type entry struct {
lim *rate.Limiter
seen time.Time
}
func (l *Limiters) Get(key string) *rate.Limiter {
l.mu.Lock()
defer l.mu.Unlock()
e, ok := l.m[key]
if !ok {
e = &entry{lim: rate.NewLimiter(l.r, l.b)}
l.m[key] = e
}
e.seen = time.Now()
return e.lim
}
// Отдельная горутина раз в минуту выкидывает ключи, не встречавшиеся 10 минут.
// Или LRU фиксированного размера: память под нагрузкой предсказуема.
func RateLimit(l *Limiters, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !l.Get(keyOf(r)).Allow() {
w.Header().Set("Retry-After", "1")
problem(w, http.StatusTooManyRequests, "rate_limited",
"Слишком много запросов, повторите позже")
return
}
next.ServeHTTP(w, r)
})
}
}
Он считает на инстанс. При 10 подах лимит «100 rps» превращается в 1000 rps суммарно, и цифра плавает при каждом автоскейлинге. Делить лимит на число реплик плохо: балансировщик распределяет неравномерно, а при выкатке число подов временно другое. Локальный лимитер честно решает две задачи: защищает сам процесс (не даёт одному клиенту съесть все горутины и коннекты к БД) и грубо отсекает лишнее. Для точного лимита на пользователя нужно общее состояние.
Распределённый лимитер на Redis
Когда лимит должен быть общим для всех инстансов, состояние переезжает в общее хранилище. Redis тут хорош по двум причинам. Он быстрый, а лимитер стоит на горячем пути каждого запроса. И он однопоточный, поэтому Lua-скрипт выполняется атомарно - между чтением счётчика и его записью никакой другой под не вклинится.
// Token bucket в Redis: одно ведро на ключ, весь read-modify-write внутри Lua.
// KEYS[1] - ключ ведра. ARGV: now_ms, rate (токенов в секунду), burst, cost.
var tokenBucket = redis.NewScript(`
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local burst = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local st = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(st[1])
local ts = tonumber(st[2])
if tokens == nil then -- первое обращение: ведро полное
tokens = burst
ts = now
end
-- долив за прошедшее время, с потолком в ёмкость ведра
local delta = math.max(0, now - ts) / 1000.0
tokens = math.min(burst, tokens + delta * rate)
ts = now
local allowed = 0
local retry = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
-- сколько миллисекунд ждать, пока накапает недостающее
retry = math.ceil(((cost - tokens) / rate) * 1000)
end
redis.call('HSET', key, 'tokens', tokens, 'ts', ts)
-- TTL с запасом на полное восстановление ведра: без него ключи копятся вечно
redis.call('PEXPIRE', key, math.ceil((burst / rate) * 1000) + 1000)
return { allowed, math.floor(tokens), retry }
`)
type Decision struct {
Allowed bool
Remaining int
RetryIn time.Duration
}
func (l *RedisLimiter) Allow(ctx context.Context, key string, cost int) (Decision, error) {
// Жёсткий таймаут: из-за лимитера запрос не должен упасть по таймауту.
ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
res, err := tokenBucket.Run(ctx, l.rdb, []string{"rl:tb:" + key},
time.Now().UnixMilli(), l.rate, l.burst, cost).Int64Slice()
if err != nil {
// fail-open: лимитер сломался, а сервис продолжает работать.
// Нужны метрика и алерт, иначе поломку никто не заметит.
l.failures.Inc()
return Decision{Allowed: true}, err
}
return Decision{
Allowed: res[0] == 1,
Remaining: int(res[1]),
RetryIn: time.Duration(res[2]) * time.Millisecond,
}, nil
}
- Время берём одно. Можно передавать в скрипт
nowс клиента, и тогда на всех подах важен NTP. А можно братьredis.call('TIME')внутри скрипта - источник единый, но скрипт становится недетерминированным (старым версиям Redis это мешало при репликации командами; современные реплицируют эффекты, так что вариант рабочий). - Все ключи скрипта в
KEYS. Иначе в Redis Cluster скрипт может обратиться к слоту на другом узле и упасть. Для нескольких ключей нужен hash tag:rl:{user:42}:minиrl:{user:42}:dayлягут в один слот. EVALSHAс фолбэком наEVAL. Скрипт кэшируется по SHA; после рестарта Redis кэш пуст, приходитNOSCRIPT, и клиент должен залить скрипт заново.go-redisсredis.NewScriptделает это сам.- Лимитер добавляет round-trip. На горячем пути это лишний поход по сети к Redis на каждый запрос. Выручает гибридная схема: локальный token bucket с «выкупленным» у Redis пакетом разрешений (батч на 10–100 запросов). Точность немного теряется, зато нагрузка на Redis падает на порядок.
- Несколько лимитов сразу. Обычно нужен не один: 10/с, 300/мин, 10 000/сутки. Проверять их надо в одном скрипте и списывать, только если прошли все, иначе счётчики разъедутся.
Что отдавать клиенту
Отказ должен быть машиночитаемым, чтобы клиент понял, что делать дальше, не разбирая текст.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 5 # секунды либо HTTP-дата
RateLimit-Policy: "default";q=100;w=60 # черновик IETF: квота 100 на 60 секунд
RateLimit: "default";r=0;t=5 # осталось 0, окно освободится через 5 с
X-RateLimit-Limit: 100 # де-факто стандарт, его понимают все SDK
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1756192846 # unix-время сброса
{"type":"https://api.example.com/errors/rate-limited",
"title":"Слишком много запросов","status":429,
"detail":"Лимит 100 запросов в минуту исчерпан","retry_after":5}
Retry-Afterобязателен. Без него клиент начнёт ретраить сразу и сделает только хуже. Тот же заголовок уместен на503при перегрузке.- Заголовки лимита отдавать и на успешных ответах, чтобы вежливый клиент
притормозил до того, как упрётся. Не забыть добавить их в
Access-Control-Expose-Headers, иначе браузерный SPA их не увидит. 429против503.429значит «конкретно ты превысил свою квоту», а503(в идеале сRetry-After) значит «плохо всему сервису». Смешивать их вредно: алерты на них настраивают по-разному, и клиенты реагируют по-разному.- Лишнего о лимитах не раскрывать. Для анонимных запросов и для эндпоинта
логина точные цифры «осталось 0 из 5» подсказывают перебиральщику ритм. Там достаточно
429иRetry-After. - Ответ 429 должен быть дешёвым. Ходить в БД, писать подробный лог на каждый отказ и рендерить тяжёлое тело незачем - иначе лимитер сам становится вектором DoS. Логировать лучше агрегатами, а метрику вести счётчиком с меткой причины.
- Со стороны клиента на
429отвечают экспоненциальным backoff с джиттером, соблюдаютRetry-Afterи ставят circuit breaker. Если вся армия клиентов ретраит без джиттера, получается синхронный «стук» ровно через N секунд.
Где ставить и по какому ключу считать
| Уровень | Что умеет | Чего не умеет |
|---|---|---|
| CDN / WAF (Cloudflare, Fastly) | отсекать объём до нашего канала, гео- и ASN-правила, защита от L7-DDoS | ничего не знает про наши сущности: тарифы, тенантов, стоимость операции |
| Edge: ingress, API-gateway, nginx | дёшево, до приложения, единая точка для всех сервисов; грубые лимиты по IP и пути | не знает, кто пользователь, пока не разобран токен; сложно завязать на бизнес-правила |
| Приложение | знает пользователя, тариф, стоимость конкретной операции, может списывать разный cost | работает уже после того, как соединение принято и токен разобран - то есть ресурсы частично потрачены |
Правильный ответ: оба уровня. И это не компромисс, у уровней просто разные задачи: периметр отсекает объём и мусор, приложение следит за бизнес-квотами. На собеседовании хорошо звучит так: «грубый лимит - как можно раньше и как можно дешевле; точный лимит - там, где есть контекст».
| Ключ | Когда | Подвох |
|---|---|---|
| Пользователь / организация | всё, что за аутентификацией | лучший ключ: не бьёт по соседям, переживает смену IP; квота может зависеть от тарифа |
API-ключ / client_id | машинные интеграции, партнёры | считать и по ключу, и по владельцу: по ключу - чтобы одна сломанная интеграция не съела квоту остальных, по владельцу - иначе партнёр обойдёт лимит, заведя десять ключей |
| IP | всё до аутентификации: логин, регистрация, восстановление пароля, публичные ручки | за одним IP сидит корпоративный NAT или целый мобильный оператор - лимит бьёт по тысячам невиновных; IPv6 надо агрегировать по /64, иначе у атакующего бесконечно много «разных» адресов |
| Составной: IP + логин, IP + эндпоинт | защита от перебора | рабочая практика: отдельный жёсткий лимит на пару «этот логин» и на «этот IP», плюс глобальный лимит на неудачные попытки |
X-Forwarded-For подделывается тривиально
Заголовок начинает клиент, а каждый прокси по пути дописывает в него своё. Если брать «первый элемент списка», атакующий пишет туда что угодно и получает новый лимит на каждый запрос, а заодно может подставить чужой IP и добиться блокировки невиновного. Правило: доверять можно только тем прокси, которые мы контролируем, и отсчитывать справа налево ровно столько шагов, сколько своих прокси стоит перед приложением.
// trustedProxies - сколько НАШИХ прокси стоит перед сервисом (обычно 1–2).
func clientIP(r *http.Request, trustedProxies int) netip.Addr {
peer, _ := netip.ParseAddrPort(r.RemoteAddr) // это подделать нельзя
if trustedProxies == 0 {
return peer.Addr()
}
// все строки заголовка: прокси может дописать свою отдельной строкой
parts := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
idx := len(parts) - trustedProxies // отсчёт СПРАВА
if idx < 0 || idx >= len(parts) {
return peer.Addr() // список короче ожидаемого - не верим
}
ip, err := netip.ParseAddr(strings.TrimSpace(parts[idx]))
if err != nil {
return peer.Addr()
}
return ip.Unmap()
}
// Альтернатива, если инфраструктура позволяет: заголовок, который наш edge
// ПЕРЕЗАПИСЫВАЕТ (не дополняет) - CF-Connecting-IP, True-Client-IP,
// либо стандартный Forwarded из RFC 7239. Всё, что пришло снаружи, стирается.
Ретраи-дубли: идемпотентность и дедупликация
Эту задачу часто путают с rate limiting, хотя она отдельная. Rate limiting защищает от слишком многих запросов, идемпотентность - от повторов одного и того же. За повторами почти никогда не стоит злой умысел: оборвался ответ, клиент словил таймаут, сработал автоматический ретрай библиотеки, пользователь дважды нажал кнопку, брокер доставил сообщение at-least-once. Сам запрос при этом выполнился - потерялся только ответ.
Ненадёжная сеть плюс ретраи гарантируют дубли. Избежать
повторов не выйдет, поэтому вопрос в том, как сделать повтор безвредным. Для этого операция
должна быть идемпотентной: по своей природе (PUT, DELETE,
«установить статус в X») или искусственно, через ключ идемпотентности.
Механика Idempotency-Key разобрана в главе 10.1. Для безопасности и надёжности в ней
важны три вещи.
- Ключ занимают до внешнего эффекта. Строку в
idempotency_keysсUNIQUEи статусомin_progressвставляют раньше, чем идут в платёжный шлюз. Тогда параллельный дубль упрётся в конфликт уникальности и вторую оплату не запустит. Порядок «сходили в банк → записали ключ» не спасает вообще: проблема живёт как раз между этими двумя шагами. - Ключ привязан к субъекту и к телу. Уникальна пара
(user_id, key), иначе через чужой ключ можно прочитать чужой сохранённый ответ. Плюс хеш тела: тот же ключ с другим телом означает ошибку клиента (422), старый ответ на него не отдают. - Сохраняют сам ответ, одного факта мало. Повтор должен получить тот же статус и то же тело, что и оригинал, иначе клиент, потерявший первый ответ, так и не узнает результата.
Есть и второй, независимый механизм: дедупликация по естественному ключу. Она нужна там, где ключ идемпотентности прислать некому: входящие вебхуки, сообщения из брокера, импорт файлов. Логика та же, только идентификатор берут из полезной нагрузки.
// Вебхук от платёжной системы: доставка at-least-once, дубли неизбежны.
// Уникальный индекс делает вторую вставку конфликтом, а не вторым эффектом.
func (s *Service) HandleWebhook(ctx context.Context, ev Event) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback()
// UNIQUE (provider, event_id)
res, err := tx.ExecContext(ctx, `
INSERT INTO processed_events (provider, event_id, received_at)
VALUES ($1, $2, now())
ON CONFLICT (provider, event_id) DO NOTHING`, ev.Provider, ev.ID)
if err != nil { return err }
if n, _ := res.RowsAffected(); n == 0 {
return nil // уже обработано: тихо отвечаем 200, иначе провайдер будет ретраить вечно
}
if err := s.apply(ctx, tx, ev); err != nil { return err }
return tx.Commit() // отметка и эффект коммитятся одной транзакцией
}
- Дедупликация и эффект живут в одной транзакции. Если отметить «обработано» в Redis, а потом упасть на записи в БД, событие потеряется навсегда. Проще всего сделать это корректно одной транзакцией БД; Redis годится как быстрый фильтр перед ней, но источником истины его делать нельзя.
- Проверять подпись вебхука (HMAC от тела с общим секретом,
constant-timeсравнение) и метку времени - иначе перехваченный когда-то запрос можно проиграть заново (replay). Подпись считают по сырому телу, до парсинга JSON: пересериализация меняет байты. - TTL у таблицы дедупликации. Хранить ключи вечно нельзя, но окно должно с запасом перекрывать политику ретраев провайдера (Stripe, например, ретраит до трёх суток).
- Отвечать быстро. Обработку вебхука лучше свести к «проверил подпись → положил в очередь → 200». Если обрабатывать долго и синхронно, провайдер отвалится по таймауту и пришлёт ретрай, то есть медленный обработчик сам плодит дубли.
Валидация входных данных
Валидация не сводится к «проверить, что поле не пустое». Она явно описывает множество допустимых входов и стоит на границе системы. Всё, что не описано, отвергается, поэтому валидация, помимо удобства, ещё и защищает.
Где валидировать
- На границе, сразу после разбора запроса, проверяют синтаксис и формат: типы,
диапазоны, длины, обязательность, регулярки. На ошибку отвечают
400(тело неразборчиво) или422(разобрано, но не проходит правила). - В домене живут бизнес-инварианты, которым нужен контекст: «дата окончания позже даты начала», «сумма не превышает остаток», «этот статус недостижим из текущего». Их место в сервисном слое, потому что они нужны любому вызывающему: HTTP, gRPC, CLI-команде, консьюмеру из брокера.
- В базе - последняя линия:
NOT NULL,CHECK,UNIQUE, внешние ключи. Это единственный уровень, который переживёт баг в коде и ручнойUPDATEиз psql. - На фронте проверяют только ради UX: это дублирование, защиты оно не даёт.
Чёрный список перечисляет плохое и проигрывает всегда:
список обходов бесконечен и растёт быстрее, чем правила. Белый список перечисляет
допустимое и ошибается в безопасную сторону - незнакомый вход он отвергает.
На практике: не «вырезать <script>», а «имя - от 1 до 64 символов из
букв, цифр, пробела и дефиса»; не «запретить ../», а «имя файла соответствует
^[a-zA-Z0-9_-]{1,64}\.(png|jpg)$»; не «отфильтровать опасные схемы URL», а
«разрешены только http и https».
go-playground/validator и кастомные правила
type createOrderReq struct {
Email string `json:"email" validate:"required,email,max=254"`
Currency string `json:"currency" validate:"required,iso4217"`
Amount int64 `json:"amount" validate:"required,gt=0,lte=100000000"`
Comment string `json:"comment" validate:"max=500"`
DeliverAt time.Time `json:"deliver_at" validate:"required,gt"` // gt без числа у времени: позже now
Items []item `json:"items" validate:"required,min=1,max=100,dive"`
Phone string `json:"phone" validate:"omitempty,e164"`
}
// Валидатор создаётся один раз: он кэширует разбор тегов через reflect,
// а пересоздание на каждый запрос заметно бьёт по производительности.
var validate = func() *validator.Validate {
v := validator.New(validator.WithRequiredStructEnabled())
// Имена полей в ошибках берём из json-тегов, чтобы наружу уезжал
// контракт API, а не имена полей Go.
v.RegisterTagNameFunc(func(f reflect.StructField) string {
name := strings.SplitN(f.Tag.Get("json"), ",", 2)[0]
if name == "-" { return "" }
return name
})
// Кастомное правило: слаг статьи.
var slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
v.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
s := fl.Field().String()
return len(s) <= 80 && slugRe.MatchString(s)
})
// Правило уровня структуры: когда одно поле зависит от другого.
v.RegisterStructValidation(func(sl validator.StructLevel) {
r := sl.Current().Interface().(createOrderReq)
if r.Currency == "RUB" && r.Amount < 100 {
sl.ReportError(r.Amount, "amount", "Amount", "min_rub", "")
}
}, createOrderReq{})
return v
}()
Ошибки валидатора нельзя отдавать наружу как есть: текст
Key: 'createOrderReq.amount' Error:Field validation for 'amount' failed on the 'gt'
tag раскрывает внутренние имена и нечитаем для клиента. Их превращают в структуру
«поле → код ошибки», совместимую с общим форматом ошибок из главы 10.1.
func validationProblem(err error) *Problem {
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
return &Problem{Status: 400, Code: "bad_request", Title: "Некорректный запрос"}
}
p := &Problem{Status: 422, Code: "validation_failed", Title: "Проверьте поля формы"}
for _, fe := range ve {
p.Errors = append(p.Errors, FieldError{
Field: fe.Field(), // "amount" благодаря RegisterTagNameFunc
Code: fe.Tag(), // "gt", "required", "email" - машиночитаемо
// Текст для человека собираем сами; сообщение библиотеки наружу не отдаём.
Message: humanize(fe),
})
}
return p
}
- Тело запроса:
http.MaxBytesReader(w, r.Body, 1<<20). Без негоio.ReadAllилиjson.NewDecoder(r.Body).Decodeс радостью примут гигабайт и положат процесс по памяти.MaxBytesReaderлучше простогоio.LimitReader: он ещё и закрывает соединение и даёт понятную ошибку. - Заголовки:
Server.MaxHeaderBytes(по умолчанию 1 МиБ). - Multipart:
r.ParseMultipartForm(n)ограничивает только память под разбор, остальное уезжает во временные файлы на диск, так что общий предел всё равно ставитMaxBytesReader. Реальный тип файла проверяют по сигнатуре (http.DetectContentType), не доверяя расширению и заголовку клиента. - Глубина и количество: длина массивов (
max=100иdive), вложенность JSON, число элементов вIN, размер страницы пагинации. Иначе безобидная ручка становится усилителем нагрузки:?limit=1000000. - Регулярные выражения на входных данных: в Go RE2 гарантирует линейное время, поэтому ReDoS нам не грозит (частый каверзный вопрос). Но правило «не строить регулярку из пользовательской строки» остаётся.
- Строки приводить к NFC и проверять на управляющие символы. Иначе в имя пролезет
\r\n(испортит логи и заголовки), нулевой байт или невидимая RTL-метка.
HTTPS everywhere и security-заголовки
TLS обязателен везде, включая внутренние
вызовы: сеть внутри периметра прослушивается ровно так же, а модель «мягкая внутри, твёрдая
снаружи» разваливается от одной скомпрометированной машины. Минимум на практике: TLS 1.2+
(лучше 1.3), автоматические сертификаты, редирект 80 → 443. Межсервисный трафик
закрывают mTLS, обычно через service mesh, чтобы не ротировать сертификаты руками.
| Заголовок | Что делает | Значение по умолчанию |
|---|---|---|
Strict-Transport-Security | браузер запоминает: на этот домен - только HTTPS, без возможности кликнуть «продолжить». Убивает атаку с понижением протокола; самый первый визит закрывает только preload | max-age=31536000; includeSubDomains; preload - только осознанно |
X-Content-Type-Options | запрещает MIME-sniffing | nosniff - всегда |
X-Frame-Options | защита от clickjacking (наш сайт в прозрачном iframe поверх ловушки) | DENY; современная замена - frame-ancestors в CSP |
Content-Security-Policy | откуда можно грузить скрипты, стили, картинки; разрешён ли инлайн | для API: default-src 'none'; frame-ancestors 'none' |
Referrer-Policy | не утекать полный URL (с токенами и id) на сторонние сайты | strict-origin-when-cross-origin |
Permissions-Policy | отключить ненужные API браузера (камера, геолокация) | camera=(), microphone=(), geolocation=() |
Cache-Control | чтобы приватный ответ не осел в общем кэше или в истории браузера | no-store на всё персональное |
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// HSTS имеет смысл только на HTTPS-ответе: по http браузер его игнорирует,
// а на localhost он ещё и мешает разработке.
if r.TLS != nil {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
h.Set("Cache-Control", "no-store")
// Server и X-Powered-By net/http сам не ставит. А Del тут не помог бы:
// он выполняется раньше хендлера, который мог их выставить.
next.ServeHTTP(w, r)
})
}
- Первый запрос всё равно уязвим - браузер узнаёт о политике только из ответа,
который уже пришёл. Это лечит список
preload, зашитый в браузеры. preloadиincludeSubDomainsпрактически необратимы. Покаmax-ageне истечёт у каждого посетителя (а из preload-списка домен удаляют месяцами), все поддомены обязаны работать по HTTPS. Один забытый внутренний поддомен на http - и он недоступен для всех, кто хоть раз заходил на основной сайт. Выкатывать надо ступенчато: сначала маленькийmax-age, потомincludeSubDomains, и только потомpreload.
- Не отдавать наружу детали ошибок: ни стек-трейса, ни текста ошибки СУБД, ни
версии сервера. Клиент видит код, обезличенное сообщение и
trace_id, подробности идут в лог под тем же id. - Не логировать секреты и персональные данные: заголовок
Authorization, тела с паролями, номера карт. Маскирует сам логгер: на «не забыть в каждом месте» надежды нет. - Зависимости:
govulncheckв CI (кроме версий он проверяет, достижим ли уязвимый код),go.sum, обновления по расписанию, а не «когда прижмёт». - Аудит-лог значимых действий: смена прав, вход, изменение платёжных реквизитов. Пишется отдельно от прикладного лога, и сервису править его нельзя.
- Правильные атрибуты куки:
Secure,HttpOnly,SameSite, префикс__Host-для сессионной куки (он требуетSecureиPath=/и запрещаетDomain).
Вопросы
13Уязвимый код и что с ним делают
q := "SELECT id, email FROM users WHERE id = " + raw // raw = "1 OR 1=1 --"
row := db.QueryRowContext(ctx, q)
Получается WHERE id = 1 OR 1=1 --: условие всегда истинно, хвост запроса
закомментирован. Дальше атаки усложняются: UNION SELECT для чужих колонок,
boolean-based и time-based blind (AND (SELECT 1 FROM pg_sleep(5)) = 1) там,
где ответа не видно,
stacked queries (1; DROP TABLE users; --) там, где драйвер разрешает
несколько операторов.
Починка
row := db.QueryRowContext(ctx, "SELECT id, email FROM users WHERE id = $1", raw)
В расширенном протоколе клиент шлёт Parse с текстом и Bind со
значениями. К моменту, когда сервер видит значение, дерево разбора уже построено, а место
подстановки стало конкретным листом этого дерева. Строка не может «дорасти» до нового
предиката: грамматика разобрана. Поэтому параметризация не смягчает риск, а устраняет его.
Почему ручное экранирование проигрывает
- Правила зависят от диалекта и настроек:
NO_BACKSLASH_ESCAPESв MySQL,standard_conforming_stringsв PostgreSQL. - Кодировки: в GBK/SJIS байт
0x5Cможет быть частью многобайтового символа, добавленный слеш «съедается» и кавычка освобождается. - Числовой контекст: если значение подставляется без кавычек, экранировать кавычки бессмысленно.
- Это ручная работа: достаточно одного забытого места, чтобы всё остальное потеряло смысл. Параметризация ошибается в безопасную сторону.
ORDER BY, имена таблиц и другие места без плейсхолдеров
Плейсхолдер подставляет значение. А идентификатор входит в грамматику, и параметр туда не встанет. Выход один: маппинг из белого списка. Клиент присылает ключ, сервер превращает его в заранее написанный фрагмент SQL.
var sortable = map[string]string{"created_at": "p.created_at", "views": "p.views_count"}
col, ok := sortable[q.Get("sort")] // неизвестный ключ → 400, а не склейка
dir := "ASC"; if strings.EqualFold(q.Get("order"), "desc") { dir = "DESC" }
Если динамическое имя схемы или таблицы неизбежно (мультитенантность), его цитируют
библиотекой (pgx.Identifier{…}.Sanitize()) и всё равно сверяют со списком:
цитирование спасает от синтаксической инъекции, но не от «покажи схему чужого клиента».
Для списка в IN годятся сгенерированные кодом $1..$N или
= ANY($1) с массивом одним параметром.
«ORM защищает?» - нет: Where("x = '"+s+"'"),
Raw, Exec дают те же грабли, защищает плейсхолдер, а не ORM.
«Что ещё сделать?» - least privilege у роли БД (без DROP и чужих схем),
не отдавать текст ошибки СУБД наружу, gosec в CI. «Бывают ли другие
инъекции?» - NoSQL ($ne в фильтре), command injection, LDAP, шаблонизаторы;
принцип везде один: не собирать команду строкой.
Content-Type плюс
nosniff, CSP и то, что вообще попадает в хранилище.Три вида
- Stored: нагрузка лежит в нашей БД (комментарий, имя профиля) и отдаётся всем, кто откроет страницу. Самый опасный вид.
- Reflected приходит в запросе и возвращается в HTML ответа
(
?q=<script>…). - В DOM-based сервер отдал чистый HTML, но JS сам положил
location.hashвinnerHTML. Чинит фронт; бэкенд помогает только через CSP.
Рычаги бэкенда
- Экранирование при рендере. В Go это
html/template, а неtext/template: он учитывает контекст и знает, куда попадает значение (текст, атрибут,href,<script>, CSS).template.HTMLотключает экранирование - только для доверенного HTML. Пользовательскую разметку чистит санитайзер с белым списком (bluemonday); чёрные списки обходятся<img onerror>,<svg onload>и десятком других вариантов. - Content-Type и
X-Content-Type-Options: nosniff. Без точного типа браузер угадывает по байтам, и «аватар», начинающийся с<html><script>, исполняется на нашем домене. Пользовательские файлы отдают сContent-Disposition: attachment, а по-хорошему с отдельного домена. - CSP работает вторым эшелоном:
script-src 'self' 'nonce-…'без'unsafe-inline'. JSON-API хватитdefault-src 'none'; frame-ancestors 'none'. - Валидация на входе: не хранить в базе ничего, кроме допустимых значений поля.
Да, JSON-эндпоинт тоже бывает вектором
- Тот же JSON, отданный как
text/htmlили без типа, исполняется. Хватит отражённого значения из запроса в тексте ошибки. - В JSONP имя коллбэка из query попадает в исполняемый код, и заодно это полный обход CORS. Не использовать.
- Даже идеальный JSON становится XSS, если фронт положит поле в
innerHTML.
Она не даёт прочитать куку, и это правильный дефолт.
Но скрипт на нашем origin может просто отправлять запросы - браузер приложит куку
сам. Токен не украден, зато перевод выполнен. При XSS всё, что доступно пользователю,
доступно и атакующему; httpOnly снижает ущерб, а не устраняет его.
Authorization в эту схему не попадает: его никто не проставляет
автоматически. Защита: SameSite, с Go 1.25 - штатный
http.CrossOriginProtection, и токены там, где нужны гарантии.Атака по шагам
- Жертва залогинена на
bank.example- кука сессии в браузере. - Жертва открывает
evil.com, где лежит формаaction="https://bank.example/transfer" method=POSTс автосабмитом (или просто<img src>, если операция висит наGET). - Браузер отправляет
POSTна наш домен и сам прикладывает куку. - Сервер видит валидную сессию и считает запрос осознанным. Перевод выполнен.
- Ответ атакующему недоступен (SOP), но он ему и не нужен: атака бьёт по побочному эффекту, а не по чтению данных.
Почему Authorization: Bearer иммунен
Разница в том, кто прикрепляет учётные данные. Куку прикрепляет браузер по правилу «домен совпал». Заголовок проставляет наш JavaScript на нашем origin, и чужая страница не может ни прочитать токен, ни заставить браузер добавить заголовок. К тому же кастомный заголовок выводит запрос из «простых»: браузер сначала пошлёт preflight, а его для чужого Origin мы не разрешим.
SameSite
| Значение | Кука уходит на cross-site запрос | Практика |
|---|---|---|
Strict | никогда | refresh-кука, админка. Побочка: переход по ссылке из почты выглядит как разлогин |
Lax | только на top-level GET-навигацию | рабочий дефолт; POST, fetch, iframe, img куку не получат |
None | всегда, требует Secure | честный кросс-домен (виджет, SSO) - и обязательный CSRF-токен |
Оговорки такие: «same-site» сравнивается по eTLD+1, поэтому захваченный поддомен
обходит защиту; Lax не закрывает изменяющие операции на GET, так
что соблюдение семантики методов тоже защищает от CSRF; старые браузеры атрибут
игнорируют.
CSRF-токены
- Synchronizer token: случайное значение хранится в состоянии сессии, отдаётся в HTML и сверяется на изменяющем запросе. Надёжно, но требует состояния.
- Double submit кладёт значение в отдельную не-
httpOnlyкуку, фронт дублирует его в заголовокX-CSRF-Token, сервер сравнивает. Работает потому, что чужой сайт куку прочитать не может. Слабое место в поддомене, способном записать куку на родительский домен, поэтому в проде берут signed double submit: значение связано с сессией через HMAC. - Сравниваем через
subtle.ConstantTimeCompare, безопасные методы пропускаем, токен ротируем при логине.
Go 1.25: http.CrossOriginProtection - то же самое без токенов
В старых гайдах этого нет. В Go 1.25 в net/http появилась штатная
защита от CSRF на Fetch metadata - заголовках, которые к каждому запросу на HTTPS
и localhost добавляет сам браузер и которые страница-инициатор подделать не может. Главный из них,
Sec-Fetch-Site, сообщает, откуда запрос инициирован
(same-origin, same-site, cross-site,
none). Правило: небезопасный метод при Sec-Fetch-Site, отличном от
same-origin и none, получает отказ 403. Ни состояния, ни куки, ни правок на фронте.
cop := http.NewCrossOriginProtection()
_ = cop.AddTrustedOrigin("https://admin.example.com") // честный фронт на другом origin
cop.AddInsecureBypassPattern("/webhooks/") // ручки, куда браузер не ходит
srv := &http.Server{Handler: cop.Handler(mux)} // всё, защита включена
Sec-Fetch-Site: same-site он тоже отвергает и потому закрывает атаку с
захваченного поддомена, где SameSite бессилен.
А запрос без Fetch metadata и без Origin пропускается, иначе сломались
бы все не-браузерные клиенты. Обратная сторона: без защиты остаётся браузер, который не
шлёт ни одного из двух заголовков. Старый браузер без Sec-Fetch-Site, но с
Origin Go проверит запасным способом, по Host. Поэтому штатный middleware становится новой базой, а токен остаётся страховкой
для критичных операций и старых клиентов.
Требование
Content-Type: application/json (иначе 415) - HTML-форма такой
запрос отправить не может. И повторный ввод пароля или 2FA на действительно опасных
операциях: его не сломает ни CSRF, ни XSS.
Где появляется и чем опасно
Точки входа: «загрузить картинку по URL», вебхуки, импорт из внешнего источника, генерация превью ссылки, прокси-ручка. Цели атакующего:
- На
169.254.169.254лежат метаданные облака с временными ключами роли инстанса. Из-за этого и появился IMDSv2 с обязательным PUT-токеном. - Внутренние сервисы без аутентификации «потому что они не торчат наружу»: Kubernetes API, Consul, Elasticsearch, панели.
- Сканирование сети по времени ответа и коду ошибки (blind SSRF).
- Смена схемы:
file://читает локальные файлы, а черезgopher://иdict://собирают произвольный TCP-пакет.http.Clientв Go таких схем не знает, но libcurl и конвертеры документов знают.
Защита по убыванию надёжности
- Allowlist хостов или заранее зарегистрированные интеграции. Только эта защита не проигрывает гонку с обходами.
- Сетевая изоляция: egress через прокси со своей политикой, network policy, запрещающая поду ходить во внутренние подсети напрямую.
- Запрет приватных диапазонов на этапе соединения через хук
Controlуnet.Dialer. Хук вызывается уже с готовым адресом, поэтому DNS rebinding (резолв поменялся между проверкой и connect) не проходит.
var safeClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Control: safeControl}).DialContext, // блокирует 10/8, 127/8, 169.254/16, fc00::/7 …
},
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) >= 3 { return errors.New("too many redirects") }
return checkScheme(r.URL) // за редиректом новый адрес, проверяем каждый прыжок
},
}
- Редиректы: разрешённый хост отвечает
302на169.254.169.254, и одноразовая проверка на входе обойдена. - Обходы парсинга:
http://[::1],http://2130706433,http://0177.0.0.1,localtest.me(публичный домен в 127.0.0.1),http://a@evil.com@internal. Строку проверять заведомо проигрышно, аnetip.Addrнадёжно. И не забытьUnmap()для::ffff:-адресов. - Не возвращать наружу тело, статус и время ответа стороннего ресурса, иначе даже безопасный фетч превращается в сканер сети.
- Лимиты: таймаут,
io.LimitReader, проверкаContent-Type. Без них SSRF заодно становится DoS против нас.
Как выглядит
// Middleware проверил, что пользователь аутентифицирован.
// Что заказ его, не проверил никто. /v1/orders/1235 вместо 1234.
order, err := repo.GetByID(ctx, id)
// Правильно: владение уезжает в WHERE и его нельзя «забыть проверить».
order, err := repo.GetForUser(ctx, id, auth.UserID(ctx)) // WHERE id=$1 AND user_id=$2
Правила, закрывающие класс целиком
- Владение проверяется в том же запросе к БД, а не отдельным
ifпосле чтения: и забыть труднее, и гонок нет. Сложные правила выносят в явную функциюcan(ctx, subject, action, object)в сервисном слое, где объект уже известен. - Авторизация не живёт целиком в middleware. Middleware закрывает грубое
(«роль
adminна/admin/*»), объектные права - только там, где известен объект. - Fail closed. По умолчанию роутер требует авторизацию, публичные маршруты помечаются явно. Полезный тест: пройтись по всем зарегистрированным маршрутам и убедиться, что у каждого есть политика.
404вместо403, если уже сам факт существования объекта раскрывает лишнее.- Непредсказуемые id только смягчают проблему. UUID убирает тривиальный перебор, но id утекают в логи, рефереры, шаринг ссылок.
- Массовые операции, вложенные ресурсы, экспорт, GraphQL-резолверы: тут проверку забывают чаще всего, потому что она нужна для каждого элемента.
- Скрытая кнопка не заменяет контроль доступа. Второй частый случай не про
объект, а про действие:
/admin/*, открытый всем, «потому что ссылки нет».
Убедительнее всего на собеседовании звучит «мы это
тестируем». Интеграционный тест, где второй пользователь пытается прочитать и изменить
объекты первого и ожидает 404/403, ловит регрессии лучше
любого ревью. Хорошо завести шаблон такого теста для каждого нового ресурса.
json.Decode заполняет те поля, которые прислали,
а прислать могут любые - включая is_admin и balance. Лечится не
проверкой, а типом: отдельный DTO на запрос, отдельный на ответ, перенос в домен -
явными присваиваниями.Как выглядит дыра
type User struct {
ID int64 `json:"id"`; Email string `json:"email"`
IsAdmin bool `json:"is_admin"`; Balance int64 `json:"balance"`
}
var u User
json.NewDecoder(r.Body).Decode(&u) // {"is_admin": true, "balance": 999999}
repo.Update(ctx, &u) // и это уезжает в UPDATE
Как правильно
type updateProfileReq struct { // ровно то, что клиенту разрешено прислать
DisplayName string `json:"display_name" validate:"required,max=64"`
Bio string `json:"bio" validate:"max=500"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
dec.DisallowUnknownFields()
// … и перенос в домен поле за полем (забытое новое поле ловят тест или exhaustruct)
Что спрашивают следом
- «Достаточно ли
DisallowUnknownFields?» Нет. Это помощь на этапе разработки, но она ломает совместимость: клиент чуть новее, и интеграция уже падает. Защищает отсутствие опасных полей в DTO, а не запрет лишних. - «Как отличить "не прислали" от "прислали ноль"?» Для
PATCHэто принципиально:bio: ""- очистить, отсутствие поля - не трогать. Указатели*string, обёрткаOptional[T]илиjson.RawMessage. - «А ответ?» Модель ответа тоже нужна отдельная, иначе однажды наружу уедет
password_hashили внутренний комментарий модератора. Обратная сторона той же ошибки: отдавая доменную модель, мы делаем любое внутреннее переименование ломающим изменением API. - «А если полей много?» Кодогенерация или маппер, но не «сэкономим на типе». Три структуры вместо одной и есть цена явного контракта.
Три следствия, снимающие все вопросы
- CORS ничего не закрывает. Если API должен быть закрыт, его закрывают аутентификация и авторизация.
- SOP блокирует чтение, а не отправку. Простой запрос уже дошёл до сервера и выполнился, браузер просто не отдал JS ответ. Потому CSRF и существует.
- Origin = схема + хост + порт.
http://xиhttps://xсчитаются разными origin.
Simple против preflight
«Простым» считается запрос GET/HEAD/POST с
Content-Type из трёх разрешённых (text/plain,
form-urlencoded, multipart) и без кастомных заголовков.
Он уходит сразу, остальным предшествует OPTIONS:
→ OPTIONS /v1/orders/42
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization, content-type
← 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin
→ DELETE /v1/orders/42 # только теперь уходит боевой запрос
← 200 OK + Access-Control-Allow-Origin # заголовок обязателен и здесь
Разница на практике: preflight предотвращает побочный эффект (боевой запрос не
уйдёт), а simple нет. Любой JSON-API с Authorization всегда идёт с
preflight, поэтому HTML-форма не может подделать JSON-запрос.
Типичные ошибки
Allow-Origin: *плюсAllow-Credentials: true- спецификация это запрещает, браузер отклонит. Логика: «любой сайт» + «с куками жертвы».- Отражение
Originбез проверки даёт ту же дыру, только спецификацию обмануть удалось. Любой сайт читает ответы с сессией жертвы. Одна из самых частых находок на пентесте. - Слабое сравнение:
HasPrefix(o, "https://example.com")пропускаетhttps://example.com.evil.net. Только точное совпадение с элементом списка. - Забытый
Vary: Origin- кэш (CDN, прокси, браузер) отдаст ответ с чужимAllow-Origin. В одну сторону - сломанный фронт, в другую - утечка разрешения. Ставить надо всегда, даже когда origin не разрешён. nullв белом списке - его присылают песочницыiframe sandboxи локальные файлы; это «впустить кого угодно».- Preflight не доходит до обработчика:
OPTIONSдолжен отвечать до auth-middleware, потому что браузер не прикладывает к нему ни кук, ниAuthorization. Симптом - «CORS-ошибка, хотя всё настроено». - Забытый
Access-Control-Expose-Headers: своиX-Request-Id,X-RateLimit-*,Locationфронт не увидит, по умолчанию ему доступны только семь базовых заголовков ответа.
«У нас CORS настроен только на наш домен - значит, API защищён
от чужих?» Нет. Заголовки Access-Control-* интересуют исключительно браузер;
curl их проигнорирует и получит ответ. И зеркальный вопрос: «почему запрос
дошёл до сервера, хотя браузер показал CORS-ошибку?» Потому что для простых запросов
блокируется чтение ответа, а не отправка.
Один и тот же вход: лимит 10/мин, 10 запросов в 59-ю секунду и ещё 10 в 61-ю
| Алгоритм | Что произойдёт | Память | Устройство |
|---|---|---|---|
| Fixed window | прошли оба всплеска: 20 запросов за 2 секунды | O(1) | INCR rl:{user}:{minute} с TTL |
| Sliding window log | первый прошёл, второй отклонён целиком - лимит точен на любом интервале | O(лимит): запись на каждый запрос | zset с метками времени, чистка старого + ZCARD |
| Sliding window counter | оценка 10 × 59/60 + 0 ≈ 9.8, а с самим новым запросом 10.8 > 10 → тот же вердикт, но это приближение | O(1): два счётчика | взвешенная сумма текущего и прошлого окна |
| Token bucket | первый прошёл (ведро было полным), второй отклонён; дальше по 1 запросу каждые 6 с | O(1): токены + отметка времени | ёмкость B, долив со скоростью r |
| Leaky bucket | первый принят в очередь и выпускается по 1 в 6 с; второй отброшен - очередь полна | O(размер очереди) | очередь + постоянная скорость слива |
Чем именно отличаются token bucket и leaky bucket
Их часто называют «одним и тем же наоборот». Это неточно. Token bucket отвечает мгновенно «да/нет» и разрешает накопленный всплеск: неиспользованная квота копится в ведре. Leaky bucket ставит в очередь и выпускает ровно, поэтому на выходе всплесков нет вообще, зато появляется задержка (десятый запрос всплеска исполнится через 54 секунды). Первый решает, пускать ли, второй занят шейпингом. Есть и вариант leaky bucket «as a meter»: он отвергает вместо очереди и по поведению равен token bucket.
Что выбирать
- Token bucket - дефолт для публичного API: две понятные ручки (средняя скорость и бюджет на всплеск), константа памяти, дружелюбен к нормальным клиентам.
- Sliding window log нужен там, где лимит маленький и важна точность: попытки входа, отправка SMS, платежи.
- Sliding window counter - компромисс для больших лимитов, так делает Cloudflare. Ошибается он на единицы процентов, но на лимите «5 попыток» это заметно.
- Leaky bucket подходит, когда за нами хрупкий downstream или сторонний API с жёстким лимитом и всплеск нельзя пропускать в принципе.
- Fixed window годится, если нужен самый дешёвый лимит, а двукратный перелёт не страшен.
Rate limiting ограничивает частоту, а не одновременность. От «десять тяжёлых отчётов параллельно» защищает не лимитер, а ограничение конкурентности: семафор на N слотов или пул воркеров с очередью. На собеседовании это хорошее уточнение: «а если нужно ограничить не rps, а число одновременных?»
Локальный
lim := rate.NewLimiter(rate.Every(time.Second/10), 20) // 10 rps, burst 20 - это token bucket
lim.Allow() // не ждать: для HTTP-хендлера
lim.Wait(ctx) // ждать токен: для исходящих вызовов и фоновых воркеров
lim.Reserve() // забронировать и узнать задержку
В хендлере нужен Allow: с Wait лимитер копит горутины, и
вместо быстрого 429 клиент получает зависший запрос.
Wait уместен там, где мы исходящая сторона и обязаны соблюдать чужой
лимит.
Лимитеры «на ключ» хранятся в map, и без вытеснения (TTL-уборка или LRU) это утечка памяти: каждый новый IP добавляет запись навсегда.
Главный минус локального
Он на инстанс. При 10 подах «100 rps» превращается в 1000 rps, и цифра плавает при автоскейлинге. Делить лимит на число реплик нельзя: балансировщик распределяет неравномерно, а при выкатке подов временно больше. Локальный честно решает две задачи: защищает сам процесс (не даёт одному клиенту съесть горутины и коннекты к БД) и грубо отсекает лишнее.
Распределённый на Redis
Redis подходит потому, что он быстрый и однопоточный: Lua-скрипт выполняется атомарно, и между чтением счётчика и записью никакой другой под не вклинится. Наивное «GET, посчитал в Go, SET» даёт классическую гонку read-modify-write.
// Token bucket целиком внутри одного атомарного вызова.
var tokenBucket = redis.NewScript(`
local st = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(st[1]); local ts = tonumber(st[2])
local now, rate, burst, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
if tokens == nil then tokens = burst; ts = now end
tokens = math.min(burst, tokens + math.max(0, now - ts) / 1000.0 * rate)
local allowed, retry = 0, 0
if tokens >= cost then tokens = tokens - cost; allowed = 1
else retry = math.ceil(((cost - tokens) / rate) * 1000) end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil((burst / rate) * 1000) + 1000)
return { allowed, math.floor(tokens), retry }
`)
- Жёсткий таймаут (десятки миллисекунд): лимитер не имеет права стать причиной таймаута запроса.
- Стратегия при отказе Redis: fail-open (пропускать) или fail-closed
(
429/503). Выбор продуктовый: публичному API обычно ставят fail-open с метрикой и алертом, антифроду fail-closed. - Обязательный TTL у ключа, иначе Redis наберёт миллионы мёртвых ключей от разовых посетителей.
- Все ключи передаются в
KEYS, иначе в Redis Cluster скрипт упадёт на чужом слоте; нескольким ключам нужен общий hash tagrl:{user:42}:min. - Несколько лимитов сразу (10/с, 300/мин, 10 000/сут) проверять одним скриптом и списывать, только если прошли все. Иначе счётчики разъедутся.
- Гибрид: локальное ведро «выкупает» у Redis пакет разрешений на 10–100 запросов. Точность чуть падает, нагрузка на Redis - на порядок.
Где ставить
| Уровень | Умеет | Не умеет |
|---|---|---|
| CDN / WAF | отсечь объём до нашего канала, L7-DDoS, гео- и ASN-правила | ничего не знает про тарифы, тенантов и стоимость операции |
| Edge: ingress, API-gateway, nginx | дёшево, до приложения, единая точка для всех сервисов | не знает пользователя, пока не разобран токен |
| Приложение | знает пользователя, тариф, стоимость операции; может списывать разный cost | работает после того, как соединение принято и токен разобран - ресурсы уже частично потрачены |
По какому ключу
- Пользователь или организация - лучший ключ: не бьёт по соседям, переживает смену IP, квота может зависеть от тарифа.
- API-ключ /
client_idнужен для машинных интеграций. Считать надо и по ключу, и по владельцу, иначе партнёр заведёт десять ключей и обойдёт лимит. - IP - единственный вариант до аутентификации: логин, регистрация, восстановление пароля, публичные ручки.
- Составной ключ защищает от перебора: отдельный жёсткий лимит на «этот логин» и на «этот IP» плюс глобальный счётчик неудачных попыток.
Проблемы IP как ключа
- NAT. За одним адресом сидит корпоративная сеть или целый мобильный оператор, и лимит бьёт по тысячам невиновных. Поэтому IP-лимит держат заметно выше пользовательского и по возможности сразу после логина переходят на ключ по пользователю.
- IPv6. У клиента может быть
/64целиком, то есть 18 квинтиллионов «разных» адресов. Агрегируют по префиксу/64(иногда/56). X-Forwarded-Forподделывается. Заголовок формирует клиент, каждый прокси лишь дополняет. Если брать «первый элемент», атакующий получит новый лимит на каждый запрос и сможет подставить чужой IP, чтобы заблокировали невиновного.
// Доверяем ровно тому числу своих прокси, которое реально стоит перед сервисом,
// и отсчитываем справа налево. r.RemoteAddr подделать нельзя.
idx := len(parts) - trustedProxies
if idx < 0 || idx >= len(parts) { return peerAddr } // список короче ожидаемого, не верим
Если инфраструктура позволяет, берут заголовок, который наш edge
перезаписывает, а не дополняет (CF-Connecting-IP,
True-Client-IP): всё пришедшее снаружи стирается. Есть и стандартный
Forwarded из RFC 7239.
Тут лимит по IP обязателен, но его мало: credential stuffing идёт с тысяч адресов по одному запросу на каждый. Нужны минимум три счётчика (по IP, по логину и глобальный по числу неудач) плюс экспоненциальная задержка и капча после порога. И одинаковый ответ «неверный логин или пароль» с одинаковым временем, чтобы лимитер не превратился в оракул существования аккаунтов.
429 Too Many Requests плюс обязательный
Retry-After, машиночитаемое тело в общем формате ошибок и заголовки
X-RateLimit-* - причём и на успешных ответах тоже, чтобы вежливый клиент
притормозил до того, как упрётся.Ответ целиком
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 5 # желателен: секунды или HTTP-дата
X-RateLimit-Limit: 100 # де-факто стандарт, понимают все SDK
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1756192846 # unix-время сброса
RateLimit-Policy: "default";q=100;w=60 # черновик IETF, постепенно вытесняет X-
RateLimit: "default";r=0;t=5
{"type":"…/errors/rate-limited","title":"Слишком много запросов",
"status":429,"detail":"Лимит 100 запросов в минуту исчерпан","retry_after":5}
Что важно проговорить
- Без
Retry-Afterклиент ретраит сразу и делает только хуже. 429≠503. Первый означает «конкретно ты превысил квоту», второй «сервису плохо целиком». По ним по-разному настраивают алерты и по-разному реагируют клиенты.- Заголовки надо добавить в
Access-Control-Expose-Headers, иначе браузерный SPA их просто не увидит. - Ответ должен быть дешёвым: без похода в БД, без подробного лога на каждый отказ и без тяжёлого тела. Иначе лимитер сам станет вектором DoS. Логируют агрегатами, метрику ведут счётчиком с меткой причины.
- Не раскрывать лишнего на анонимных ручках и на логине: точное «осталось 0 из 5»
подсказывает перебиральщику ритм. Там хватит
429иRetry-After. - Со стороны клиента нужен экспоненциальный backoff
с джиттером и уважение к
Retry-After. Без джиттера вся армия клиентов постучится синхронно ровно через N секунд.
Idempotency-Key, для событий и вебхуков - дедупликация по
естественному ключу в той же транзакции, что и эффект.Откуда берутся дубли
Почти никогда не из злого умысла: оборвался ответ, клиент словил таймаут, сработал автоматический ретрай библиотеки, пользователь дважды нажал кнопку, брокер доставил сообщение at-least-once. Запрос при этом реально выполнился, потерялся только ответ. Поэтому повтор обязан вернуть тот же результат, а не выполнить операцию заново.
Idempotency-Key: три вещи, которые надо назвать
- Ключ занимается до внешнего эффекта. Строку в
idempotency_keysсUNIQUEи статусомin_progressвставляют раньше, чем идут в платёжный шлюз. Тогда параллельный дубль упирается в конфликт и не запускает вторую оплату. Порядок «сходили в банк → записали ключ» не спасает вообще. - Ключ привязан к субъекту и к телу. Уникальна пара
(user_id, key), иначе через чужой ключ можно прочитать чужой сохранённый ответ. Плюс хеш тела: на тот же ключ с другим телом отвечают422, а не отдают старый ответ. - Хранится ответ целиком, факта мало. Повтор получает тот же статус и то же тело, иначе клиент, потерявший первый ответ, так и не узнает результат.
Дедупликация событий
// UNIQUE (provider, event_id): вторая вставка становится конфликтом, а не вторым эффектом.
res, err := tx.ExecContext(ctx, `INSERT INTO processed_events (provider, event_id)
VALUES ($1,$2) ON CONFLICT DO NOTHING`, ev.Provider, ev.ID)
if err != nil {
return err // без проверки res == nil, и RowsAffected ниже упадёт
}
if n, _ := res.RowsAffected(); n == 0 {
return nil // уже обработано → тихий 200, иначе провайдер будет ретраить вечно
}
if err := apply(ctx, tx, ev); err != nil { return err }
return tx.Commit() // отметка и эффект коммитятся одной транзакцией
- Отметка и эффект живут в одной транзакции. Отметили в Redis, упали на записи в БД, и событие потеряно навсегда. Redis годится как быстрый фильтр перед транзакцией, но не как источник истины.
- Подпись вебхука (HMAC от сырого тела, constant-time сравнение) и метка времени. Без них перехваченный запрос можно проиграть заново.
- TTL таблицы дедупликации с запасом перекрывает политику ретраев провайдера (обычно сутки-двое).
- Отвечать быстро: «проверил подпись → положил в очередь → 200». Долгая синхронная обработка ловит таймаут у провайдера и новый ретрай, то есть размножает сама себя.
Их часто путают. Rate limiting защищает от слишком многих
запросов, идемпотентность - от повторов одного и того же. Лимитер, отдающий
429 на легитимный ретрай, проблему дублей не решает, а идемпотентность не
спасает от перебора. Нужны оба механизма, и они независимы.
nosniff, HSTS,
frame-ancestors, CSP, Referrer-Policy.Где валидировать
- На границе, сразу после разбора запроса, проверяют типы, диапазоны, длины,
обязательность, форматы.
400- тело неразборчиво,422- разобрано, но не проходит правила. - В домене держат инварианты, которым нужен контекст: «дата окончания позже начала», «сумма не превышает остаток». Они обязаны жить в сервисном слое, потому что нужны любому вызывающему: HTTP, gRPC, CLI, консьюмеру из брокера.
- В базе стоят
NOT NULL,CHECK,UNIQUE, внешние ключи. Только этот уровень переживёт баг в коде и ручнойUPDATEиз psql. - На фронте только UX: это дублирование, а не защита.
Whitelist против blacklist
Чёрный список перечисляет плохое и проигрывает всегда: список обходов бесконечен.
Белый перечисляет допустимое и ошибается в безопасную сторону. Не «вырезать
<script>», а «имя - 1–64 символа из букв, цифр, пробела и дефиса»;
не «запретить ../», а «имя файла соответствует
^[a-zA-Z0-9_-]{1,64}\.(png|jpg)$».
Библиотека и кастомные правила
Amount int64 `json:"amount" validate:"required,gt=0,lte=100000000"`
Items []item `json:"items" validate:"required,min=1,max=100,dive"`
v.RegisterTagNameFunc(...) // имена полей в ошибках - из json-тегов, а не из Go
v.RegisterValidation("slug", ...) // своё правило на поле
v.RegisterStructValidation(...) // правило, где одно поле зависит от другого
Валидатор создаётся один раз (он кэширует разбор тегов через reflect). Ошибки наружу отдаются не текстом библиотеки, а структурой «поле → машиночитаемый код → текст для человека» в общем формате ошибок.
Лимиты размеров - это тоже валидация
http.MaxBytesReader(w, r.Body, 1<<20)- без негоjson.Decodeпримет гигабайт и положит процесс по памяти.Server.MaxHeaderBytes. Для multipart нужен предел памяти вParseMultipartFormплюс общийMaxBytesReader, а тип файла проверяют по сигнатуре (http.DetectContentType), а не по расширению.- Длина массивов, глубина вложенности, размер страницы пагинации - иначе безобидная ручка становится усилителем нагрузки.
- Про ReDoS хороший ответ: в Go регулярки на RE2, время линейное - но строить регулярку из пользовательской строки всё равно нельзя.
Заголовки и HTTPS
| Заголовок | Зачем |
|---|---|
Strict-Transport-Security | только HTTPS на этот домен, без «продолжить всё равно». Убивает понижение протокола |
X-Content-Type-Options: nosniff | запрет MIME-sniffing - целый класс атак одним заголовком |
X-Frame-Options: DENY / frame-ancestors | clickjacking |
Content-Security-Policy | второй эшелон против XSS; для API - default-src 'none' |
Referrer-Policy | не утекать полный URL с токенами и id на чужие сайты |
Cache-Control: no-store | приватный ответ не осядет в общем кэше и в истории браузера |
TLS нужен везде, включая внутренние вызовы: модель «мягкая внутри, твёрдая снаружи»
разваливается от одной скомпрометированной машины. Минимум: TLS 1.2+, редирект
80 → 443, автоматические сертификаты, а межсервисный трафик идёт по mTLS,
обычно через service mesh.
Первый запрос всё равно уязвим: о политике браузер
узнаёт из уже пришедшего ответа, и помогает тут только preload-список.
И preload с includeSubDomains практически необратимы:
пока max-age не истечёт у каждого посетителя, все поддомены обязаны работать
по HTTPS. Один забытый внутренний поддомен на http - и он недоступен для всех, кто хоть
раз заходил на основной сайт. Выкатывать ступенчато: маленький max-age →
includeSubDomains → preload.
Что стоит запомнить
- Параметры отделяют значения SQL от текста запроса; имена/направления сортировки задаются allowlist.
- SSR F возникает, когда сервер сам запрашивает адрес под контролем клиента; проверяй каждый redirect и резолв адреса.
- CSRF связан с автоматически отправляемыми браузером credentials; CORS не является механизмом авторизации.
Частые ошибки
- Не биндь request JSON прямо в доменную модель с полями is_admin/balance.
- Не считай CORS запретом для curl или серверного клиента; он управляет доступом браузерного JS к ответу.
- Не проверяй SSRF только по исходному hostname до DNS resolution: возможен DNS rebinding.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Найди SSRF вход и redirect риск
Определи, какие адреса может запросить server-side клиент и почему проверка только первой URL недостаточна.
func fetchAvatar(ctx context.Context, rawURL string) ([]byte, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Ответ и разбор
Пользовательский rawURL позволяет запросить loopback, private network, metadata endpoint или неподдерживаемую схему. Проверки URL до http.DefaultClient.Do недостаточно: Transport отдельно разрешает DNS и подключается, поэтому между отдельной проверкой и connect возможен rebinding; redirect может направить на новый запрещённый адрес. Ограничь scheme/host/port, отключи автоматические redirects или проверь каждый адрес в CheckRedirect. Для каждого соединения используй контролируемый DialContext: разреши host, отвергни loopback/private/link-local/metadata IP и подключись именно к уже проверенному IP, сохранив исходный hostname как TLS ServerName для проверки сертификата. Ограничь timeout и размер ответа.
Код-ревью
2. Исправь mass assignment
Входной JSON декодируется в доменную User и напрямую сохраняется. Покажи конкретные поля, которыми клиент может повысить права или изменить баланс, и исправь границу.
type User struct {
ID int64 json:"id"
Email string json:"email"
IsAdmin bool json:"is_admin"
Balance int64 json:"balance"
}
var u User
_ = json.NewDecoder(r.Body).Decode(&u)
_ = repo.Update(ctx, &u)
Ответ и разбор
Клиент может прислать {"is_admin":true,"balance":999999}. Декодируй в отдельный updateProfileReq только с разрешёнными display_name/bio, поставь MaxBytesReader и DisallowUnknownFields, проверь поля и явно перенеси их в доменную сущность. Тогда запрещённые поля не попадут в repository update.
Применение
3. Различи CSRF и CORS
Сайт злоумышленника не может прочитать ответ API из-за same-origin policy, но браузер отправляет cookie пользователя на POST. Какой риск остаётся и почему CORS не исправляет его?
Ответ и разбор
Остаётся CSRF: браузер автоматически приложил cookie и мог выполнить действие от имени пользователя. CORS ограничивает чтение ответа скриптом, но запрос мог уже изменить состояние. Для cookie-auth изменяющих методов проверь CSRF token и Origin/SameSite; bearer token, который скрипт явно добавляет, имеет другую модель угроз.
Применение
4. Защити SQL сортировку и значение
Пользователь задаёт q и sort. Покажи отдельно, как передать q параметром и как обработать sort.
Ответ и разбор
Передай q через bind parameter, например WHERE name = $1. Для sort используй allowlist значения вроде created_at -> created_at DESC; плейсхолдер для имени колонки не работает как ожидается, а склейка строки открывает injection. Не подменяй allowlist ручным экранированием.