Подключение из 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 с дефолтами под песочницу:

go
pool, err := pg.NewPool(ctx)   // пул соединений к песочнице
defer pool.Close()             // вернуть все соединения при выходе
if err := pool.Ping(ctx); err != nil { /* БД недоступна */ }

Пул ленивый: реального соединения после NewPool ещё нет, оно установится при первом запросе. Ping — способ проверить доступность БД явно: если песочница не поднята, ошибка прилетит здесь, а не в середине бизнес-логики. (Сам жизненный цикл соединений в пуле — отдельная тема, ей посвящён 00-06.)

Параметры $1: почему это не про удобство, а про безопасность

Запрос с параметром выглядит так:

go
rows, err := pool.Query(ctx, "SELECT ... FROM drinks WHERE category = $1", "coffee")

$1 — это плейсхолдер, а "coffee" едет отдельным аргументом. Ключевой момент в том, как это уходит на сервер: текст SQL и значения параметров передаются раздельно, разными полями протокола. Сервер парсит SQL (с плейсхолдерами) один раз, строит план — и только потом подставляет значения в уже готовый план. Значение $1 физически не может стать частью SQL: оно никогда не проходит через парсер запроса.

Сравни со склейкой строкой:

go
// ❌ НИКОГДА так не делай
sql := "SELECT ... FROM drinks WHERE category = '" + input + "'"

Здесь input становится текстом запроса. Для честного coffee получится корректный SQL. Но для ' OR 1=1 -- получится:

sql
SELECT ... FROM drinks WHERE category = '' OR 1=1 --'

Кавычка закрылась раньше времени, OR 1=1 сделал условие всегда истинным, а -- закомментировал хвост. Фильтр обойдён, таблица утекла. С параметром $1 тот же ввод — это просто строковое значение категории ' OR 1=1 --, которого в меню нет: ноль строк. Вот и весь механизм: разделять код и данные.

Если нарисовать, как это уходит на сервер:

plaintext
   склейка строкой — ОДИН конверт, данные вклеиваются в текст запроса:
 
     "… WHERE category = '" + input + "'"   ─▶  единый текст SQL  ─▶  парсер
        input = ' OR 1=1 --  становится частью запроса ──────────────┘  (стал кодом)
 
   параметр $1 — ДВА конверта, значение едет мимо парсера:
 
     конверт 1 (текст):    "… WHERE category = $1"   ─▶  парсер ─▶ план с дыркой $1
     конверт 2 (значение): "coffee"  ──────────────────────────▶ подстановка в план
                                                                  (через парсер НЕ идёт)

pgx подталкивает к правильному пути by design — у него нет API «выполни вот эту склеенную строку с данными внутри», только запрос-с-плейсхолдерами + аргументы. Чтобы выстрелить себе в ногу, инъекцию надо собрать руками намеренно (что мы и делаем в анти-демо — на безопасной read-only песочнице).

Что показывает наш код

В центре — main.go. Запрос пишем строкой, а строки результата раскладываем в структуру руками:

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:

sh
docker compose up -d
make lecture L=00-getting-connected/00-04-connecting-from-go T=db-reset

Запусти демо:

sh
make lecture L=00-getting-connected/00-04-connecting-from-go

(T=run — значение по умолчанию. Изнутри каталога юнита это просто make db-reset и make run.)

Вывод:

plaintext
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 — и получим типизированный метод, где порядок и типы колонок проверены против схемы на этапе сборки, а не в рантайме.

·Модуль 01

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Подключение и ориентация / Подключение из Go