Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 8 из 25 ~18 мин +20 XP

Плейсхолдеры $1 и SQL-инъекции

SQL-инъекции в Go случаются не из-за слабого драйвера, а из-за одной привычки: склеивать SQL строками через fmt.Sprintf вместо плейсхолдеров $1, которые для этого и придуманы. Разберём, как аргументы pgx уезжают на сервер отдельно от текста запроса, почему значение не может изменить структуру запроса в принципе и что делать с местами, куда параметр просто так не ставится — ORDER BY и имена таблиц.

Содержание урока · 10 разделов

Начнём с истории, которая повторяется на практике чаще, чем кажется. Форма логина, запрос собран склейкой: 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да, с ANYWHERE id = ANY($1)срез уезжает массивом-параметром
имя таблицы или колонкинетбелый список или pgx.Identifierидентификатор — часть грамматики, не значение
ORDER BY колонканетбелый список в кодесортировка по константе бессмысленна

pgx.NamedArgs: имена вместо цифр#

В длинных запросах цифры $7 и $11 начинают путаться: переставили аргумент — вся нумерация поехала, и компилятор не подскажет. pgx умеет именованные плейсхолдеры: первым аргументом передаётся pgx.NamedArgs, и каждый @name в тексте переписывается в позиционный плейсхолдер с собранными по порядку аргументами. Запрос WHERE author_id = @author AND status = @status с картой значений эквивалентен позиционной записи — но имена читаются, и порядок в карте не важен. Имена чувствительны к регистру и начинаются с буквы или подчёркивания. Строгий вариант pgx.StrictNamedArgs дополнительно проверяет полноту: лишние ключи в карте и пропущенные имена в запросе дают ошибку — режим для библиотечного кода, где контракт хочется зафиксировать. Под капотом оба работают через интерфейс QueryRewriter — его можно реализовать и самому, если нужен свой способ переписывания запроса; стандартные два покрывают, по моему опыту, все реальные нужды. На сервер уезжает всё тот же позиционный протокол: именованная запись — чисто клиентское удобство, поэтому никакой новой поверхности атаки она не добавляет.

Именованные аргументы и сортировка из белого списка

GO
var 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 при разборе инцидентов.

Чек-лист параметризации одним списком#

  • Все значения — через плейсхолдеры $1, без единого Sprintf рядом с SQL
  • Число аргументов совпадает с числом плейсхолдеров — рассинхрон даёт ошибку до отправки
  • Списки для IN — через = ANY($1) со срезом, а не склейкой запятых
  • Имена таблиц, колонок и направление ORDER BY — белый список в коде или pgx.Identifier
  • Длинные запросы — NamedArgs с именами вместо $7
  • Несколько команд одной строкой — Batch, не точка с запятой
  • В логи — SQL с плейсхолдерами плюс отдельный массив значений
  • Один раз в спринт: rg по репозиторию на Sprintf рядом с SELECT — дешевле пентеста

Соберите защищённый запрос

Восстановите плейсхолдеры и именованные аргументы.

rows, err := pool.Query(ctx, "SELECT id, title FROM notes WHERE author_id = AND status = ANY()", pgx.{"author": author, "statuses": statuses}) if err != nil { log.Fatal(err) }

Соберите безопасный метод выборки

Расставьте строки метода с белым списком и аргументом в правильном порядке.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: плейсхолдеры и инъекции

Термин

SQL-инъекция

нажмите, чтобы перевернуть
Определение

Данные пользователя попадают в текст SQL до разбора и меняют структуру запроса. Лечится параметризацией, а не фильтрацией символов.

1 / 6

Проверить этот урок руками можно только локально: песочница сайта исполняет тематическую программу на чистом Go без сети, а для атаки и защиты нужен настоящий сервер — поднимите контейнер Postgres из первого урока, соберите уязвимую версию авторизации, введите ' OR '1'='1 в поле логина и посмотрите на результат глазами тестировщика. Пять минут на воспроизведение дыры убеждают надёжнее любых правил. Дальше по курсу: в девятом уроке — работа через интерфейс database/sql, где те же плейсхолдеры выглядят иначе; в шестнадцатом — разбор pgconn.PgError, в который превращается и синтаксическая ошибка от неудачной склейки. А правило из predict — сверять число аргументов с числом плейсхолдеров — отработает вам первые баги ещё до пуша: ошибка expected N arguments, got M пишет в тексте всё, что нужно для исправления.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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) для поиска по нескольким авторам и сравните его со склеенным вариантом из чата поддержки, который вы наверняка однажды увидите.

Личные заметкиПусто
Хранятся только в вашем браузере (pgx · Плейсхолдеры $1 и SQL-инъекции)

Вопросы и ответы по уроку

Экранирование кавычек заменяет плейсхолдеры?

Нет. Ручное экранирование закрывает только строковые литералы и оставляет открытыми проблемы кодировок, числовых контекстов и человеческих пропусков. Числовой контекст — та же дыра, хоть кавычек в нём и нет: склейка LIMIT %s со значением из query-строки — та же инъекция, поэтому LIMIT и OFFSET из таблицы выше параметризуются как любое значение. Плейсхолдеры устраняют саму возможность попасть данным в разбираемый текст — это структурная гарантия, а не набор правил.

Почему нельзя передать имя колонки параметром $1?

Параметр — это значение для сравнения или вставки, а идентификатор — элемент грамматики SQL, который разбирается на этапе Parse. Причина глубже синтаксиса: план запроса строится для фиксированных колонок, и значение параметра в их выборе участвовать не может — сервер обязан заранее знать, по каким столбцам читать данные. Для динамических имён существует белый список и pgx.Identifier с Sanitize().

Что безопаснее: позиционные $1 или NamedArgs?

Безопасность одинаковая — оба варианта доезжают до сервера позиционным протоколом, и QueryRewriter ничего не решает о защите. NamedArgs выигрывает в читаемости длинных запросов и устойчивости к перестановкам аргументов, позиционные короче в коротких запросах. Я пишу $1 до трёх аргументов и перехожу на имена дальше — граница не жёсткая, важно не смешивать стили внутри одного запроса.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

MongoDB25 уроков~12 чпрактика25 уроков уже доступны

MongoDB в Go: официальный драйвер mongo-driver v2

Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.

  • Подключаться к MongoDB и настраивать клиента
  • Моделировать документы структурами и bson-тегами
  • Делать CRUD, сложные фильтры и проекции
  • Строить агрегации $match/$group/$lookup