From 51b30dd7f5c45bceb51b29e195ae4894d247264a Mon Sep 17 00:00:00 2001 From: Rust Wizard Date: Mon, 30 Mar 2026 21:21:46 +0300 Subject: [PATCH 1/5] block pg_read_file, pg_read_binary_file, pg_stat_file via fmgr_hook --- Dockerfile | 24 ++++---- sql/hooks.sql | 4 +- src/lib.rs | 135 +++++++++++++++++++++++++++++++++++++++++++ tests/docker/test.sh | 72 ++++++++++++++++++++++- 4 files changed, 220 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 623e989..471d8b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,30 @@ # Stage 1: Build the extension using pgrx FROM rust:1-trixie AS builder -# Add the official PostgreSQL apt repository so postgresql-server-dev-17 is available +# Disable incremental compilation and enable sparse registry to reduce disk usage +ENV CARGO_INCREMENTAL=0 \ + CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse + +# postgresql-server-dev-17 is available in Debian trixie's default repositories RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - gnupg \ - lsb-release \ - && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] \ - https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update && apt-get install -y --no-install-recommends \ postgresql-server-dev-17 \ clang \ libclang-dev \ pkg-config \ && rm -rf /var/lib/apt/lists/* -RUN cargo install cargo-pgrx --version 0.17.0 --locked +# Install cargo-pgrx, then immediately purge the downloaded crate registry to free space +RUN cargo install cargo-pgrx --version 0.17.0 --locked && \ + rm -rf /root/.cargo/registry/src /root/.cargo/registry/cache WORKDIR /build COPY . . -# Register system pg17 with pgrx (no download needed), then package the extension +# Register system pg17 with pgrx (no download needed), then package the extension. +# Override the release profile to use thin LTO so the linker does not exhaust /tmp. RUN cargo pgrx init --pg17 /usr/bin/pg_config && \ + CARGO_PROFILE_RELEASE_LTO=thin \ + CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \ cargo pgrx package --pg-config /usr/bin/pg_config --features pg17 # Stage 2: PostgreSQL 17 with the extension installed diff --git a/sql/hooks.sql b/sql/hooks.sql index aa74cde..0c7301b 100644 --- a/sql/hooks.sql +++ b/sql/hooks.sql @@ -23,6 +23,7 @@ CREATE TABLE command_fw.audit_log ( current_user_name text NOT NULL, query_text text NOT NULL, -- Command category: 'TRUNCATE' | 'DROP_TABLE' | 'ALTER_SYSTEM' | 'LOAD' | 'COPY_PROGRAM' + -- | 'COPY' | 'READ_FILE' | 'STAT_FILE' command_type text NOT NULL, -- For DROP_TABLE: the production schema that triggered the block (NULL otherwise). target_schema text, @@ -33,7 +34,8 @@ CREATE TABLE command_fw.audit_log ( application_name text, blocked bool NOT NULL, -- NULL when not blocked; one of: 'role_listed', 'truncate_non_superuser', - -- 'drop_production_table', 'alter_system', 'load', 'copy_program' when blocked. + -- 'drop_production_table', 'alter_system', 'load', 'copy_program', 'copy', + -- 'read_file' when blocked. block_reason text, PRIMARY KEY (id) ); diff --git a/src/lib.rs b/src/lib.rs index 363038c..2fa4c01 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,8 @@ static BLOCK_LOAD: GucSetting = GucSetting::::new(true); static BLOCK_COPY_PROGRAM: GucSetting = GucSetting::::new(true); // COPY (plain, non-PROGRAM): blocked for non-superusers (opt-in). static BLOCK_COPY: GucSetting = GucSetting::::new(false); +// pg_read_file / pg_read_binary_file / pg_stat_file: blocked for all users including superusers. +static BLOCK_READ_FILE: GucSetting = GucSetting::::new(true); // Cross-category settings // Comma-separated list of roles that are always blocked, including superusers. @@ -41,6 +43,7 @@ static HINT: GucSetting> = GucSetting::>::new(No static AUDIT_LOG_ENABLED: GucSetting = GucSetting::::new(true); static mut PREV_PROCESS_UTILITY_HOOK: pg_sys::ProcessUtility_hook_type = None; +static mut PREV_FMGR_HOOK: pg_sys::fmgr_hook_type = None; // Hook argument bundle struct ProcessUtilityArgs { @@ -287,6 +290,109 @@ unsafe fn extract_string_node(ptr: *mut std::os::raw::c_void) -> Option .map(|v| v.to_owned()) } +// FunctionManager hook for pg_read_file / pg_read_binary_file / pg_stat_file +/// Returns the audit log command_type label if `fn_oid` is a file-access function +/// in pg_catalog that we want to intercept, or `None` otherwise. +unsafe fn file_access_command_type(fn_oid: pg_sys::Oid) -> Option<&'static str> { + let tuple = pg_sys::SearchSysCache1( + pg_sys::SysCacheIdentifier::PROCOID as _, + pg_sys::Datum::from(fn_oid), + ); + if tuple.is_null() { + return None; + } + let proc_form = pg_sys::GETSTRUCT(tuple) as *mut pg_sys::FormData_pg_proc; + let pronamespace = (*proc_form).pronamespace; + // Must be pg_catalog (OID 11). + if pronamespace != pg_sys::Oid::from(pg_sys::PG_CATALOG_NAMESPACE) { + pg_sys::ReleaseSysCache(tuple); + return None; + } + let name_cstr = + std::ffi::CStr::from_ptr((*proc_form).proname.data.as_ptr()); + let result = match name_cstr.to_str().unwrap_or("") { + "pg_read_file" | "pg_read_binary_file" => Some("READ_FILE"), + "pg_stat_file" => Some("STAT_FILE"), + _ => None, + }; + pg_sys::ReleaseSysCache(tuple); + result +} + +#[pg_guard] +unsafe extern "C-unwind" fn fmgr_hook_trampoline( + event: pg_sys::FmgrHookEventType::Type, + flinfo: *mut pg_sys::FmgrInfo, + arg: *mut pg_sys::Datum, +) { + // Chain previous hook first for all events so prior hooks get proper START/END/ABORT. + if let Some(prev) = PREV_FMGR_HOOK { + prev(event, flinfo, arg); + } + + if event != pg_sys::FmgrHookEventType::FHET_START { + return; + } + if !FW_ENABLED.get() || !BLOCK_READ_FILE.get() { + return; + } + + let command_type = match file_access_command_type((*flinfo).fn_oid) { + Some(ct) => ct, + None => return, + }; + + let current_user = get_current_username().unwrap_or_else(|| "unknown".to_string()); + let session_user = get_session_username().unwrap_or_else(|| "unknown".to_string()); + let in_blocked_list = is_role_blocked(¤t_user); + + let block_reason = if in_blocked_list { + Some("role_listed") + } else { + Some("read_file") + }; + + let query_text = if !pg_sys::debug_query_string.is_null() { + std::ffi::CStr::from_ptr(pg_sys::debug_query_string) + .to_str() + .unwrap_or("") + } else { + "" + }; + + write_audit_log( + &session_user, + ¤t_user, + query_text, + command_type, + None, + None, + true, + block_reason, + ); + + pgrx::log!( + "blocked {} user={:?} reason={:?}", + command_type, + current_user, + block_reason.unwrap_or(""), + ); + + let msg = format!( + "{} command is not allowed", + command_type.replace('_', " ") + ); + let hint = HINT + .get() + .and_then(|cstr| cstr.to_str().ok().map(str::to_owned)); + let mut report = + ErrorReport::new(PgSqlErrorCode::ERRCODE_INSUFFICIENT_PRIVILEGE, msg, ""); + if let Some(h) = hint { + report = report.set_hint(h); + } + report.report(PgLogLevel::ERROR); +} + // Role helper fn is_role_blocked(current_user: &str) -> bool { BLOCKED_ROLES @@ -578,6 +684,18 @@ pub extern "C-unwind" fn _PG_init() { GucFlags::default(), ); + GucRegistry::define_bool_guc( + c"pg_command_fw.block_read_file", + c"Block pg_read_file / pg_read_binary_file / pg_stat_file for all users", + c"When on (default), calls to pg_read_file(), pg_read_binary_file(), and \ + pg_stat_file() are blocked for every role including superusers. These \ + functions allow reading arbitrary server-side files and represent the same \ + data-exfiltration threat as COPY TO FILE.", + &BLOCK_READ_FILE, + GucContext::Suset, + GucFlags::default(), + ); + GucRegistry::define_string_guc( c"pg_command_fw.blocked_roles", c"Comma-separated list of roles always blocked from firewall-governed commands", @@ -613,6 +731,9 @@ pub extern "C-unwind" fn _PG_init() { unsafe { PREV_PROCESS_UTILITY_HOOK = pg_sys::ProcessUtility_hook; pg_sys::ProcessUtility_hook = Some(hook_trampoline); + + PREV_FMGR_HOOK = pg_sys::fmgr_hook; + pg_sys::fmgr_hook = Some(fmgr_hook_trampoline); } } @@ -831,6 +952,20 @@ mod tests { assert_eq!(show("pg_command_fw.audit_log_enabled"), "on"); } + #[pg_test] + fn test_guc_block_read_file_default_on() { + assert_eq!(show("pg_command_fw.block_read_file"), "on"); + } + + #[pg_test] + fn test_guc_block_read_file_roundtrip() { + Spi::run("SET pg_command_fw.block_read_file = off").unwrap(); + assert_eq!(show("pg_command_fw.block_read_file"), "off"); + Spi::run("SET pg_command_fw.block_read_file = on").unwrap(); + assert_eq!(show("pg_command_fw.block_read_file"), "on"); + } + + // Audit log table structure #[pg_test] fn test_audit_log_table_exists() { diff --git a/tests/docker/test.sh b/tests/docker/test.sh index 5bf32ba..3329a3a 100644 --- a/tests/docker/test.sh +++ b/tests/docker/test.sh @@ -360,11 +360,79 @@ else fail "expected session_user_name='postgres', got: '$user'" fi +echo "" +echo "=== pg_read_file / pg_read_binary_file / pg_stat_file ===" + +echo "" +echo "--- Test 28: superuser pg_read_file is blocked by default ---" +out=$($SU -c "SELECT pg_read_file('PG_VERSION');" 2>&1 || true) +if echo "$out" | grep -q "READ FILE command is not allowed"; then + pass "superuser pg_read_file blocked by default" +else + fail "superuser pg_read_file not blocked; got: $out" +fi + +echo "" +echo "--- Test 29: non-superuser pg_read_file is blocked by default ---" +out=$(PGPASSWORD=testpass $RU -c "SELECT pg_read_file('PG_VERSION');" 2>&1 || true) +if echo "$out" | grep -q "READ FILE command is not allowed\|must be superuser\|permission denied"; then + pass "non-superuser pg_read_file blocked" +else + fail "non-superuser pg_read_file not blocked; got: $out" +fi + +echo "" +echo "--- Test 30: superuser pg_read_binary_file is blocked by default ---" +out=$($SU -c "SELECT pg_read_binary_file('PG_VERSION');" 2>&1 || true) +if echo "$out" | grep -q "READ FILE command is not allowed"; then + pass "superuser pg_read_binary_file blocked by default" +else + fail "superuser pg_read_binary_file not blocked; got: $out" +fi + +echo "" +echo "--- Test 31: superuser pg_stat_file is blocked by default ---" +out=$($SU -c "SELECT pg_stat_file('PG_VERSION');" 2>&1 || true) +if echo "$out" | grep -q "STAT FILE command is not allowed"; then + pass "superuser pg_stat_file blocked by default" +else + fail "superuser pg_stat_file not blocked; got: $out" +fi + +echo "" +echo "--- Test 32: block_read_file=off -> superuser pg_read_file allowed ---" +out=$($SU -c "SET pg_command_fw.block_read_file = off; SELECT pg_read_file('PG_VERSION');" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "superuser pg_read_file allowed when block_read_file=off" +else + fail "superuser pg_read_file blocked when block_read_file=off; got: $out" +fi + +echo "" +echo "--- Test 33: enabled=off -> superuser pg_read_file allowed ---" +out=$($SU -c "SET pg_command_fw.enabled = off; SELECT pg_read_file('PG_VERSION');" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "superuser pg_read_file allowed when firewall disabled" +else + fail "superuser pg_read_file blocked when firewall disabled; got: $out" +fi + +echo "" +echo "--- Test 34: blocked pg_read_file recorded in server log, not in audit_log ---" +psql -c "TRUNCATE command_fw.audit_log;" +$SU -c "SELECT pg_read_file('PG_VERSION');" 2>/dev/null || true +count=$(q "SELECT count(*) FROM command_fw.audit_log WHERE blocked AND command_type = 'READ_FILE';") +if [ "$count" = "0" ]; then + pass "blocked pg_read_file not persisted in audit_log (transaction rollback)" +else + fail "expected 0 audit_log rows for blocked pg_read_file, got: $count" +fi + echo "" echo "=== Regular SQL unaffected ===" echo "" -echo "--- Test 28: SELECT works ---" +echo "--- Test 35: SELECT works ---" result=$(psql -t -A -c "SELECT 42;") if [ "$result" = "42" ]; then pass "Regular SELECT works" @@ -373,7 +441,7 @@ else fi echo "" -echo "--- Test 29: CREATE TABLE / INSERT / SELECT work ---" +echo "--- Test 36: CREATE TABLE / INSERT / SELECT work ---" count=$(psql -t -A <<'SQL' | tail -1 CREATE TEMP TABLE _docker_test (id int); INSERT INTO _docker_test VALUES (1), (2), (3); From bdf3a8cb31c2014fd296af446d4c5ec0cb5c0bd5 Mon Sep 17 00:00:00 2001 From: Rust Wizard Date: Tue, 31 Mar 2026 12:17:02 +0300 Subject: [PATCH 2/5] debug logs --- src/lib.rs | 49 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2fa4c01..cb067ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,7 @@ static AUDIT_LOG_ENABLED: GucSetting = GucSetting::::new(true); static mut PREV_PROCESS_UTILITY_HOOK: pg_sys::ProcessUtility_hook_type = None; static mut PREV_FMGR_HOOK: pg_sys::fmgr_hook_type = None; +static mut PREV_NEEDS_FMGR_HOOK: pg_sys::needs_fmgr_hook_type = None; // Hook argument bundle struct ProcessUtilityArgs { @@ -290,6 +291,23 @@ unsafe fn extract_string_node(ptr: *mut std::os::raw::c_void) -> Option .map(|v| v.to_owned()) } +// needs_fmgr_hook: tells PostgreSQL to route specific functions through fmgr_security_definer +// so that fmgr_hook fires for them. Must not check GUC settings here — the result is +// cached per-function for the session lifetime. +#[pg_guard] +unsafe extern "C-unwind" fn needs_fmgr_hook_fn(fn_oid: pg_sys::Oid) -> bool { + pgrx::log!("needs_fmgr_hook_fn ENTER oid={:?}", fn_oid); + let result = file_access_command_type(fn_oid).is_some(); + pgrx::log!("needs_fmgr_hook called oid={:?} result={}", fn_oid, result); + if result { + return true; + } + if let Some(prev) = PREV_NEEDS_FMGR_HOOK { + return prev(fn_oid); + } + false +} + // FunctionManager hook for pg_read_file / pg_read_binary_file / pg_stat_file /// Returns the audit log command_type label if `fn_oid` is a file-access function /// in pg_catalog that we want to intercept, or `None` otherwise. @@ -299,18 +317,14 @@ unsafe fn file_access_command_type(fn_oid: pg_sys::Oid) -> Option<&'static str> pg_sys::Datum::from(fn_oid), ); if tuple.is_null() { + pgrx::log!("file_access_command_type oid={:?} tuple=null", fn_oid); return None; } let proc_form = pg_sys::GETSTRUCT(tuple) as *mut pg_sys::FormData_pg_proc; - let pronamespace = (*proc_form).pronamespace; - // Must be pg_catalog (OID 11). - if pronamespace != pg_sys::Oid::from(pg_sys::PG_CATALOG_NAMESPACE) { - pg_sys::ReleaseSysCache(tuple); - return None; - } - let name_cstr = - std::ffi::CStr::from_ptr((*proc_form).proname.data.as_ptr()); - let result = match name_cstr.to_str().unwrap_or("") { + let name_cstr = std::ffi::CStr::from_ptr((*proc_form).proname.data.as_ptr()); + let name = name_cstr.to_str().unwrap_or(""); + pgrx::log!("file_access_command_type oid={:?} name={:?}", fn_oid, name); + let result = match name { "pg_read_file" | "pg_read_binary_file" => Some("READ_FILE"), "pg_stat_file" => Some("STAT_FILE"), _ => None, @@ -325,6 +339,11 @@ unsafe extern "C-unwind" fn fmgr_hook_trampoline( flinfo: *mut pg_sys::FmgrInfo, arg: *mut pg_sys::Datum, ) { + pgrx::log!( + "fmgr_hook_trampoline called event={} oid={:?}", + event, + (*flinfo).fn_oid + ); // Chain previous hook first for all events so prior hooks get proper START/END/ABORT. if let Some(prev) = PREV_FMGR_HOOK { prev(event, flinfo, arg); @@ -378,15 +397,11 @@ unsafe extern "C-unwind" fn fmgr_hook_trampoline( block_reason.unwrap_or(""), ); - let msg = format!( - "{} command is not allowed", - command_type.replace('_', " ") - ); + let msg = format!("{} command is not allowed", command_type.replace('_', " ")); let hint = HINT .get() .and_then(|cstr| cstr.to_str().ok().map(str::to_owned)); - let mut report = - ErrorReport::new(PgSqlErrorCode::ERRCODE_INSUFFICIENT_PRIVILEGE, msg, ""); + let mut report = ErrorReport::new(PgSqlErrorCode::ERRCODE_INSUFFICIENT_PRIVILEGE, msg, ""); if let Some(h) = hint { report = report.set_hint(h); } @@ -734,6 +749,9 @@ pub extern "C-unwind" fn _PG_init() { PREV_FMGR_HOOK = pg_sys::fmgr_hook; pg_sys::fmgr_hook = Some(fmgr_hook_trampoline); + + PREV_NEEDS_FMGR_HOOK = pg_sys::needs_fmgr_hook; + pg_sys::needs_fmgr_hook = Some(needs_fmgr_hook_fn); } } @@ -965,7 +983,6 @@ mod tests { assert_eq!(show("pg_command_fw.block_read_file"), "on"); } - // Audit log table structure #[pg_test] fn test_audit_log_table_exists() { From ffda5d1dd1ba09640201189d788fc67c9873d1d6 Mon Sep 17 00:00:00 2001 From: Rust Wizard Date: Tue, 31 Mar 2026 12:33:57 +0300 Subject: [PATCH 3/5] debug log --- src/lib.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cb067ed..87edfc0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -297,15 +297,20 @@ unsafe fn extract_string_node(ptr: *mut std::os::raw::c_void) -> Option #[pg_guard] unsafe extern "C-unwind" fn needs_fmgr_hook_fn(fn_oid: pg_sys::Oid) -> bool { pgrx::log!("needs_fmgr_hook_fn ENTER oid={:?}", fn_oid); - let result = file_access_command_type(fn_oid).is_some(); - pgrx::log!("needs_fmgr_hook called oid={:?} result={}", fn_oid, result); - if result { - return true; - } - if let Some(prev) = PREV_NEEDS_FMGR_HOOK { - return prev(fn_oid); + // DEBUG: return true for ALL functions to see if trampoline fires for pg_read_file + return true; + #[allow(unreachable_code)] + { + let result = file_access_command_type(fn_oid).is_some(); + pgrx::log!("needs_fmgr_hook called oid={:?} result={}", fn_oid, result); + if result { + return true; + } + if let Some(prev) = PREV_NEEDS_FMGR_HOOK { + return prev(fn_oid); + } + false } - false } // FunctionManager hook for pg_read_file / pg_read_binary_file / pg_stat_file From 92d179ba3f5e75bc9c874c931bc4431d399e6556 Mon Sep 17 00:00:00 2001 From: Rust Wizard Date: Tue, 31 Mar 2026 15:23:09 +0300 Subject: [PATCH 4/5] replace fmgr_hook with post_parse_analyze_hook for pg_read_file blocking --- src/lib.rs | 217 ++++++++++++++++++++++++++++------------------------- 1 file changed, 116 insertions(+), 101 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 87edfc0..5fc7b73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,8 +43,7 @@ static HINT: GucSetting> = GucSetting::>::new(No static AUDIT_LOG_ENABLED: GucSetting = GucSetting::::new(true); static mut PREV_PROCESS_UTILITY_HOOK: pg_sys::ProcessUtility_hook_type = None; -static mut PREV_FMGR_HOOK: pg_sys::fmgr_hook_type = None; -static mut PREV_NEEDS_FMGR_HOOK: pg_sys::needs_fmgr_hook_type = None; +static mut PREV_POST_PARSE_ANALYZE_HOOK: pg_sys::post_parse_analyze_hook_type = None; // Hook argument bundle struct ProcessUtilityArgs { @@ -291,44 +290,19 @@ unsafe fn extract_string_node(ptr: *mut std::os::raw::c_void) -> Option .map(|v| v.to_owned()) } -// needs_fmgr_hook: tells PostgreSQL to route specific functions through fmgr_security_definer -// so that fmgr_hook fires for them. Must not check GUC settings here — the result is -// cached per-function for the session lifetime. -#[pg_guard] -unsafe extern "C-unwind" fn needs_fmgr_hook_fn(fn_oid: pg_sys::Oid) -> bool { - pgrx::log!("needs_fmgr_hook_fn ENTER oid={:?}", fn_oid); - // DEBUG: return true for ALL functions to see if trampoline fires for pg_read_file - return true; - #[allow(unreachable_code)] - { - let result = file_access_command_type(fn_oid).is_some(); - pgrx::log!("needs_fmgr_hook called oid={:?} result={}", fn_oid, result); - if result { - return true; - } - if let Some(prev) = PREV_NEEDS_FMGR_HOOK { - return prev(fn_oid); - } - false - } -} - -// FunctionManager hook for pg_read_file / pg_read_binary_file / pg_stat_file -/// Returns the audit log command_type label if `fn_oid` is a file-access function -/// in pg_catalog that we want to intercept, or `None` otherwise. +// Returns the audit log command_type label if `fn_oid` resolves to one of the +// file-access functions we block, or `None` otherwise. unsafe fn file_access_command_type(fn_oid: pg_sys::Oid) -> Option<&'static str> { let tuple = pg_sys::SearchSysCache1( pg_sys::SysCacheIdentifier::PROCOID as _, pg_sys::Datum::from(fn_oid), ); if tuple.is_null() { - pgrx::log!("file_access_command_type oid={:?} tuple=null", fn_oid); return None; } let proc_form = pg_sys::GETSTRUCT(tuple) as *mut pg_sys::FormData_pg_proc; let name_cstr = std::ffi::CStr::from_ptr((*proc_form).proname.data.as_ptr()); - let name = name_cstr.to_str().unwrap_or(""); - pgrx::log!("file_access_command_type oid={:?} name={:?}", fn_oid, name); + let name = name_cstr.to_str().unwrap_or(""); let result = match name { "pg_read_file" | "pg_read_binary_file" => Some("READ_FILE"), "pg_stat_file" => Some("STAT_FILE"), @@ -338,79 +312,123 @@ unsafe fn file_access_command_type(fn_oid: pg_sys::Oid) -> Option<&'static str> result } -#[pg_guard] -unsafe extern "C-unwind" fn fmgr_hook_trampoline( - event: pg_sys::FmgrHookEventType::Type, - flinfo: *mut pg_sys::FmgrInfo, - arg: *mut pg_sys::Datum, -) { - pgrx::log!( - "fmgr_hook_trampoline called event={} oid={:?}", - event, - (*flinfo).fn_oid - ); - // Chain previous hook first for all events so prior hooks get proper START/END/ABORT. - if let Some(prev) = PREV_FMGR_HOOK { - prev(event, flinfo, arg); - } - - if event != pg_sys::FmgrHookEventType::FHET_START { - return; - } - if !FW_ENABLED.get() || !BLOCK_READ_FILE.get() { - return; - } - - let command_type = match file_access_command_type((*flinfo).fn_oid) { - Some(ct) => ct, - None => return, - }; +// Thin wrappers around expression_tree_walker / query_tree_walker. +// PG16 renamed both to *_impl (they became macros in the headers), so the +// old symbol names are only available as real exports in PG15. +type WalkerFn = Option bool>; + +#[cfg(feature = "pg15")] +extern "C" { + fn expression_tree_walker( + node: *mut pg_sys::Node, + walker: WalkerFn, + context: *mut std::ffi::c_void, + ) -> bool; + fn query_tree_walker( + query: *mut pg_sys::Query, + walker: WalkerFn, + context: *mut std::ffi::c_void, + flags: std::ffi::c_int, + ) -> bool; +} - let current_user = get_current_username().unwrap_or_else(|| "unknown".to_string()); - let session_user = get_session_username().unwrap_or_else(|| "unknown".to_string()); - let in_blocked_list = is_role_blocked(¤t_user); +#[inline] +unsafe fn expr_tree_walk(node: *mut pg_sys::Node, walker: WalkerFn, ctx: *mut std::ffi::c_void) -> bool { + #[cfg(feature = "pg15")] + { expression_tree_walker(node, walker, ctx) } + #[cfg(not(feature = "pg15"))] + { pg_sys::expression_tree_walker_impl(node, walker, ctx) } +} - let block_reason = if in_blocked_list { - Some("role_listed") - } else { - Some("read_file") - }; +#[inline] +unsafe fn query_tree_walk(query: *mut pg_sys::Query, walker: WalkerFn, ctx: *mut std::ffi::c_void) -> bool { + #[cfg(feature = "pg15")] + { query_tree_walker(query, walker, ctx, 0) } + #[cfg(not(feature = "pg15"))] + { pg_sys::query_tree_walker_impl(query, walker, ctx, 0) } +} - let query_text = if !pg_sys::debug_query_string.is_null() { - std::ffi::CStr::from_ptr(pg_sys::debug_query_string) - .to_str() - .unwrap_or("") - } else { - "" - }; +// Walker callback: returns true (stop) when a blocked FuncExpr is found. +// Stores the command_type label in the context pointer. +unsafe extern "C-unwind" fn blocked_func_walker( + node: *mut pg_sys::Node, + context: *mut std::ffi::c_void, +) -> bool { + if node.is_null() { + return false; + } + if is_a(node, pg_sys::NodeTag::T_FuncExpr) { + let fe = node as *mut pg_sys::FuncExpr; + if let Some(ct) = file_access_command_type((*fe).funcid) { + *(context as *mut Option<&'static str>) = Some(ct); + return true; + } + } + // Descend into subqueries (SubLink, RTE_SUBQUERY, etc.) + if is_a(node, pg_sys::NodeTag::T_Query) { + return query_tree_walk(node as *mut pg_sys::Query, Some(blocked_func_walker), context); + } + expr_tree_walk(node, Some(blocked_func_walker), context) +} - write_audit_log( - &session_user, - ¤t_user, - query_text, - command_type, - None, - None, - true, - block_reason, - ); +// Scan the entire query tree for any call to a blocked file-access function. +unsafe fn query_find_blocked_func(query: *mut pg_sys::Query) -> Option<&'static str> { + let mut result: Option<&'static str> = None; + let ctx = &mut result as *mut Option<&'static str> as *mut std::ffi::c_void; + query_tree_walk(query, Some(blocked_func_walker), ctx); + result +} - pgrx::log!( - "blocked {} user={:?} reason={:?}", - command_type, - current_user, - block_reason.unwrap_or(""), - ); +// post_parse_analyze_hook trampoline (PG14+ signature: includes jstate) +#[pg_guard] +unsafe extern "C-unwind" fn post_parse_analyze_hook_fn( + pstate: *mut pg_sys::ParseState, + query: *mut pg_sys::Query, + jstate: *mut pg_sys::JumbleState, +) { + if FW_ENABLED.get() && BLOCK_READ_FILE.get() && !query.is_null() { + if let Some(command_type) = query_find_blocked_func(query) { + let current_user = get_current_username().unwrap_or_else(|| "unknown".to_string()); + let session_user = get_session_username().unwrap_or_else(|| "unknown".to_string()); + let in_blocked_list = is_role_blocked(¤t_user); + let block_reason = if in_blocked_list { + Some("role_listed") + } else { + Some("read_file") + }; + let query_text = if !pg_sys::debug_query_string.is_null() { + std::ffi::CStr::from_ptr(pg_sys::debug_query_string) + .to_str() + .unwrap_or("") + } else { + "" + }; + write_audit_log( + &session_user, + ¤t_user, + query_text, + command_type, + None, + None, + true, + block_reason, + ); + let msg = format!("{} command is not allowed", command_type.replace('_', " ")); + let hint = HINT + .get() + .and_then(|cstr| cstr.to_str().ok().map(str::to_owned)); + let mut report = + ErrorReport::new(PgSqlErrorCode::ERRCODE_INSUFFICIENT_PRIVILEGE, msg, ""); + if let Some(h) = hint { + report = report.set_hint(h); + } + report.report(PgLogLevel::ERROR); + } + } - let msg = format!("{} command is not allowed", command_type.replace('_', " ")); - let hint = HINT - .get() - .and_then(|cstr| cstr.to_str().ok().map(str::to_owned)); - let mut report = ErrorReport::new(PgSqlErrorCode::ERRCODE_INSUFFICIENT_PRIVILEGE, msg, ""); - if let Some(h) = hint { - report = report.set_hint(h); + if let Some(prev) = PREV_POST_PARSE_ANALYZE_HOOK { + prev(pstate, query, jstate); } - report.report(PgLogLevel::ERROR); } // Role helper @@ -752,11 +770,8 @@ pub extern "C-unwind" fn _PG_init() { PREV_PROCESS_UTILITY_HOOK = pg_sys::ProcessUtility_hook; pg_sys::ProcessUtility_hook = Some(hook_trampoline); - PREV_FMGR_HOOK = pg_sys::fmgr_hook; - pg_sys::fmgr_hook = Some(fmgr_hook_trampoline); - - PREV_NEEDS_FMGR_HOOK = pg_sys::needs_fmgr_hook; - pg_sys::needs_fmgr_hook = Some(needs_fmgr_hook_fn); + PREV_POST_PARSE_ANALYZE_HOOK = pg_sys::post_parse_analyze_hook; + pg_sys::post_parse_analyze_hook = Some(post_parse_analyze_hook_fn); } } From 8753713ca651be98d73a4ad2c4e1723b3bfe4e1e Mon Sep 17 00:00:00 2001 From: Rust Wizard Date: Tue, 31 Mar 2026 15:28:31 +0300 Subject: [PATCH 5/5] update README: add pg_read_file blocking, post-parse analyze hook, PG 15-18 support --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 86cbea3..b1495a8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # pg_command_fw -A PostgreSQL extension that intercepts and optionally blocks DDL and utility commands via a `ProcessUtility` hook. Each command category is independently controlled by a GUC flag. +A PostgreSQL extension that intercepts and optionally blocks DDL, utility commands, and dangerous built-in functions. Two hooks are used: `ProcessUtility` for DDL/utility statements, and the post-parse analyze hook for function calls such as `pg_read_file`. Each command category is independently controlled by a GUC flag. + +**Supported PostgreSQL versions: 15–18.** ## Building @@ -13,7 +15,7 @@ cargo install cargo-pgrx --version 0.17.0 --locked cargo pgrx init # downloads and configures a managed PostgreSQL instance ``` -Build for a specific PostgreSQL version (13–18): +Build for a specific PostgreSQL version (15–18): ```bash cargo build --features pg17 @@ -35,7 +37,7 @@ Spins up a temporary PostgreSQL process, runs all `#[pg_test]` functions, then s cargo pgrx test --features pg17 ``` -To run against a different PostgreSQL version replace `pg17` with `pg13`–`pg18`. +To run against a different PostgreSQL version replace `pg17` with `pg15`–`pg18`. ### Integration tests (Docker) @@ -71,6 +73,7 @@ CREATE EXTENSION pg_command_fw; | `LOAD` | `pg_command_fw.block_load` | `on` | Everyone including superusers | | `COPY … PROGRAM` | `pg_command_fw.block_copy_program` | `on` | Everyone including superusers | | Plain `COPY` | `pg_command_fw.block_copy` | `off` | Non-superusers (opt-in) | +| `pg_read_file` / `pg_read_binary_file` / `pg_stat_file` | `pg_command_fw.block_read_file` | `on` | Everyone including superusers | Superusers are always exempt from non-superuser checks unless they appear in `pg_command_fw.blocked_roles`. @@ -104,6 +107,9 @@ Block `COPY … TO/FROM PROGRAM` for all roles including superusers. Prevents sh **`pg_command_fw.block_copy`** (bool, default `off`) Block plain `COPY` (to/from file or stdout) for non-superusers. Superusers are exempt unless listed in `blocked_roles`. +**`pg_command_fw.block_read_file`** (bool, default `on`) +Block calls to `pg_read_file()`, `pg_read_binary_file()`, and `pg_stat_file()` for all roles including superusers. These functions allow reading arbitrary server-side files and represent the same data-exfiltration risk as `COPY TO FILE`. Enforced via the post-parse analyze hook, so calls are caught before planning regardless of how they are nested in the query. + ### Cross-category **`pg_command_fw.blocked_roles`** (string, default empty) @@ -126,7 +132,7 @@ Every intercepted command (allowed or blocked) is recorded in `command_fw.audit_ | `session_user_name` | text | Session-level user | | `current_user_name` | text | Current (possibly SET ROLE) user | | `query_text` | text | Original query string | -| `command_type` | text | e.g. `TRUNCATE`, `DROP_TABLE`, `ALTER_SYSTEM`, `LOAD`, `COPY_PROGRAM`, `COPY` | +| `command_type` | text | e.g. `TRUNCATE`, `DROP_TABLE`, `ALTER_SYSTEM`, `LOAD`, `COPY_PROGRAM`, `COPY`, `READ_FILE`, `STAT_FILE` | | `target_schema` | text | Schema that triggered the block (DROP TABLE with `production_schemas`) | | `target_object` | text | Object name (LOAD: library path) | | `client_addr` | inet | Client IP address |