В pg_stat_statements внезапно триста записей на один и тот же запрос, а в логе видны чужие значения прямо в тексте. Это не поломка базы: приложение собирает текст запроса из значений, и каждое новое значение, а иногда и каждая новая форма, превращается в новый текст. Если же приложение передаёт значения параметрами, база получает текст ... name = $1 и значение по отдельным сообщениям протокола: форма запроса не зависит от ввода. Как pg_stat_statements считает формы, разобрано в статье про нормализацию и queryid, а как параметры влияют на планы — в статье про prepared statements. Здесь смотрим с другой стороны: что на уровне протокола, лога и прав видит и ограничивает DBA. Уязвимостей не воспроизводим: все значения ниже безобидные.
Куда попадает ввод: в текст запроса или в Bind
Опыт 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: решай заранее, для каких ролей параметры в логе допустимы.
Чек-лист
- В логе приложения видны
parse,bind,executeсDETAIL: Parameters, а не толькоstatement:с готовыми значениями: значит, драйвер отправляет параметры отдельно. pg_stat_statements: доля записей сcalls = 1,pg_stat_statements_info.deallocи топ запросов по числу вариантов одной формы не растут от недели к неделе.- Для роли приложения
ALTER ROLE ... SET statement_timeoutиidle_in_transaction_session_timeoutзаданы и видны вpg_db_role_setting; значения выбраны по самому длинному легитимному запросу. pg_has_role(app, 'pg_execute_server_program', 'USAGE')иrolsuperдаютf, у роли нетCREATEвpublicи нет доступа к чужим схемам.log_parameter_max_lengthзадан осознанно для ролей, чьи параметры содержат персональные данные. Контейнер опыта удаляется командойdocker rm -f -v dba-params.