Подключение из Go
В Brew появилась фича: поиск напитков по категории. Разработчик собрал запрос так, как кажется естественным новичку — склеил строку: "... WHERE category = '" + input + "'". На демо всё работало. А на ревью безопасности выяснилось, что строка ' OR 1=1 -- в поле поиска возвращает всё меню целиком, в обход любого фильтра. Это SQL-инъекция, и это не экзотика, а та самая ошибка №1 из любого списка веб-уязвимостей.
Цель юнита — сделать первый запрос к Postgres из Go правильно: открыть пул соединений pgxpool, выполнить запрос с параметром через $1 и на одном вводе увидеть разницу между склейкой строки и биндингом. Это raw-pgx юнит: мы намеренно пишем rows.Scan руками, чтобы в следующем юните (00-05) увидеть, что именно у нас заберёт sqlc.
Пул соединений: pgxpool, а не одно соединение
Из 00-02 мы знаем: чтобы что-то сделать с данными, нужно соединение. Но открывать новое TCP-соединение на каждый запрос дорого — рукопожатие, аутентификация, настройка сессии занимают миллисекунды, которые под нагрузкой превращаются в узкое место. Поэтому драйвер pgx работает через пул: набор уже открытых соединений, которые переиспользуются. Запросил соединение → выполнил запрос → вернул в пул, не закрывая.
В курсе пул создаётся одним вызовом internal/pg.NewPool — он читает DATABASE_URL и отдаёт готовый *pgxpool.Pool с дефолтами под песочницу:
pool, err := pg.NewPool(ctx) // пул соединений к песочнице
defer pool.Close() // вернуть все соединения при выходе
if err := pool.Ping(ctx); err != nil { /* БД недоступна */ }Пул ленивый: реального соединения после NewPool ещё нет, оно установится при первом запросе. Ping — способ проверить доступность БД явно: если песочница не поднята, ошибка прилетит здесь, а не в середине бизнес-логики. (Сам жизненный цикл соединений в пуле — отдельная тема, ей посвящён 00-06.)
Параметры $1: почему это не про удобство, а про безопасность
Запрос с параметром выглядит так:
rows, err := pool.Query(ctx, "SELECT ... FROM drinks WHERE category = $1", "coffee")$1 — это плейсхолдер, а "coffee" едет отдельным аргументом. Ключевой момент в том, как это уходит на сервер: текст SQL и значения параметров передаются раздельно, разными полями протокола. Сервер парсит SQL (с плейсхолдерами) один раз, строит план — и только потом подставляет значения в уже готовый план. Значение $1 физически не может стать частью SQL: оно никогда не проходит через парсер запроса.
Сравни со склейкой строкой:
// ❌ НИКОГДА так не делай
sql := "SELECT ... FROM drinks WHERE category = '" + input + "'"Здесь input становится текстом запроса. Для честного coffee получится корректный SQL. Но для ' OR 1=1 -- получится:
SELECT ... FROM drinks WHERE category = '' OR 1=1 --'Кавычка закрылась раньше времени, OR 1=1 сделал условие всегда истинным, а -- закомментировал хвост. Фильтр обойдён, таблица утекла. С параметром $1 тот же ввод — это просто строковое значение категории ' OR 1=1 --, которого в меню нет: ноль строк. Вот и весь механизм: разделять код и данные.
Если нарисовать, как это уходит на сервер:
склейка строкой — ОДИН конверт, данные вклеиваются в текст запроса:
"… WHERE category = '" + input + "'" ─▶ единый текст SQL ─▶ парсер
input = ' OR 1=1 -- становится частью запроса ──────────────┘ (стал кодом)
параметр $1 — ДВА конверта, значение едет мимо парсера:
конверт 1 (текст): "… WHERE category = $1" ─▶ парсер ─▶ план с дыркой $1
конверт 2 (значение): "coffee" ──────────────────────────▶ подстановка в план
(через парсер НЕ идёт)pgx подталкивает к правильному пути by design — у него нет API «выполни вот эту склеенную строку с данными внутри», только запрос-с-плейсхолдерами + аргументы. Чтобы выстрелить себе в ногу, инъекцию надо собрать руками намеренно (что мы и делаем в анти-демо — на безопасной read-only песочнице).
Что показывает наш код
В центре — main.go. Запрос пишем строкой, а строки результата раскладываем в структуру руками:
type drink struct {
id int64
sku, name string
category string
basePrice int64
}
rows, err := pool.Query(ctx, "SELECT id, sku, name, category, base_price FROM drinks WHERE category = $1", "coffee")
defer rows.Close()
for rows.Next() {
var d drink
if err := rows.Scan(&d.id, &d.sku, &d.name, &d.category, &d.basePrice); err != nil {
return err
}
out = append(out, d)
}
return rows.Err()Этот цикл — Query → for rows.Next → Scan → rows.Err — и есть «ручной» способ читать данные из Postgres в Go. Он работает, но в нём легко ошибиться: перепутать порядок колонок в Scan, забыть rows.Err(), не закрыть rows. Запомни этот код — в 00-05 ровно его сгенерирует sqlc из query.sql, и сравнение будет наглядным.
Анти-демо использует тот же queryDrinks, но дважды на злонамеренном вводе: один раз через небезопасную склейку, один раз через $1. Разница — в выводе.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=00-getting-connected/00-04-connecting-from-go T=db-resetЗапусти демо:
make lecture L=00-getting-connected/00-04-connecting-from-go(T=run — значение по умолчанию. Изнутри каталога юнита это просто make db-reset и make run.)
Вывод:
1) Параметризованный поиск: category = $1, значение 'coffee' — штатный путь.
ID SKU НАЗВАНИЕ КАТЕГОРИЯ ЦЕНА
1 ESP-01 Эспрессо coffee 3.00
2 CAP-01 Капучино coffee 4.50
3 LAT-01 Латте coffee 4.80
2) Злонамеренный ввод в поле «категория»: ' OR 1=1 --
Небезопасно (склейка строкой): запросили одну категорию — сервер вернул 5 строк (вся таблица утекла).
Безопасно ($1 как параметр): тот же ввод — это литерал категории, совпадений нет, 0 строк.Штатный поиск вернул 3 напитка категории coffee. Тот же злонамеренный ввод: при склейке утекли все 5 строк меню, при биндинге — ноль. Один и тот же текст — разный исход, и решает его только то, как значение попало в запрос.
Заборчик
- SQL руками строками не склеивают никогда — ни для пользовательского ввода, ни «для значений из конфига, они же доверенные». Единственный правильный способ передать значение в запрос — параметр (
$1,$2, …). В этом юните мы пишем SQL строкой намеренно, чтобы показать механику; в боевом коде у тебя и так не будет соблазна —pgxпринимает только запрос-с-плейсхолдерами. rows.Scanруками — это допустимо, но это boilerplate, в котором легко ошибиться молча (перепутанный порядок колонок компилятор не поймает). Поэтому дефолт курса — не raw pgx, а sqlc: следующий юнит уберёт этот ручной разбор, оставив сам SQL.
Что забрать с собой
pgxpool— пул переиспользуемых соединений;pg.NewPoolотдаёт готовый пул,pool.Pingпроверяет доступность БД. Пул ленивый: соединение открывается при первом запросе.- Параметр
$1и значение едут на сервер раздельно: значение не проходит через парсер SQL и не может стать кодом. Это закрывает SQL-инъекцию на корню. - Склейка SQL строками открывает инъекцию (
' OR 1=1 --→ утечка всей таблицы). Передавай значения только параметрами. - Ручной
Query → Scan → rows.Errработает, но это boilerplate — его за нас сгенерирует sqlc.
Дальше — юнит 00-05 «типизированные запросы через sqlc»: возьмём ровно этот ручной разбор строк и заменим его кодогенерацией. Напишем query.sql с параметром $1, выполним sqlc generate — и получим типизированный метод, где порядок и типы колонок проверены против схемы на этапе сборки, а не в рантайме.