· 14 мин чтения

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 на сопровождение или проводим точечный аудит безопасности, последовательность такая:

  1. Инвентаризация: запрос по pg_proc / pg_namespace из раздела выше выгружает все SECURITY DEFINER-функции, включая установленные расширениями.
  2. Классификация: для каждой функции — есть ли SET search_path, оканчивается ли он на pg_temp, есть ли явный pg_catalog, кому выдан EXECUTE (проверяем через information_schema.routine_privileges).
  3. Приоритизация: в первую очередь чиним функции, которые вызываются из веб-слоя или из ролей с широким кругом пользователей (например, из обменного контура 1С через внешний источник данных) — там больше потенциальных вызывающих сессий.
  4. Правка DDL и regression-тест: собираем новое определение через CREATE OR REPLACE FUNCTION с полным списком search_path, прогоняем существующие сценарии использования на staging-копии базы, сверяем результат через pg_get_functiondef до и после.
  5. Выкладка в окно обслуживания: CREATE OR REPLACE FUNCTION не требует блокировки таблиц, но мы всё равно проводим замену вне пиковой нагрузки клиента, потому что PL/pgSQL кеширует планы по сессии, и часть активных сессий может ещё какое-то время работать со старым определением.
  6. Фиксация в регламенте разработки: для клиентов с собственной командой разработки добавляем правило код-ревью — любой 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, если только вы сами не разместили его в другой позиции списка. Явное перечисление не меняет эту механику, а лишь убирает двусмысленность при чтении кода — ревьюер сразу видит полный список источников имён, не держа в голове умолчания сервера версии, под которой база когда-то создавалась.
📄
Скачайте подробный разбор в PDF Кейсы, статистика, типовые ошибки и чек-лист самопроверки — 12 страниц
Скачать PDF

Подпишитесь на разборы ITfresh

Раз в неделю — практичные материалы по ИТ для бизнеса: без спама, только польза.

Письмо придёт в течение минутыНе нашли его во «Входящих» — загляните в папку «Спам» или «Промоакции» и нажмите «Не спам». Так все следующие выпуски будут приходить прямо в основную почту.