SECURITY DEFINER-функции PostgreSQL 18: зачем pg_temp в конце search_path
За последний год я лично разобрал больше сотни SECURITY DEFINER-функций в базах наших клиентов на PostgreSQL — от самописных надстроек над 1С-хранилищами до бэкендов CRM и биллинга. Практически в каждой второй базе я нахожу один и тот же паттерн: разработчик честно исключил public из search_path функции, посчитал вопрос закрытым и пошёл дальше. А про pg_temp — временную схему сессии — никто не вспоминает, потому что её не видно в обычном списке схем базы и она не участвует в GRANT/REVOKE на объекты БД. Ниже — как я объясняю эту проблему своей команде и клиентам, с конкретными командами, запросами для аудита и чек-листом, которым мы закрываем эту дыру при внедрении.
Почему я вообще завёл этот разговор
У наших клиентов — юрлиц до 50 рабочих мест — PostgreSQL чаще всего живёт не как витрина в вакууме, а как движок под конкретной прикладной задачей: обмен с 1С через внешние источники данных, бэкенд самописной CRM, агрегатор для отчётов. В таких системах почти всегда есть функции с SECURITY DEFINER — они нужны, чтобы обычный пользователь приложения мог, например, пересчитать баланс контрагента или записать в журнал аудита без прямого доступа к таблицам-источникам. Функция выполняется с правами того, кто её создал (обычно это владелец схемы или служебная роль с расширенными правами), а не с правами вызывающего — в этом всё удобство и вся опасность одновременно.
Официальная документация PostgreSQL прямо предупреждает в разделе про CREATE FUNCTION: поскольку такая функция выполняется с привилегиями создателя, нужно заботиться о том, чтобы её нельзя было использовать во вред. Ключевой инструмент защиты — параметр search_path, который функция получает по умолчанию от вызывающей сессии, если разработчик явно не переопределил его. И вот здесь начинается типовая ошибка, которую я хочу разобрать до последнего винтика.
Как на самом деле работает search_path — и где в нём прячется pg_temp
search_path — это упорядоченный список схем, по которому PostgreSQL ищет неквалифицированные имена таблиц, функций, операторов, типов. Разработчики обычно думают о нём как о простом списке: SET search_path = app, public — и порядок, в котором сервер будет искать app.таблица, понятен. Но в этой модели пропущены две схемы, которые участвуют в поиске неявно, даже если их нет в списке.
Первая — pg_catalog, системный каталог: он всегда участвует в поиске, обычно в начале, если не указан явно в другом месте списка. Вторая — временная схема сессии, обозначаемая псевдонимом pg_temp. По документации PostgreSQL, схема временных таблиц ищется первой по умолчанию, если она не указана явно в search_path — то есть даже когда вы написали search_path = app, pg_catalog и ни разу не упомянули temp-схему, сервер всё равно проверит её первой при разрешении любого неквалифицированного имени.
Это не баг, а осознанное поведение: приложениям нужно, чтобы CREATE TEMP TABLE и работа с временными объектами не требовали постоянной квалификации имени схемой. Но именно это удобство и превращает pg_temp в слепую зону для функций, которые выполняются с чужими привилегиями.
На практике это означает, что схема временных таблиц по сути невидима для тех, кто проверяет права доступа через information_schema.schemata или через список GRANT на схемы: pg_temp не хранится как постоянный объект каталога с фиксированным именем — для каждой сессии сервер создаёт отдельную физическую схему вида pg_temp_N, а имя pg_temp — это просто псевдоним, который в контексте текущей сессии всегда резолвится в её собственную временную схему. Именно поэтому обычный аудит прав на схемы такую дыру не находит: формально в списке GRANT никакой лишней записи нет, а фактическая уязвимость живёт в порядке разрешения имён, а не в таблице привилегий.
Анатомия риска: временный объект с коротким именем внутри доверенной функции
Возьмём типичную SECURITY DEFINER-функцию, которая где-то внутри тела обращается к таблице или вызывает вспомогательную функцию по короткому имени — без явной квалификации схемой. Это нормальная практика: писать schema.table перед каждым идентификатором в каждой строке PL/pgSQL — значит превратить код в нечитаемое месиво, и в документации PostgreSQL честно признаётся, что риск забыть проквалифицировать хотя бы один безобидный оператор вроде = слишком велик, чтобы полагаться на ручную квалификацию как на основную защиту.
Дальше сценарий такой: сессия, вызывающая функцию, не обязана быть привилегированной — обычно право на CONNECT и создание временных объектов (TEMP) в базе есть у любого аутентифицированного пользователя, если владелец базы явно это не отозвал. Пользователь создаёт объект с именем, которое совпадает с тем, что функция ожидает найти в доверенной схеме — таблицу, а в некоторых случаях и функцию или оператор, потому что pg_temp — это полноценная схема, в которую можно писать объекты явной квалификацией (CREATE FUNCTION pg_temp.имя(...)), а не только через CREATE TEMP TABLE.
Если в search_path функции просто исключён public, но не зафиксировано явное положение pg_temp, эта временная схема всё равно проверяется первой — раньше доверенной схемы, раньше pg_catalog. Функция, выполняясь с повышенными правами, обращается не к тому объекту, который задумал разработчик, а к тому, что подложила вызывающая сторона в своей временной схеме. Это в чистом виде обход границы привилегий: чтение или запись происходят под чужой учётной записью, но по указке вызывающего.
Отдельно подчеркну: моя задача здесь — объяснить механизм защиты, а не разобрать инструментарий эксплуатации. Дальше — только про то, как это закрыть правильно.
Ещё один нюанс, который я разбираю с командой отдельно: опасность не ограничивается таблицами. Раз pg_temp — полноценная схема, в неё можно явной квалификацией поместить и функцию, и оператор, и пользовательский тип. Это значит, что даже если тело SECURITY DEFINER-функции нигде не работает с пользовательскими таблицами напрямую, а лишь вызывает вспомогательную функцию по короткому имени или сравнивает значения через стандартный оператор, риск подмены всё равно есть — именно поэтому в документации отдельно подчёркивается, что полагаться на ручную схемную квалификацию каждого оператора в теле функции ненадёжно, а единственная системная защита — контролируемый search_path на уровне определения самой функции.
Наш чек-лист написания SECURITY DEFINER-функций под PostgreSQL 18
С PostgreSQL 18 (актуальный релиз на момент написания) синтаксис и рекомендации из раздела «Writing SECURITY DEFINER Functions Safely» документации CREATE FUNCTION не изменились в части общей механики search_path — но именно 18-я ветка продолжает линию на ужесточение умолчаний вокруг привилегированного кода, поэтому мы используем её как повод пересобрать регламент. Вот шаблон, которым мы теперь оформляем каждую такую функцию:
CREATE FUNCTION app.recalc_balance(p_account_id bigint)
RETURNS numeric
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = app, pg_catalog, pg_temp
AS $$
DECLARE
v_balance numeric;
BEGIN
SELECT balance INTO v_balance FROM account WHERE id = p_account_id;
RETURN v_balance;
END;
$$;Три вещи в этом определении принципиальны. Первая — SET search_path объявлен прямо в теле CREATE FUNCTION, а не выставляется командой SET внутри тела функции с последующим RESET: такое объявление сохраняется в системном каталоге как атрибут функции (поле proconfig в pg_proc) и применяется автоматически на каждый вызов, независимо от того, что творится в search_path вызывающей сессии, и без риска забыть восстановить прежнее значение при ошибке внутри тела.
Вторая — в списке явно присутствует доверенная схема (app) первой, затем pg_catalog — хотя каталог и так участвует в поиске неявно, явное перечисление убирает двусмысленность и облегчает ревью кода: любой, кто читает определение функции, сразу видит полный список источников имён, не держа в голове умолчания сервера.
Третья и главная для этой статьи — pg_temp в конце списка. Именно это, по документации PostgreSQL, единственный способ заставить сервер искать временную схему последней, а не первой: если явно написать pg_temp в любом месте search_path, сервер использует именно эту позицию вместо умолчания «искать первой».
REVOKE CREATE ON SCHEMA public — обязательный дефолт с PG15, но это другая ось защиты
Начиная с PostgreSQL 15 схема public в новых базах создаётся с владельцем pg_database_owner и без права CREATE для роли PUBLIC — это изменение умолчаний зафиксировано в документации по релизу 15 и закрывает классический вектор CVE-2018-1058, когда любой аутентифицированный пользователь мог создать объект в public и подложить его под чужой search_path.
| Параметр | До PostgreSQL 15 (или после pg_upgrade со старого кластера) | PostgreSQL 15+ (новая база) |
|---|---|---|
| Владелец схемы public | обычно роль-суперпользователь / владелец базы | pg_database_owner |
| CREATE в public для PUBLIC | разрешён по умолчанию | отозван по умолчанию |
| Действие при апгрейде | — | нужно вручную выполнить REVOKE, дефолт не применяется задним числом |
| Закрывает ли вектор pg_temp | нет | нет |
Для баз, полученных через pg_upgrade или восстановленных из дампа со старого кластера, новый дефолт сам по себе не применяется — его нужно проставить руками:
ALTER SCHEMA public OWNER TO pg_database_owner;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;Мы проверяем это у каждого клиента при первичном аудите, но именно здесь чаще всего и рождается ложное чувство защищённости: REVOKE CREATE закрывает создание объектов в public, а не в pg_temp — это два разных механизма привилегий. Право писать во временную схему регулируется не привилегией CREATE на схему, а привилегией TEMP на уровне базы данных, и она по умолчанию тоже выдана PUBLIC — причём этот дефолт REVOKE CREATE ON SCHEMA public никак не трогает.
REVOKE EXECUTE FROM PUBLIC: вторая половина модели минимальных привилегий
Правильный search_path закрывает подмену объектов внутри функции. Но остаётся вопрос: кто вообще имеет право вызвать эту функцию? По умолчанию PostgreSQL выдаёт привилегию EXECUTE на новую функцию роли PUBLIC — то есть если явно не отозвать это право, вызвать привилегированную функцию сможет любой пользователь с доступом к базе, даже если у него нет прав на таблицы, которые функция читает или меняет.
REVOKE EXECUTE ON FUNCTION app.recalc_balance(bigint) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION app.recalc_balance(bigint) TO app_service_role;
-- чтобы дефолт не всплывал заново на каждой новой функции схемы:
ALTER DEFAULT PRIVILEGES IN SCHEMA app
REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;Это не альтернатива правильному search_path, а обязательное дополнение к нему: даже идеально написанная функция с pg_temp в конце списка расширяет поверхность атаки, если её может дёрнуть кто угодно. Мы всегда проверяем оба параметра в связке — иначе аудит закрывает половину проблемы и создаёт ложное ощущение, что вопрос решён.
Как я проверяю существующие функции в базах клиентов
Для аудита я не читаю определения функций по одному — это не масштабируется на базу из нескольких сотен объектов. Сначала выгружаю список всех SECURITY DEFINER-функций и их конфигурации через системный каталог pg_proc:
SELECT n.nspname AS schema,
p.proname AS function,
p.prosecdef AS is_security_definer,
p.proconfig AS config
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE p.prosecdef
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1, 2;Поле proconfig — это массив настроек, привязанных к функции (аналог параметров сессии, но зафиксированных в определении); если search_path там вообще не встречается, функция целиком наследует search_path сессии вызывающего — худший из возможных случаев. Если встречается, но строка не заканчивается на pg_temp, это кандидат на исправление. Дальше по каждому подозрительному объекту вытаскиваю полный текст определения через pg_get_functiondef:
SELECT pg_get_functiondef(p.oid)
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'app' AND p.proname = 'recalc_balance';Эта функция возвращает готовый DDL-текст с учётом всех SET-атрибутов — удобно и для ревью, и для того, чтобы приложить diff «было / стало» в отчёт клиенту. По этому же запросу мы прогоняем и функции, установленные расширениями (extensions): документация PostgreSQL отдельно рекомендует для расширений временно выставлять search_path = pg_catalog, pg_temp и явно квалифицировать обращения к схеме установки расширения — это тот же принцип, но в контексте кода, который разработчик клиента не писал сам и рискует не проверить вовсе.
Типичные находки у наших клиентов
| Что находим | Почему это опасно | Как чиним |
|---|---|---|
| SECURITY DEFINER без SET search_path в определении | функция целиком наследует search_path сессии вызывающего, включая любые схемы, которые он сам себе выставил | добавить явный SET search_path с фиксированным списком схем |
| search_path исключает public, но не упоминает pg_temp | временная схема сессии всё равно проверяется первой по умолчанию — public тут ни при чём | добавить pg_temp последним элементом списка |
| search_path без явного pg_catalog | сложнее контролировать порядок разрешения системных типов и операторов при ревью, риск неоднозначности растёт при разрастании доверенной схемы | указывать pg_catalog явно в списке |
| SECURITY DEFINER навешан «на всякий случай», хотя вызывающему хватило бы своих прав | без необходимости расширяет поверхность атаки — функция становится мостом через границу привилегий там, где моста быть не должно | пересмотреть на SECURITY INVOKER (это дефолт PostgreSQL, если атрибут не указан явно) |
| EXECUTE оставлен ролью PUBLIC по умолчанию | любой пользователь с доступом к базе может вызвать привилегированную функцию, даже без прав на таблицы-источники | REVOKE EXECUTE FROM PUBLIC + точечный GRANT сервисной роли |
| public не исключён из search_path вовсе (база пришла с версии до 15 без REVOKE) | PostgreSQL 15+ не применяет новый дефолт задним числом при апгрейде | ALTER SCHEMA public OWNER TO pg_database_owner; REVOKE CREATE ON SCHEMA public FROM PUBLIC |
Регламент внедрения: что мы делаем у клиента пошагово
Когда мы берём базу PostgreSQL на сопровождение или проводим точечный аудит безопасности, последовательность такая:
- Инвентаризация: запрос по
pg_proc/pg_namespaceиз раздела выше выгружает все SECURITY DEFINER-функции, включая установленные расширениями. - Классификация: для каждой функции — есть ли SET search_path, оканчивается ли он на pg_temp, есть ли явный pg_catalog, кому выдан EXECUTE (проверяем через
information_schema.routine_privileges). - Приоритизация: в первую очередь чиним функции, которые вызываются из веб-слоя или из ролей с широким кругом пользователей (например, из обменного контура 1С через внешний источник данных) — там больше потенциальных вызывающих сессий.
- Правка DDL и regression-тест: собираем новое определение через
CREATE OR REPLACE FUNCTIONс полным списком search_path, прогоняем существующие сценарии использования на staging-копии базы, сверяем результат черезpg_get_functiondefдо и после. - Выкладка в окно обслуживания:
CREATE OR REPLACE FUNCTIONне требует блокировки таблиц, но мы всё равно проводим замену вне пиковой нагрузки клиента, потому что PL/pgSQL кеширует планы по сессии, и часть активных сессий может ещё какое-то время работать со старым определением. - Фиксация в регламенте разработки: для клиентов с собственной командой разработки добавляем правило код-ревью — любой
CREATE FUNCTION ... SECURITY DEFINERбезpg_tempпоследним элементом search_path не проходит ревью; для остальных берём эту проверку на своё сопровождение и повторяем её при каждом плановом аудите.
Отдельно поясню, почему я завязываю этот регламент именно на PostgreSQL 18, а не описываю абстрактную «лучшую практику вообще»: в 18-й ветке команда PostgreSQL продолжила точечно закрывать похожие векторы поиска по search_path в системном коде — например, функции проверки индексов из contrib/amcheck теперь жёстко ограничивают search_path перед выполнением индексных выражений, потому что раньше вызывающий мог подсунуть свою версию функции и заставить amcheck выполнить её от имени владельца таблицы. Это тот же самый класс проблемы, что мы разбираем здесь, только внутри самого ядра — и он подтверждает, что принцип «явный список доверенных схем плюс pg_temp последним» остаётся актуальной практикой, а не пережитком старых версий.
Отдельно отмечу практический момент, с которым мы сталкиваемся у клиентов на смешанных версиях кластера: если инфраструктура ещё не обновлена до PostgreSQL 18 и работает, скажем, на ветке 14 или 16, весь описанный здесь подход применим без изменений — механика search_path и поведение pg_temp в этой части не менялись между актуальными поддерживаемыми версиями. Разница лишь в дефолтах вокруг схемы public (14 и ниже — старое поведение, 15 и выше — новое), поэтому при переходе на новую версию через pg_upgrade мы всегда отдельным пунктом регламента проверяем, что REVOKE CREATE на public применён вручную, а не унаследован автоматически из версии.
Итог этого регламента простой: одна проверенная фраза в определении функции — SET search_path = <схема>, pg_catalog, pg_temp — закрывает целый класс атак через подмену объектов, но работает она только тогда, когда применена системно, ко всем функциям с повышенными правами, а не выборочно к тем, что попались на глаза при последнем ревью.
Частые вопросы
- Разве REVOKE CREATE ON SCHEMA public FROM PUBLIC, ставший дефолтом с PostgreSQL 15, не закрывает эту проблему целиком?
- Нет, это другой механизм привилегий. REVOKE CREATE ON SCHEMA public запрещает создавать постоянные объекты в схеме public. Право писать во временную схему сессии регулируется отдельной привилегией TEMP на уровне базы данных, и она по умолчанию всё ещё выдана роли PUBLIC. Даже на базе, где public полностью защищён этим REVOKE, обычный пользователь по-прежнему может создать временную таблицу или явно квалифицированный объект в pg_temp — и если search_path функции не фиксирует позицию pg_temp явно, эта временная схема всё равно проверяется первой.
- Нужно ли добавлять pg_temp в конец search_path у всех функций, включая обычные SECURITY INVOKER?
- Риск принципиально ниже для SECURITY INVOKER: такая функция выполняется с привилегиями самого вызывающего, и подмена объекта через его же временную схему не даёт эскалации прав — пользователь просто обманывает сам себя. Но как правило гигиены мы всё равно рекомендуем фиксировать search_path на функциях, которые входят в общие библиотеки или вызываются из триггеров, чтобы поведение не зависело от того, что вызывающая сессия успела выставить себе до вызова.
- Не сломается ли логика, если я добавлю строгий search_path к функции, которая годами работала без него?
- Такой риск реален, если тело функции опирается на то, что нужная схема окажется в search_path сессии вызывающего (например, обращается к временной таблице, которую заранее создаёт клиентский код). Поэтому мы всегда сравниваем DDL через pg_get_functiondef до и после правки, прогоняем реальные сценарии на staging-копии и только потом выкатываем в прод в окне обслуживания, а не патчим боевую базу на лету.
- А что с функциями, которые ставятся расширениями (extensions) — их же я не писал сам?
- Их нужно проверять тем же запросом по pg_proc — расширение точно так же может создать SECURITY DEFINER-функцию с неполным search_path. Документация PostgreSQL отдельно рекомендует для расширений временно выставлять search_path = pg_catalog, pg_temp и явно квалифицировать обращения к схеме установки расширения. Мы включаем контрибные и сторонние расширения в тот же цикл аудита, что и собственный код клиента.
- Если я и так пишу pg_catalog первым в списке явно, разве это не то же самое, что и умолчание?
- Почти то же самое с одной оговоркой: pg_catalog участвует в поиске неявно и обычно первым независимо от того, упомянут ли он в search_path, если только вы сами не разместили его в другой позиции списка. Явное перечисление не меняет эту механику, а лишь убирает двусмысленность при чтении кода — ревьюер сразу видит полный список источников имён, не держа в голове умолчания сервера версии, под которой база когда-то создавалась.