voidlearn
 

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 пропустят спокойно.
Конкатенация: параметр пересекает границу «данные → код» HTTP-запрос GET /users?id=1+OR+1%3D1-- строка от клиента хендлер / репозиторий q := "SELECT id,email FROM users WHERE id = " + raw склейка - граница стёрта текст, ушедший в СУБД SELECT id,email FROM users WHERE id = 1 OR 1=1 -- это уже не параметр, а SQL парсер и планировщик условие тождественно истинно → в ответе вся таблица users Параметризация: значение вообще не попадает в текст запроса HTTP-запрос GET /users?id=1+OR+1%3D1-- та же самая строка хендлер / репозиторий db.QueryContext(ctx, "… WHERE id = $1", raw) текст константен, значение отдельно протокол СУБД Parse: SELECT id,email FROM users WHERE id = $1 Bind: $1 = '1 OR 1=1 --' (одно значение, тип int4) парсер и планировщик текст разобран ДО значения → 0 строк либо ошибка приведения типа
Путь инъекции. Всё решает шаг, на котором значение встречается с парсером, а вовсе не «фильтрация плохих символов». При конкатенации оно приходит вместе с текстом и разбирается как SQL; при параметризации текст разобран заранее, и значение попадёт только в лист готового дерева - как данные, чем бы оно ни было.

Почему параметризация работает, а экранирование - нет

Плейсхолдер не «умный 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 станет &lt;img …&gt;,
//   а в <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() в ответ. В Go http.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 и «XSS украдёт токен»

httpOnly-кука действительно не читается из JS, и это правильный дефолт. Но говорить, что она «защищает от XSS», нельзя: скрипт на нашем origin может просто слать запросы - куку браузер приложит сам. Токен не украден, зато перевод денег выполнен. httpOnly снижает ущерб (нельзя унести сессию на другой хост и пользоваться ей месяц), но при XSS всё, что доступно пользователю, доступно и атакующему.

CSRF: браузер отправляет запрос за пользователя

Cross-Site Request Forgery держится на автоматическом прикреплении учётных данных. Браузер прикладывает куки к запросу, глядя на адрес назначения, а не на того, кто этот запрос инициировал. Значит, чужая страница может заставить браузер жертвы сходить на наш домен - и запрос придёт полностью аутентифицированным.

Прочитать ответ злоумышленник при этом не может. Same-Origin Policy не даст чужому скрипту увидеть тело, статус или заголовки нашего ответа. Поэтому CSRF бьёт по изменяющим операциям: перевод денег, смена email, смена пароля, удаление, выдача прав. Это «слепой» вызов, результат которого атакующему не нужен.

evil.com браузер жертвы bank.example - наш API жертва уже залогинена: в браузере лежит кука bank.example → session=8f3a 1. жертва открыла страницу атакующего <form action="https://bank.example/transfer" method=POST><script>form.submit()</script> 2. браузер отправляет форму: инициатор - evil.com, адресат - bank.example → это cross-site запрос 3. запрос уходит на наш домен POST /transfer amount=10000&to=attacker Cookie: session=8f3a ← приложена браузером САМИМ 4. подпись куки верна → сервер считает запрос осознанным. Перевод выполнен 5. ответ атакующему не виден (SOP) - он ему и не нужен Где ломается цепочка: атрибут SameSite решает судьбу шага 3 SameSite=Strict кука не уходит ни при каком переходе с чужого сайта − переход по внешней ссылке выглядит как разлогин SameSite=Lax - дефолт в Chrome уходит только на top-level GET-навигацию POST, iframe, img, fetch - куки нет → шаг 4 даёт 401 SameSite=None; Secure уходит всегда, в том числе на cross-site POST атака снова работает - обязателен CSRF-токен
CSRF по шагам. Сессию никто не угадывал: браузер сам прикладывает её по адресу назначения. 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, но с Origin Go проверит запасным способом, по 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 решает, выполнять ли запрос. Разные задачи, и одно другое не заменяет.
Как отвечать про CSRF в 2026-м

«Механика прежняя: браузер прикладывает куку по адресу назначения, поэтому чужая страница может выполнить изменяющую операцию от имени пользователя. Базой служит 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.

Как защищаться - по убыванию надёжности

  1. Allowlist. В идеале сервис вообще не ходит по произвольным URL: есть список разрешённых хостов или заранее зарегистрированные интеграции, всё остальное отвергается. Только эта защита не проигрывает гонку с обходами.
  2. Отдельный egress-прокси. Весь исходящий трафик сервиса идёт через прокси, у которого своя политика; сам под сидит в сетевой политике, запрещающей прямые соединения во внутренние подсети. Тогда даже ошибка в коде не даёт доступа.
  3. Запрет приватных диапазонов на этапе соединения. Проверять надо не строку 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 по другой причине: браузер не прикладывает его сам, как куку.

JS на app.example.com браузер api.example.com 1. код вызывает fetch DELETE /v1/orders/42, Authorization: Bearer … 2. запрос НЕ простой: метод DELETE и кастомный заголовок → сначала спросить разрешение 3. PREFLIGHT - служебный запрос, JS о нём не знает OPTIONS /v1/orders/42 Origin: https://app.example.com Request-Method: DELETE Request-Headers: authorization, content-type 4. сервер отвечает одной политикой, тела нет 204 No Content Allow-Origin: https://app.example.com Allow-Methods: GET, POST, DELETE Allow-Headers: authorization, content-type Allow-Credentials: true Max-Age: 600 Vary: Origin 5. политика подходит → решение кладётся в кэш на 600 секунд: для этого origin, URL и режима кук 6. и только теперь уходит настоящий запрос DELETE /v1/orders/42 Authorization: Bearer … 7. на боевом ответе заголовки нужны СНОВА 200 OK Allow-Origin: https://app.example.com Allow-Credentials: true 8. промис резолвится - только здесь JS видит тело В схеме опущен префикс Access-Control- у всех заголовков CORS. Если шаг 4 браузер не устроил Шаг 6 не отправляется вовсе: промис падает с TypeError, а причина видна только в консоли браузера. Сервер при этом ничего не заметил - в его логах есть OPTIONS и нет DELETE. Отсюда ложное «CORS сломал API».
Preflight на оси времени. Браузер задаёт два вопроса: «можно ли этому origin такой метод» и «можно ли такие заголовки», а сервер отвечает одной политикой и сроком её кэширования. На схеме видно, что боевой запрос уходит после разрешения, а заголовок 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 уйдёт клиенту с origin b.example.com - и наоборот. Смотря в какую сторону промахнулся кэш, получится либо неработающий фронт, либо утечка разрешения. Ставить его надо на всех ответах, где Allow-Origin вычисляется динамически.
  • null в белом списке. Origin null присылают песочницы 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 как два числа: сколько в нём сейчас и когда его трогали в прошлый раз.

Один и тот же вход во всех четырёх панелях: лимит 10 запросов в минуту, клиент шлёт 10 запросов в 59-ю секунду и ещё 10 в 61-ю. Fixed window · счётчик на календарное окно лимит 10 10 ✓ 10 ✓ всплеск A t = 59 c всплеск B t = 61 c счётчик привязан к минуте по часам сервера в 60.000 он обнуляется - оба всплеска легальны За 2 секунды прошло 20 запросов - вдвое больше лимита. Это и есть всплеск на границе окна. Плюсы: один INCR, память O(1). Sliding window · log (точный) и counter лимит 10 10 ✓ 10 ✗ всплеск A t = 59 c всплеск B t = 61 c log: метки всех запросов в zset, окно [1 c; 61 c] counter: 10 × 59/60 + 0 ≈ 9.8, с новым запросом 10.8 > 10 → тот же вердикт За любые 60 секунд - ровно 10. Границы окна нет вообще. Цена: log хранит запись на каждый запрос - память O(лимит). Token bucket · ёмкость 10, долив 10 / мин ёмкость 10 10 ✓ 10 ✗ всплеск A t = 59 c всплеск B t = 61 c ведро было полным - всплеск прошёл целиком на t = 61 в ведре 0.33 токена, дальше 1 / 6 c Всплеск разрешён намеренно: ёмкость - это бюджет на burst, скорость долива - средний лимит. Память O(1): токены + отметка. Leaky bucket · очередь 10, слив 1 / 6 c очередь 10 9 ждут 10 ✗ всплеск A t = 59 c всплеск B t = 61 c на выход - строго один запрос каждые 6 секунд всплеск не проходит, а растягивается во времени Бэкенд видит идеально ровную нагрузку - всплеска нет вообще. Цена - задержка: десятый запрос всплеска исполнится через 54 с.
Четыре алгоритма на одном всплеске. Двойной лимит пропускает только fixed window, и лишь потому, что всплеск лёг на границу окна. Sliding window и token bucket дают одинаковый вердикт, но по разным причинам и с разной ценой памяти. Leaky bucket вообще не отвечает «да/нет» мгновенно: он превращает рваный вход в ровный выход и платит за это задержкой.
АлгоритмКак устроенПамять на ключВсплескиГде уместен
Fixed windowINCR ключа rl:{user}:{minute} с TTLO(1), одна командадо 2× лимита на стыке оконгрубые лимиты, где двукратный перелёт неважен; самый дешёвый
Sliding window logzset с меткой времени каждого запроса; чистим старое, считаем оставшееся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-скрипт выполняется атомарно - между чтением счётчика и его записью никакой другой под не вклинится.

балансировщик один клиент → N подов API pod 1 своя память - не считается API pod 2 своя память - не считается API pod 3 своя память - не считается EVALSHA <sha> 1 rl:tb:user:42 now_ms rate burst cost один round-trip, атомарно Redis - общее состояние HASH rl:tb:user:42 tokens = 7.3 ts = 1756192841203 однопоточный → Lua выполняется целиком Что скрипт возвращает и что с этим делать { 1, remaining = 6, retry_ms = 0 } пропускаем, ставим X-RateLimit-Remaining: 6 { 0, remaining = 0, retry_ms = 4200 } 429 + Retry-After: 5 - округление всегда вверх Почему Lua, а не INCR и GET из Go: между чтением и записью влезает соседний под - классическая гонка read-modify-write. Redis недоступен: fail-open (пропускать, лимит временно не работает) или fail-closed (429/503) - это продуктовое решение, а не техническое.
Распределённый лимитер. Инстансы состояния не хранят, оно лежит в Redis, а решение принимает один атомарный вызов. Про два узких места спросят наверняка: Redis становится зависимостью на горячем пути (нужны жёсткий таймаут и понятная стратегия при отказе) и точкой концентрации нагрузки - по одному вызову на каждый входящий запрос.
// 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: это дублирование, защиты оно не даёт.
Whitelist против blacklist

Чёрный список перечисляет плохое и проигрывает всегда: список обходов бесконечен и растёт быстрее, чем правила. Белый список перечисляет допустимое и ошибается в безопасную сторону - незнакомый вход он отвергает. На практике: не «вырезать <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, без возможности кликнуть «продолжить». Убивает атаку с понижением протокола; самый первый визит закрывает только preloadmax-age=31536000; includeSubDomains; preload - только осознанно
X-Content-Type-Optionsзапрещает MIME-sniffingnosniff - всегда
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)
    })
}
HSTS: две ловушки
  • Первый запрос всё равно уязвим - браузер узнаёт о политике только из ответа, который уже пришёл. Это лечит список 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
Суть: пользовательская строка попадает в текст запроса и разбирается СУБД как SQL - граница между данными и кодом стёрта. Лечится не фильтрацией символов, а параметризацией: текст запроса уходит отдельно от значений и разбирается до того, как значение вообще увидено.

Уязвимый код и что с ним делают

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, шаблонизаторы; принцип везде один: не собирать команду строкой.

Суть: XSS - инъекция в HTML: строка пользователя исполняется браузером другого пользователя в контексте нашего origin. Бэкенд закрывает четыре вещи: экранирование при рендере, точный Content-Type плюс nosniff, CSP и то, что вообще попадает в хранилище.

Три вида

  • Stored: нагрузка лежит в нашей БД (комментарий, имя профиля) и отдаётся всем, кто откроет страницу. Самый опасный вид.
  • Reflected приходит в запросе и возвращается в HTML ответа (?q=<script>…).
  • В DOM-based сервер отдал чистый HTML, но JS сам положил location.hash в innerHTML. Чинит фронт; бэкенд помогает только через CSP.

Рычаги бэкенда

  1. Экранирование при рендере. В Go это html/template, а не text/template: он учитывает контекст и знает, куда попадает значение (текст, атрибут, href, <script>, CSS). template.HTML отключает экранирование - только для доверенного HTML. Пользовательскую разметку чистит санитайзер с белым списком (bluemonday); чёрные списки обходятся <img onerror>, <svg onload> и десятком других вариантов.
  2. Content-Type и X-Content-Type-Options: nosniff. Без точного типа браузер угадывает по байтам, и «аватар», начинающийся с <html><script>, исполняется на нашем домене. Пользовательские файлы отдают с Content-Disposition: attachment, а по-хорошему с отдельного домена.
  3. CSP работает вторым эшелоном: script-src 'self' 'nonce-…' без 'unsafe-inline'. JSON-API хватит default-src 'none'; frame-ancestors 'none'.
  4. Валидация на входе: не хранить в базе ничего, кроме допустимых значений поля.

Да, JSON-эндпоинт тоже бывает вектором

  • Тот же JSON, отданный как text/html или без типа, исполняется. Хватит отражённого значения из запроса в тексте ошибки.
  • В JSONP имя коллбэка из query попадает в исполняемый код, и заодно это полный обход CORS. Не использовать.
  • Даже идеальный JSON становится XSS, если фронт положит поле в innerHTML.
«httpOnly-кука защищает от XSS» - неверная формулировка

Она не даёт прочитать куку, и это правильный дефолт. Но скрипт на нашем origin может просто отправлять запросы - браузер приложит куку сам. Токен не украден, зато перевод выполнен. При XSS всё, что доступно пользователю, доступно и атакующему; httpOnly снижает ущерб, а не устраняет его.

Суть: браузер прикладывает куку исходя из адреса назначения, а не из того, кто инициировал запрос. Значит, чужая страница может заставить браузер жертвы выполнить изменяющую операцию на нашем домене - полностью аутентифицированную. Токен в заголовке Authorization в эту схему не попадает: его никто не проставляет автоматически. Защита: SameSite, с Go 1.25 - штатный http.CrossOriginProtection, и токены там, где нужны гарантии.

Атака по шагам

  1. Жертва залогинена на bank.example - кука сессии в браузере.
  2. Жертва открывает evil.com, где лежит форма action="https://bank.example/transfer" method=POST с автосабмитом (или просто <img src>, если операция висит на GET).
  3. Браузер отправляет POST на наш домен и сам прикладывает куку.
  4. Сервер видит валидную сессию и считает запрос осознанным. Перевод выполнен.
  5. Ответ атакующему недоступен (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.

Суть: клиент управляет адресом, по которому наш сервер делает исходящий запрос. Ценность в том, что сервер стоит внутри периметра и видит то, что не видно снаружи: облачные метаданные, внутренние админки, служебные порты. Защита - allowlist, а на техническом уровне - проверка итогового IP перед connect, а не строки URL.

Где появляется и чем опасно

Точки входа: «загрузить картинку по URL», вебхуки, импорт из внешнего источника, генерация превью ссылки, прокси-ручка. Цели атакующего:

  • На 169.254.169.254 лежат метаданные облака с временными ключами роли инстанса. Из-за этого и появился IMDSv2 с обязательным PUT-токеном.
  • Внутренние сервисы без аутентификации «потому что они не торчат наружу»: Kubernetes API, Consul, Elasticsearch, панели.
  • Сканирование сети по времени ответа и коду ошибки (blind SSRF).
  • Смена схемы: file:// читает локальные файлы, а через gopher:// и dict:// собирают произвольный TCP-пакет. http.Client в Go таких схем не знает, но libcurl и конвертеры документов знают.

Защита по убыванию надёжности

  1. Allowlist хостов или заранее зарегистрированные интеграции. Только эта защита не проигрывает гонку с обходами.
  2. Сетевая изоляция: egress через прокси со своей политикой, network policy, запрещающая поду ходить во внутренние подсети напрямую.
  3. Запрет приватных диапазонов на этапе соединения через хук 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 против нас.
Суть: инъекцию ловит линтер и она одинакова везде, а «этот заказ принадлежит этому пользователю» - бизнес-правило, которое надо написать руками в каждом обработчике. Достаточно один раз забыть. Поэтому сломанный контроль доступа стабильно на первом месте в OWASP Top 10 и в отчётах bug bounty.

Как выглядит

// 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 - не защита сервера, а механизм браузера, позволяющий ослабить Same-Origin Policy. Сервер заголовками разрешает конкретному origin читать свой ответ. Никакого влияния на curl, Postman и любой не-браузер он не имеет.

Три следствия, снимающие все вопросы

  • 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-ошибку?» Потому что для простых запросов блокируется чтение ответа, а не отправка.

Суть: все отвечают на вопрос «пропустить или отклонить», но по-разному ведут себя на всплеске. Fixed window пропускает до двойного лимита на границе окна; sliding точен, но дороже; token bucket разрешает всплеск намеренно, в пределах ёмкости; leaky bucket не пропускает всплеск, а растягивает его во времени.

Один и тот же вход: лимит 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, а число одновременных?»

Суть: локальный лимитер защищает процесс и умножается на число реплик; распределённый даёт честный общий лимит, но добавляет зависимость и round-trip на горячем пути. В проде обычно оба: локальный как грубая отсечка, Redis - как бизнес-квота.

Локальный

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 tag rl:{user:42}:min.
  • Несколько лимитов сразу (10/с, 300/мин, 10 000/сут) проверять одним скриптом и списывать, только если прошли все. Иначе счётчики разъедутся.
  • Гибрид: локальное ведро «выкупает» у Redis пакет разрешений на 10–100 запросов. Точность чуть падает, нагрузка на Redis - на порядок.
Суть: оба уровня, и это не компромисс, а разделение задач: периметр отсекает объём как можно раньше и дешевле, приложение реализует бизнес-квоты, для которых нужен контекст (кто пользователь, какой тариф, сколько стоит операция). Ключ по возможности - пользователь или API-ключ; IP - только до аутентификации и с оглядкой на NAT.

Где ставить

УровеньУмеетНе умеет
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

Их часто путают. 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-ancestorsclickjacking
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.

HSTS: две ловушки

Первый запрос всё равно уязвим: о политике браузер узнаёт из уже пришедшего ответа, и помогает тут только 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 ручным экранированием.