Содержание урока · 10 разделов
- Анатомия инъекции: как склейка ломает запрос
- Расширенный протокол: SQL и данные едут раздельно
- Плейсхолдеры по правилам: порядок, повтор, приведение
- Куда параметр ставится, а куда нет
- pgx.NamedArgs: имена вместо цифр
- Белый список: единственный путь для имён колонок
- Чек-лист параметризации одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Начнём с истории, которая повторяется на практике чаще, чем кажется. Форма логина, запрос собран склейкой: fmt.Sprintf("SELECT * FROM users WHERE login = '%s' AND password = '%s'", login, password). На честных данных всё работает — и работает месяцами, пока тестировщик не вводит в поле логина значение ' OR '1'='1. Кавычка из значения закрыла строковый литерал запроса, дальше вписался фрагмент OR '1'='1', который делает условие всегда истинным, — и запрос вернул первого пользователя таблицы вместо пустоты. Аутентификация пройдена без пароля. Это не экзотика, а минимальная форма SQL-инъекции: данные пользователя попали в место, где сервер разбирает их как код запроса, и изменили смысл SQL. Дальнейшее зависит от настроения атакующего: закомментировать остаток условия через --, достать чужие строки через UNION, а при неприятном стечении прав — выполнить разрушительные команды, если драйвер пропускает несколько стейтментов. Инъекция десятилетиями возглавляет списки критичных уязвимостей веб-приложений, и в Go она не лечится валидацией форм или фильтром кавычек — лечится она единственным структурным решением, о котором весь этот урок.
Анатомия инъекции: как склейка ломает запрос#
Поймём механику на уровне символов. Сервер Postgres разбирает текст запроса на лексемы: ключевые слова, имена колонок, строковые литералы в одинарных кавычках, числа. Склейка через Sprintf подмешивает пользовательский текст прямо в этот текст до разбора — и любые кавычки в значении становятся частью грамматики. Значение O'Brien ломает литерал: одиночная кавычка внутри имени закрывает строку раньше времени, и остаток текста сервер пытается прочитать как SQL — запрос падает с синтаксической ошибкой, что уже неприятно. Значение ' OR '1'='1 ломает запрос осознанно: кавычка, фрагмент-вставка, искусственно истинное условие. Значение с -- обрезает хвост запроса, потому что от двойного дефиса до конца строки сервер считает всё комментарием. Экранирование кавычек руками — замена ' на '' — закрывает часть щелей, но это игра в догонялки: остаются нестандартные кодировки, многобайтовые символы вроде байтового эквивалента кавычки в старых GBK-таблицах, ну и человеческий фактор в половине мест склейки. Ключевая мысль: пока данные пользователя едут внутри текста запроса, безопасность держится на аккуратности каждого разработчика в каждом месте. Нужен способ, при котором данные физически не встречаются с грамматикой — и он есть.
Одна и та же авторизация: дыра и защита
GO// ОПАСНО: значение попадает в текст запроса
sql := fmt.Sprintf(
"SELECT id FROM users WHERE login = '%s' AND password = '%s'",
login, password)
var id int64
err := pool.QueryRow(ctx, sql).Scan(&id)
// login = "' OR '1'='1" -> вход без пароля
// БЕЗОПАСНО: значение едет аргументом
err = pool.QueryRow(ctx,
"SELECT id FROM users WHERE login = $1 AND password = $2",
login, password).Scan(&id)
// любая строка в login — просто значение для сравненияВ уязвимой версии Sprintf вшивает данные в SQL до отправки — сервер видит изменённое условие. В защищённой версии текст запроса неизменен, а логин и пароль едут аргументами $1 и $2: даже строка с кавычками сравнивается как обычное значение и не может открыть условие.
Расширенный протокол: SQL и данные едут раздельно#
Плейсхолдеры работают не потому, что драйвер «экранирует кавычки», а потому, что pgx разговаривает с Postgres по расширенному протоколу, где запрос и аргументы — разные сообщения. Сначала уходит Parse: текст с $1 и $2, сервер его разбирает, строит план и запоминает под именем. Потом уходит Bind: аргументы в бинарном виде с явными типами — строка остаётся строкой длиной N байт, число остаётся числом, и ни один байт из них не участвует в разборе грамматики. Потом Execute и синхронизация. Значение, приехавшее по протоколу, не может достроить запрос — для этого ему пришлось бы вернуться на этап Parse, которого нет. Это структурная гарантия, а не фильтр: кавычки, дефисы, точки с запятой в значении — просто символы содержимого. Бонус той же механики — кэш prepared statements из первого урока: второй вызов того же SQL переиспользует разобранный запрос, и на сервер уезжает только Bind с данными. Отсюда же ограничения, о которых ниже: расширенный протокол принимает один стейтмент за вызов, поэтому два запроса через точку с запятой в строке с аргументами не пройдут.
Проверка: почему плейсхолдеры спасают
Почему значение, переданное аргументом $1, не может провести инъекцию?
Что вернёт вызов?
Прочитайте код и выберите, что он выведет:
err := pool.QueryRow(ctx,
"SELECT title FROM notes WHERE author_id = $1 AND id = $2",
7).Scan(&title)
fmt.Println(err)Плейсхолдеры по правилам: порядок, повтор, приведение#
Позиционные плейсхолдеры нумеруются с единицы и заполняются аргументами по порядку вызова: $1 берёт первый аргумент. Полезная деталь, которую знают не все: один аргумент может обслуживать несколько плейсхолдеров — WHERE author_id = $1 OR reviewer_id = $1 с одним аргументом легальна, номер повторно ссылается на тот же параметр. Типы параметров сервер выводит из контекста запроса, но в неоднозначных местах поставьте явное приведение: $1::text или $1::bigint снимает вопрос и делает SQL самодокументируемым. Расхождение количества — ошибка на стороне pgx, как в predict выше: серверу ушло меньше аргументов, чем объявлено. Ограничение расширенного протокола: один вызов — один стейтмент, так что SELECT ...; DELETE ... в одной строке с аргументами не выполнится — пачки запросов делаются через Batch из восемнадцатого урока, а не через склейку. И правило больших запросов: чем длиннее SQL, тем раньше соблазн склеить фрагмент «для удобства». Соблазн местный: 95 процентов фрагментов — это значения, которые параметризуются без разговоров, а оставшиеся 5 — имена колонок и таблиц, для которых существует отдельный приём из следующего раздела.
Куда параметр ставится, а куда нет#
| Место в SQL | Аргумент $1 | Как правильно | Почему |
|---|---|---|---|
| WHERE title = ... | да | WHERE title = $1 | значение колонки — обычный параметр |
| LIMIT и OFFSET | да | LIMIT $1 OFFSET $2 | число — тоже значение |
| список для IN | да, с ANY | WHERE id = ANY($1) | срез уезжает массивом-параметром |
| имя таблицы или колонки | нет | белый список или pgx.Identifier | идентификатор — часть грамматики, не значение |
| ORDER BY колонка | нет | белый список в коде | сортировка по константе бессмысленна |
Лента с переключателем сортировки, sort приходит из строки запроса, и кто-то вшивает его в SQL склейкой — ORDER BY со значением прямо в тексте. Вставка вида ORDER BY (SELECT CASE WHEN условие THEN title ELSE created_at END) превращает сортировку в канал утечки: истинное и ложное условие дают разный порядок строк, и по одному биту за запрос атакующий вычитывает базу из ответов. С подзапросом внутрь затягивается и pg_sleep — тогда разница приходит по времени ответа. Ни той ни другой утечки в интерфейсе не видно: данные выглядят обычным списком, просто в странном порядке. Правильный вариант — карта допустимых значений: orderBy := allowedSorts[sort] с дефолтом при промахе. Белый список короче уязвимости на порядок и читается за секунду.
pgx.NamedArgs: имена вместо цифр#
В длинных запросах цифры $7 и $11 начинают путаться: переставили аргумент — вся нумерация поехала, и компилятор не подскажет. pgx умеет именованные плейсхолдеры: первым аргументом передаётся pgx.NamedArgs, и каждый @name в тексте переписывается в позиционный плейсхолдер с собранными по порядку аргументами. Запрос WHERE author_id = @author AND status = @status с картой значений эквивалентен позиционной записи — но имена читаются, и порядок в карте не важен. Имена чувствительны к регистру и начинаются с буквы или подчёркивания. Строгий вариант pgx.StrictNamedArgs дополнительно проверяет полноту: лишние ключи в карте и пропущенные имена в запросе дают ошибку — режим для библиотечного кода, где контракт хочется зафиксировать. Под капотом оба работают через интерфейс QueryRewriter — его можно реализовать и самому, если нужен свой способ переписывания запроса; стандартные два покрывают, по моему опыту, все реальные нужды. На сервер уезжает всё тот же позиционный протокол: именованная запись — чисто клиентское удобство, поэтому никакой новой поверхности атаки она не добавляет.
Именованные аргументы и сортировка из белого списка
GOvar allowedSorts = map[string]string{
"new": "created_at DESC",
"old": "created_at ASC",
"title": "title ASC",
}
func ListNotes(ctx context.Context, pool *pgxpool.Pool,
author int64, sort string) ([]Note, error) {
orderBy, ok := allowedSorts[sort]
if !ok {
orderBy = "created_at DESC"
}
sql := "SELECT id, title, created_at FROM notes " +
"WHERE author_id = @author ORDER BY " + orderBy
rows, err := pool.Query(ctx, sql, pgx.NamedArgs{"author": author})
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByName[Note])
}Значение автора едет именованным аргументом @author — данные не касаются текста SQL и не могут его изменить. Фрагмент сортировки — единственное, что вшивается строкой, и он берётся из карты-константы в коде: незнакомый sort откатывается к дефолту, и инъекция через него невозможна. Оба приёма живут рядом в одном методе — как в реальном репозитории.
Проверка: идентификаторы
Пользователь выбирает таблицу отчёта из фиксированного набора. Как безопасно вшить её имя в SQL?
Белый список: единственный путь для имён колонок#
Правило из таблицы стоит закрепить кодом. Имена таблиц, колонок и направление сортировки — не значения, поэтому плейсхолдеры тут бессильны; зато такие имена почти всегда принадлежат фиксированному набору, известному на момент написания кода. Карта map[string]string в коде решает вопрос целиком: вход из внешнего мира либо совпал с ключом, либо получил дефолт, третьего не дано. Если идентификатор всё же собирается из частей — схема плюс имя, — в pgx есть pgx.Identifier: срез строк, который метод Sanitize() оборачивает в корректные двойные кавычки с удвоением внутренних кавычек, как это делает quote_ident на сервере. Чего делать не стоит, так это писать собственный экранировщик идентификаторов или переиспользовать функции для строковых литералов: они для другого контекста и других кавычек. Отдельно про отладку: иногда хочется увидеть итоговый SQL с подставленными значениями — в простом протоколе pgx подставляет аргументы сам, внутренним санитайзером, и это единственное место, где интерполяция легальна и уже реализована лучше, чем получится руками. В логи же правильнее писать текст с плейсхолдерами и отдельным массивом аргументов — тогда значения не путаются с SQL при разборе инцидентов.
Спорное мнение, которое я защищаю на ревью: любая склейка SQL в прикладном коде — плохой запах, и белый список не исключение, а лишь контролируемая уступка. Условие моей правоты: карта допустимых фрагментов живёт в коде как константа и проходит ревью вместе с запросом. Как только белый список переезжает в конфиг или в таблицу базы — он превращается в данные, которые кто-то изменит в проде без ревью, и граница «что может попасть в SQL» снова размыта. Что сломалось бы при отмене правила: через полгода в коде появится пятая склейка «по аналогии», и ревью уже не отличит константу от значения из формы. Держите исключение в одном месте и одним паттерном — ORDER BY из карты, всё остальное через $1.
Чек-лист параметризации одним списком#
- Все значения — через плейсхолдеры $1, без единого Sprintf рядом с SQL
- Число аргументов совпадает с числом плейсхолдеров — рассинхрон даёт ошибку до отправки
- Списки для IN — через = ANY($1) со срезом, а не склейкой запятых
- Имена таблиц, колонок и направление ORDER BY — белый список в коде или pgx.Identifier
- Длинные запросы — NamedArgs с именами вместо $7
- Несколько команд одной строкой — Batch, не точка с запятой
- В логи — SQL с плейсхолдерами плюс отдельный массив значений
- Один раз в спринт: rg по репозиторию на Sprintf рядом с SELECT — дешевле пентеста
Соберите защищённый запрос
Восстановите плейсхолдеры и именованные аргументы.
Соберите безопасный метод выборки
Расставьте строки метода с белым списком и аргументом в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: плейсхолдеры и инъекции
Проверить этот урок руками можно только локально: песочница сайта исполняет тематическую программу на чистом Go без сети, а для атаки и защиты нужен настоящий сервер — поднимите контейнер Postgres из первого урока, соберите уязвимую версию авторизации, введите ' OR '1'='1 в поле логина и посмотрите на результат глазами тестировщика. Пять минут на воспроизведение дыры убеждают надёжнее любых правил. Дальше по курсу: в девятом уроке — работа через интерфейс database/sql, где те же плейсхолдеры выглядят иначе; в шестнадцатом — разбор pgconn.PgError, в который превращается и синтаксическая ошибка от неудачной склейки. А правило из predict — сверять число аргументов с числом плейсхолдеров — отработает вам первые баги ещё до пуша: ошибка expected N arguments, got M пишет в тексте всё, что нужно для исправления.
Параметризация закрывает внедрение кода, но не конфликты уникальности, гонки записей и потерю обновлений. Вставка с дубликатом вернёт 23505 — штатную ошибку, которую разбирает семнадцатый урок; там же upsert как альтернатива проверке-потом-вставке, в которой гонки как раз и живут.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Инъекция возможна, пока данные едут внутри текста SQL; Sprintf рядом с запросом — маркер уязвимости.
- Расширенный протокол разделяет Parse и Bind: аргумент не может изменить разобранный запрос.
- Число аргументов обязано совпадать с числом плейсхолдеров — иначе expected N arguments, got M.
- LIMIT, OFFSET и списки через = ANY($1) параметризуются; имена таблиц, колонок и ORDER BY — нет.
- Для идентификаторов — белый список в коде или pgx.Identifier с Sanitize().
- pgx.NamedArgs переписывает @name в позиционные плейсхолдеры; StrictNamedArgs строг к лишним и пропущенным ключам.
- Именованные плейсхолдеры — клиентское удобство: на сервер уходит тот же позиционный протокол.
Домашнее задание
Воспроизведите инъекцию на локальной базе: соберите авторизацию через Sprintf и войдите со значением ' OR '1'='1, зафиксируйте результат. Перепишите запрос на плейсхолдеры и повторите ввод — убедитесь, что теперь это просто невалидный логин. Добавьте к таблице notes эндпоинт сортировки с картой allowedSorts и проверьте, что незнакомое значение откатывается к дефолту. В конце соберите запрос с = ANY($1) для поиска по нескольким авторам и сравните его со склеенным вариантом из чата поддержки, который вы наверняка однажды увидите.
Личные заметкиПусто
Вопросы и ответы по уроку
Экранирование кавычек заменяет плейсхолдеры?
Нет. Ручное экранирование закрывает только строковые литералы и оставляет открытыми проблемы кодировок, числовых контекстов и человеческих пропусков. Числовой контекст — та же дыра, хоть кавычек в нём и нет: склейка LIMIT %s со значением из query-строки — та же инъекция, поэтому LIMIT и OFFSET из таблицы выше параметризуются как любое значение. Плейсхолдеры устраняют саму возможность попасть данным в разбираемый текст — это структурная гарантия, а не набор правил.
Почему нельзя передать имя колонки параметром $1?
Параметр — это значение для сравнения или вставки, а идентификатор — элемент грамматики SQL, который разбирается на этапе Parse. Причина глубже синтаксиса: план запроса строится для фиксированных колонок, и значение параметра в их выборе участвовать не может — сервер обязан заранее знать, по каким столбцам читать данные. Для динамических имён существует белый список и pgx.Identifier с Sanitize().
Что безопаснее: позиционные $1 или NamedArgs?
Безопасность одинаковая — оба варианта доезжают до сервера позиционным протоколом, и QueryRewriter ничего не решает о защите. NamedArgs выигрывает в читаемости длинных запросов и устойчивости к перестановкам аргументов, позиционные короче в коротких запросах. Я пишу $1 до трёх аргументов и перехожу на имена дальше — граница не жёсткая, важно не смешивать стили внутри одного запроса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup