Справочник DBA: базы данных изнутри читается по порядку

Статья 11.5 · раздел 11 «Безопасность и соответствие» · 84 из 109

Параметры запроса глазами DBA: Parse и Bind, что видно в pg_stat_statements, statement_timeout и права как ограничитель ущерба

опубликовано
читать
≈ 8 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18 с --memory 512m и shared_preload_libraries=pg_stat_statements; база shop, таблица sales.customers на три строки; клиент psql из того же контейнера по 127.0.0.1 (trust); опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

В pg_stat_statements внезапно триста записей на один и тот же запрос, а в логе видны чужие значения прямо в тексте. Это не поломка базы: приложение собирает текст запроса из значений, и каждое новое значение, а иногда и каждая новая форма, превращается в новый текст. Если же приложение передаёт значения параметрами, база получает текст ... name = $1 и значение по отдельным сообщениям протокола: форма запроса не зависит от ввода. Как pg_stat_statements считает формы, разобрано в статье про нормализацию и queryid, а как параметры влияют на планы — в статье про prepared statements. Здесь смотрим с другой стороны: что на уровне протокола, лога и прав видит и ограничивает DBA. Уязвимостей не воспроизводим: все значения ниже безобидные.

DBA / 01

Куда попадает ввод: в текст запроса или в Bind

Простой и расширенный протокол: куда попадает пользовательское значение Простой протокол: сообщение Query содержит значения внутри текста, поэтому цепочка из 300 разных длин даёт 300 queryid в pg_stat_statements. Расширенный протокол: Parse несёт текст с $1, Bind несёт значение отдельно, получается один queryid и три вызова. Ниже ограничители роли app: COPY PROGRAM и схема hr отвечают permission denied, statement_timeout 1 секунда отменяет запрос, idle_in_transaction_session_timeout 2 секунды обрывает сессию. простой протокол: значение внутри текста запроса расширенный протокол: текст и значения раздельно Query name = 'tag1' OR name = 'tag2' queryid: 300 300 длин цепочки — 300 строк Parse name = $1 Bind $1 = 'O''Brien' queryid: 1 calls = 3, rows = 3 если ошибка в коде всё же случилась: ограничители роли app COPY ... PROGRAM: permission denied схема hr: permission denied statement_timeout 1s: отмена запроса idle in transaction 2s: обрыв сессии
Простой протокол приносит ввод в текст запроса, и форма запроса меняется вместе с вводом. В расширенном форма фиксирована, ввод идёт отдельным сообщением. Права роли и таймауты не зависят от того, как собран запрос, и ограничивают последствия ошибки.

Опыт 1: параметры в расширенном протоколе

Контейнер с pg_stat_statements, база с двумя схемами и роль приложения без владения объектами. В таблице sales.customers три клиента, у третьего в имени кавычка и служебные слова: это обычная строка.

docker run -d --name dba-params --memory 512m -e POSTGRES_PASSWORD=pg postgres:18 \
  -c shared_preload_libraries=pg_stat_statements
CREATE ROLE owner_r NOLOGIN;
CREATE ROLE app LOGIN PASSWORD 'a';
CREATE DATABASE shop OWNER owner_r;
\c shop
CREATE EXTENSION pg_stat_statements;
REVOKE ALL ON DATABASE shop FROM PUBLIC;
GRANT CONNECT ON DATABASE shop TO app;
CREATE SCHEMA sales AUTHORIZATION owner_r;
CREATE SCHEMA hr AUTHORIZATION owner_r;
GRANT USAGE ON SCHEMA sales TO app;
SET ROLE owner_r;
CREATE TABLE sales.customers (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text, city text);
CREATE TABLE hr.salaries (id int, amount int);
GRANT SELECT, INSERT, UPDATE, DELETE ON sales.customers TO app;
INSERT INTO sales.customers(name, city) VALUES
  ('Anna','Kazan'), ('O''Brien','Perth'), ('x''; DROP TABLE sales.customers; --','Oslo');
RESET ROLE;
ALTER SYSTEM SET log_min_duration_statement = 0;
SELECT pg_reload_conf();

\bind в psql отправляет текст сообщением Parse, значения сообщением Bind, затем Execute. Запрос вызывается под ролью app (docker exec -i dba-params psql -h 127.0.0.1 -U app -d shop) три раза, с каждым значением; ниже первый и последний результаты, два других вернули по одной строке (O'Brien и Anna):

SELECT id, name FROM sales.customers WHERE name = $1 \bind 'x''; DROP TABLE sales.customers; --' \g
SELECT id, name FROM sales.customers WHERE name = $1 \bind 'O''Brien' \g
SELECT id, name FROM sales.customers WHERE name = $1 \bind 'Anna' \g
SELECT count(*) AS customers_left FROM sales.customers \bind \g
вывод
 id |                name                
----+------------------------------------
  3 | x'; DROP TABLE sales.customers; --
(1 row)

 customers_left 
----------------
              3
(1 row)

Значение с DROP TABLE нашло строку по имени и больше ничего не сделало. В логе для каждого шага три записи, и параметры лежат отдельно от текста:

вывод (лог, одно значение)
LOG:  duration: 0.226 ms  parse <unnamed>: SELECT id, name FROM sales.customers WHERE name = $1 
LOG:  duration: 0.144 ms  bind <unnamed>: SELECT id, name FROM sales.customers WHERE name = $1 
DETAIL:  Parameters: $1 = 'O''Brien'
LOG:  duration: 0.007 ms  execute <unnamed>: SELECT id, name FROM sales.customers WHERE name = $1 
DETAIL:  Parameters: $1 = 'O''Brien'

Параметры в логе — это данные пользователей. log_parameter_max_length по документации по умолчанию -1 (значение целиком). Для роли app с ALTER ROLE app SET log_parameter_max_length = 4 значение в DETAIL стало 'O''Br...', а при 0 строка DETAIL исчезла, остались parse, bind, execute с одним лишь текстом. Для таблиц с персональными данными это дешёвая защита лога.

Расширенный протокол отвергает несколько команд в одном тексте. Ту же пару через \; psql отправил одной строкой в Parse:

вывод
ERROR:  cannot insert multiple commands into a prepared statement
LOG:  ... STATEMENT:  SELECT 1 ; SELECT 2 

Простой протокол принимает такую строку и выполняет обе команды: в логе она выглядела как statement: SELECT 1 ; SELECT 2;.

Что в коде

exec_parse_message разбирает текст и сразу проверяет число команд (src/backend/tcop/postgres.c, строки 1467–1475):

	/*
	 * We only allow a single user statement in a prepared statement. This is
	 * mainly to keep the protocol simple --- otherwise we'd need to worry
	 * about multiple result tupdescs and things like that.
	 */
	if (list_length(parsetree_list) > 1)
		ereport(ERROR,
				(errcode(ERRCODE_SYNTAX_ERROR),
				 errmsg("cannot insert multiple commands into a prepared statement")));

exec_bind_message получает значение как байты, переводит кодировку и передаёт входной функции типа параметра. Текст значения не попадает в разбор SQL, он только превращается в Datum (строки 1865–1879):

				getTypeInputInfo(ptype, &typinput, &typioparam);

				/*
				 * We have to do encoding conversion before calling the
				 * typinput routine.
				 */
				if (isNull)
					pstring = NULL;
				else
					pstring = pg_client_to_server(pbuf.data, plength);

				/* Now we can log the input string in case of error */
				one_param_data.paramval = pstring;

				pval = OidInputFunctionCall(typinput, pstring, typioparam, -1);

exec_simple_query принимает всю строку, разбирает её в список команд (строка 1065) и выполняет каждую по очереди (строки 1090–1097):

	use_implicit_block = (list_length(parsetree_list) > 1);

	/*
	 * Run through the raw parsetree(s) and process each one.
	 */
	foreach(parsetree_item, parsetree_list)
	{
		RawStmt    *parsetree = lfirst_node(RawStmt, parsetree_item);

Значит, граница проходит по протоколу, а не по настройке. Параметр не может стать частью SQL, пока приложение не склеивает текст само: имена таблиц, столбец в ORDER BY и динамический SQL внутри функций параметрами передать нельзя, и это отдельный список «проверять по белому списку».

Опыт 2: что видно в pg_stat_statements

pg_stat_statements нормализует константы в $1, так что склейка одних лишь значений формы не меняет. Тысяча разных значений через простой протокол (\gexec шлёт каждую строку отдельным Query), затем IN с растущим списком и три допустимых столбца сортировки, подставленных в текст:

SELECT format('SELECT id FROM sales.customers WHERE name = %L', 'user_' || g) FROM generate_series(1,1000) g \gexec
SELECT format('SELECT id FROM sales.customers WHERE id IN (%s)', (SELECT string_agg(i::text, ',') FROM generate_series(1,n) i)) FROM generate_series(1,100) n \gexec
SELECT format('SELECT id FROM sales.customers ORDER BY %I', c) FROM unnest(ARRAY['id','name','city']) c \gexec
SELECT count(*) AS queryids, sum(calls) AS calls, left(query,60) AS shape FROM pg_stat_statements
WHERE query LIKE '%sales.customers%' GROUP BY left(query,45), 3 ORDER BY 1;
вывод
 queryids | calls |                           shape                            
----------+-------+------------------------------------------------------------
        1 |     1 | SELECT id FROM sales.customers ORDER BY city
        1 |     1 | SELECT id FROM sales.customers ORDER BY id
        1 |     1 | SELECT id FROM sales.customers ORDER BY name
        1 |    99 | SELECT id FROM sales.customers WHERE id IN ($1 /*, ... */)
        1 |     1 | SELECT id FROM sales.customers WHERE id IN ($1)
        1 |  1000 | SELECT id FROM sales.customers WHERE name = $1
(6 rows)

Тысяча значений дала один queryid, а сто длин IN в PostgreSQL 18.6 — два: примечания к выпуску 18 говорят, что для списков констант учитываются только первая и последняя. Зато каждый вариант текста с другой формой считается отдельным запросом: три столбца сортировки дали три queryid. Чтобы форма росла без предела, достаточно собирать условия цепочкой. После pg_stat_statements_reset() 300 запросов вида name = 'tag1' OR name = 'tag2' ... с растущим числом условий:

вывод
 queryids | calls | min | max 
----------+-------+-----+-----
      300 |   300 |   1 |   1
(1 row)

Триста записей по одному вызову: так выглядит приложение, которое строит текст из пользовательского ввода (записи name = $1, name = $1 OR name = $2 и так далее). После обоих запусков подряд в pg_stat_statements было 305 записей на 1403 вызова, из них 303 с calls = 1. Практичный признак для мониторинга: рост доли записей с calls = 1 и счётчик dealloc в pg_stat_statements_info (pg_stat_statements.max по умолчанию 5000, в опыте dealloc был 0): при переполнении, по документации, теряется информация о наименее исполнявшихся запросах, то есть именно об этих записях.

Есть и второй след: в простом протоколе значения лежат в тексте statement: и параметры лога на них не действуют.

вывод (лог)
LOG:  duration: 0.100 ms  statement: SELECT id FROM sales.customers WHERE name = 'tag1' OR name = 'tag2'

Если вместо цепочек parse/bind/execute в логе приложения идут только statement: с готовыми значениями, драйвер или код отправляют простой Query: значения подставлены до базы.

Опыт 3: ограничители со стороны базы

Что бы ни пришло в запросе, роль приложения сама ограничена; как раскладывать права по ролям сервиса, разобрано в статье о ролях и привилегиях. Роли выданы таймауты и проверены права:

ALTER ROLE app SET statement_timeout = '1s';
ALTER ROLE app SET idle_in_transaction_session_timeout = '2s';
SELECT r.rolname, s.setconfig FROM pg_db_role_setting s JOIN pg_roles r ON r.oid = s.setrole WHERE r.rolname = 'app';
SELECT pg_has_role('app','pg_execute_server_program','USAGE') AS exec_program,
       pg_has_role('app','pg_read_server_files','USAGE') AS read_files,
       pg_has_role('app','pg_write_server_files','USAGE') AS write_files,
       r.rolsuper, r.rolcreaterole,
       has_schema_privilege('app','public','CREATE') AS create_public,
       has_schema_privilege('app','hr','USAGE') AS hr_usage
FROM pg_roles r WHERE r.rolname = 'app';
вывод
 rolname |                           setconfig                           
---------+---------------------------------------------------------------
 app     | {statement_timeout=1s,idle_in_transaction_session_timeout=2s}
(1 row)

 exec_program | read_files | write_files | rolsuper | rolcreaterole | create_public | hr_usage 
--------------+------------+-------------+----------+---------------+---------------+----------
 f            | f          | f           | f        | f             | f             | f
(1 row)

Теперь сессия app пробует то, чего от неё ждать не следует:

SELECT pg_sleep(3);
SELECT amount FROM hr.salaries;
CREATE TABLE public.tmp_t (x int);
COPY sales.customers TO PROGRAM 'true';
DROP TABLE sales.customers;
вывод
ERROR:  canceling statement due to statement timeout
ERROR:  permission denied for schema hr
ERROR:  permission denied for schema public
ERROR:  permission denied to COPY to or from an external program
DETAIL:  Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program.
ERROR:  must be owner of table customers

Транзакцию, оставленную открытой, база обрывает сама: BEGIN; SELECT 1;, пауза 4 секунды, и в ответ

вывод
FATAL:  terminating connection due to idle-in-transaction timeout

В логе остались ERROR: canceling statement due to statement timeout и STATEMENT: SELECT pg_sleep(3); рядом, поэтому log_min_duration_statement с таймаутом дают след и для отменённых запросов. Но таймаут роли — значение по умолчанию, а не стена: сессия app выполнила SET statement_timeout = 0, затем pg_sleep(1.5) и получила результат без отмены. Таймауты защищают от ошибки в коде (зависший запрос, забытая транзакция), а от намеренного обхода защищают права: у роли нет ни суперпользователя, ни pg_execute_server_program, ни CREATE в public.

Что из этого следует

Расширенный протокол снимает класс проблем, в котором значение становится частью SQL, но не всю проблему: идентификаторы, динамический SQL и серверные функции, склеивающие текст, остаются за приложением и проверкой по белому списку. DBA не видит код, зато видит три следа: формы запросов в pg_stat_statements, значения в тексте statement: и ошибки синтаксиса в логе. Права роли и таймауты не заменяют исправления кода, но превращают его ошибку из потери данных в отказ с понятным сообщением. Сколько от каждого значения попадёт в лог, задаёт log_parameter_max_length: решай заранее, для каких ролей параметры в логе допустимы.

Чек-лист

  1. В логе приложения видны parse, bind, execute с DETAIL: Parameters, а не только statement: с готовыми значениями: значит, драйвер отправляет параметры отдельно.
  2. pg_stat_statements: доля записей с calls = 1, pg_stat_statements_info.dealloc и топ запросов по числу вариантов одной формы не растут от недели к неделе.
  3. Для роли приложения ALTER ROLE ... SET statement_timeout и idle_in_transaction_session_timeout заданы и видны в pg_db_role_setting; значения выбраны по самому длинному легитимному запросу.
  4. pg_has_role(app, 'pg_execute_server_program', 'USAGE') и rolsuper дают f, у роли нет CREATE в public и нет доступа к чужим схемам.
  5. log_parameter_max_length задан осознанно для ролей, чьи параметры содержат персональные данные. Контейнер опыта удаляется командой docker rm -f -v dba-params.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru