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/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 | 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..5fc7b73 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_POST_PARSE_ANALYZE_HOOK: pg_sys::post_parse_analyze_hook_type = None; // Hook argument bundle struct ProcessUtilityArgs { @@ -287,6 +290,147 @@ unsafe fn extract_string_node(ptr: *mut std::os::raw::c_void) -> Option .map(|v| v.to_owned()) } +// 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() { + 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(""); + let result = match name { + "pg_read_file" | "pg_read_binary_file" => Some("READ_FILE"), + "pg_stat_file" => Some("STAT_FILE"), + _ => None, + }; + pg_sys::ReleaseSysCache(tuple); + result +} + +// 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; +} + +#[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) } +} + +#[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) } +} + +// 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) +} + +// 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 +} + +// 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); + } + } + + if let Some(prev) = PREV_POST_PARSE_ANALYZE_HOOK { + prev(pstate, query, jstate); + } +} + // Role helper fn is_role_blocked(current_user: &str) -> bool { BLOCKED_ROLES @@ -578,6 +722,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 +769,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_POST_PARSE_ANALYZE_HOOK = pg_sys::post_parse_analyze_hook; + pg_sys::post_parse_analyze_hook = Some(post_parse_analyze_hook_fn); } } @@ -831,6 +990,19 @@ 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);