Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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)
Expand All @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion sql/hooks.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
);
Expand Down
172 changes: 172 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ static BLOCK_LOAD: GucSetting<bool> = GucSetting::<bool>::new(true);
static BLOCK_COPY_PROGRAM: GucSetting<bool> = GucSetting::<bool>::new(true);
// COPY (plain, non-PROGRAM): blocked for non-superusers (opt-in).
static BLOCK_COPY: GucSetting<bool> = GucSetting::<bool>::new(false);
// pg_read_file / pg_read_binary_file / pg_stat_file: blocked for all users including superusers.
static BLOCK_READ_FILE: GucSetting<bool> = GucSetting::<bool>::new(true);

// Cross-category settings
// Comma-separated list of roles that are always blocked, including superusers.
Expand All @@ -41,6 +43,7 @@ static HINT: GucSetting<Option<CString>> = GucSetting::<Option<CString>>::new(No
static AUDIT_LOG_ENABLED: GucSetting<bool> = GucSetting::<bool>::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 {
Expand Down Expand Up @@ -287,6 +290,147 @@ unsafe fn extract_string_node(ptr: *mut std::os::raw::c_void) -> Option<String>
.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<unsafe extern "C-unwind" fn(*mut pg_sys::Node, *mut std::ffi::c_void) -> 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(&current_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("<non-utf8 query>")
} else {
"<unknown>"
};
write_audit_log(
&session_user,
&current_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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading