---
title: "Плейсхолдеры $1 и SQL-инъекции"
description: "Плейсхолдеры $1 в pgx против SQL-инъекций в Go: почему Sprintf в SQL опасен, как едет расширенный протокол, NamedArgs и белый список для ORDER BY. Примеры."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/08-argumenty-zaprosa/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 8 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/08-argumenty-zaprosa/*

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

Начнём с истории, которая повторяется на практике чаще, чем кажется. Форма логина, запрос собран склейкой: 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, не может провести инъекцию?

- pgx удаляет все кавычки и спецсимволы из аргументов
- ✅ Аргументы едут отдельным сообщением протокола и не участвуют в разборе SQL
- Сервер проверяет аргументы на подозрительные шаблоны
- pgx шифрует аргументы перед отправкой

Расширенный протокол разбирает SQL на этапе Parse, а аргументы привязываются на этапе Bind как бинарные значения с типами. Ничего не экранируется и не фильтруется: значение просто не встречается с грамматикой запроса.

### Что вернёт вызов?

```go
err := pool.QueryRow(ctx,
        "SELECT title FROM notes WHERE author_id = $1 AND id = $2",
        7).Scan(&title)

fmt.Println(err)
```

**Что выведет программа?**

- <nil> — недостающий плейсхолдер примет ноль
- ✅ expected 2 arguments, got 1
- ошибка синтаксиса SQL от сервера
- ErrNoRows — строка не нашлась

Число плейсхолдеров и число аргументов проверяется на стороне pgx: два плейсхолдера и один аргумент дают ошибку вида expected 2 arguments, got 1. Никаких значений по умолчанию нет — рассинхрон это баг, а не пустой результат.

### Плейсхолдеры по правилам: порядок, повтор, приведение

Позиционные плейсхолдеры нумеруются с единицы и заполняются аргументами по порядку вызова: $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 колонка | нет | белый список в коде | сортировка по константе бессмысленна |

> **ORDER BY из query-параметра — классическая дыра.** Лента с переключателем сортировки, 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 — его можно реализовать и самому, если нужен свой способ переписывания запроса; стандартные два покрывают, по моему опыту, все реальные нужды. На сервер уезжает всё тот же позиционный протокол: именованная запись — чисто клиентское удобство, поэтому никакой новой поверхности атаки она не добавляет.

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

```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?

- Экранировать кавычки в имени и склеить Sprintf-ом
- ✅ Взять имя из карты-белого списка или собрать pgx.Identifier и вызвать Sanitize()
- Передать имя таблицы аргументом $1
- Использовать NamedArgs с ключом table

Имя таблицы — часть грамматики SQL, параметром оно быть не может. Для фиксированного набора достаточно белого списка; pgx.Identifier([]string{schema, table}).Sanitize() кавычками экранирует идентификатор, если он собирается динамически.

### Белый список: единственный путь для имён колонок

Правило из таблицы стоит закрепить кодом. Имена таблиц, колонок и направление сортировки — не значения, поэтому плейсхолдеры тут бессильны; зато такие имена почти всегда принадлежат фиксированному набору, известному на момент написания кода. Карта 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
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)
}
```

Подсказка: Именованный плейсхолдер начинается с собаки, а карта значений передаётся типом pgx.NamedArgs.

Правильные ответы в пропусках: `@author`, `@statuses`, `NamedArgs`

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

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

Правильный порядок строк:

1. `var allowedSorts = map[string]string{"new": "created_at DESC"}`
2. `orderBy, ok := allowedSorts[sort]`
3. `if !ok { orderBy = "created_at DESC" }`
4. `sql := "SELECT id, title FROM notes ORDER BY " + orderBy`
5. `rows, err := pool.Query(ctx, sql, pgx.NamedArgs{"author": id})`
6. `if err != nil {`
7. `  return nil, err`
8. `}`
9. `return pgx.CollectRows(rows, pgx.RowToStructByName[Note])`

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

- **SQL-инъекция** — Данные пользователя попадают в текст SQL до разбора и меняют структуру запроса. Лечится параметризацией, а не фильтрацией символов.
- **Плейсхолдер $1** — Позиционный параметр запроса. Значение едёт отдельным сообщением протокола и не участвует в разборе SQL.
- **Расширенный протокол** — Parse — сервер разбирает SQL с $1, Bind — аргументы в бинарном виде, Execute — выполнение. Кэш prepared statements строится на этом же.
- **pgx.NamedArgs** — Именованные плейсхолдеры @name с картой значений; переписываются в позиционные. StrictNamedArgs проверяет полноту ключей.
- **pgx.Identifier** — Срез частей идентификатора; Sanitize() оборачивает их в корректные двойные кавычки для безопасной вставки имени таблицы или колонки.
- **= ANY($1)** — Параметризованный аналог IN со списком: срез уезжает одним массивом-параметром вместо склейки значений запятыми.

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

> **Инъекция — не единственный враг данных.** Параметризация закрывает внедрение кода, но не конфликты уникальности, гонки записей и потерю обновлений. Вставка с дубликатом вернёт 23505 — штатную ошибку, которую разбирает [семнадцатый урок](/courses/pgx/lessons/17-unikalnost-23505/); там же upsert как альтернатива проверке-потом-вставке, в которой гонки как раз и живут.

## Коротко: что запомнить

- Инъекция возможна, пока данные едут внутри текста 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 до трёх аргументов и перехожу на имена дальше — граница не жёсткая, важно не смешивать стили внутри одного запроса.
