From 7520bcd9822551e2e1cf12b682394df023a0cac8 Mon Sep 17 00:00:00 2001 From: Rust Wizard Date: Sun, 8 Mar 2026 09:18:57 +0300 Subject: [PATCH] blocks granularity --- README.md | 74 +++++++++++++++++--- src/lib.rs | 53 ++++++++++++++- tests/docker/test.sh | 79 +++++++++++++++++++--- tests/pg_regress/expected/copy_blocked.out | 8 +-- tests/pg_regress/sql/copy_blocked.sql | 2 +- 5 files changed, 188 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 9017404..1e8918d 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,15 @@ A PostgreSQL extension that blocks `COPY` commands by installing a `ProcessUtili When loaded via `shared_preload_libraries`, the extension registers a hook into PostgreSQL's utility command processing pipeline. `COPY` statements are intercepted before execution according to the following priority: 1. If the role is listed in `block_copy_command.blocked_roles` → **always blocked**, even superusers -2. If `block_copy_command.enabled = off` → allowed (for roles not in the blocklist) -3. If the user is a superuser → **allowed** (bypass) -4. Otherwise → **blocked** +2. If `block_copy_command.block_program = on` and the statement is `COPY TO/FROM PROGRAM` → **always blocked**, even superusers +3. If `block_copy_command.enabled = off` → allowed (for roles not in the blocklist) +4. If the user is a superuser → **allowed** (bypass) +5. Otherwise, direction is checked: `block_copy_command.block_to` and `block_copy_command.block_from` ``` -ERROR: COPY command is not allowed +ERROR: COPY TO command is not allowed +ERROR: COPY FROM command is not allowed +ERROR: COPY TO PROGRAM command is not allowed ``` All other SQL commands (DDL, DML, queries) are unaffected and pass through to the standard handler. @@ -66,21 +69,25 @@ COPY is blocked for non-superusers by default: ```sql -- as a regular user: COPY my_table TO STDOUT; --- ERROR: COPY command is not allowed +-- ERROR: COPY TO command is not allowed COPY my_table FROM STDIN; --- ERROR: COPY command is not allowed +-- ERROR: COPY FROM command is not allowed COPY (SELECT * FROM my_table) TO '/tmp/out.csv'; --- ERROR: COPY command is not allowed +-- ERROR: COPY TO command is not allowed ``` -Superusers are bypassed unless explicitly listed in `block_copy_command.blocked_roles`: +Superusers are bypassed unless explicitly listed in `block_copy_command.blocked_roles` or unless `block_copy_command.block_program = on`: ```sql -- as a superuser (not in blocked_roles): COPY (SELECT 1) TO STDOUT; -- 1 + +-- COPY TO PROGRAM is blocked for everyone by default: +COPY (SELECT 1) TO PROGRAM 'cat'; +-- ERROR: COPY TO PROGRAM command is not allowed ``` ### GUC: `block_copy_command.enabled` @@ -89,7 +96,7 @@ Toggles the block for non-superusers at runtime. Only superusers can change this | Value | Effect | |-------|--------| -| `on` (default) | COPY blocked for non-superusers | +| `on` (default) | COPY blocked for non-superusers (subject to `block_to`/`block_from`) | | `off` | COPY allowed (roles not in `blocked_roles`) | **Per-role** (takes effect on next connection): @@ -115,6 +122,47 @@ COPY ...; SET block_copy_command.enabled = on; ``` +### GUC: `block_copy_command.block_to` + +Controls whether `COPY TO` (export) is blocked for non-superusers. Only evaluated when `enabled = on`. Only superusers can change this setting. + +| Value | Effect | +|-------|--------| +| `on` (default) | `COPY TO` blocked for non-superusers | +| `off` | `COPY TO` allowed for non-superusers | + +**Typical ETL pattern** — allow import, block export: + +```sql +-- Allow COPY FROM (import) for etl_user, keep COPY TO blocked +ALTER ROLE etl_user SET block_copy_command.block_from = off; +``` + +### GUC: `block_copy_command.block_from` + +Controls whether `COPY FROM` (import) is blocked for non-superusers. Only evaluated when `enabled = on`. Only superusers can change this setting. + +| Value | Effect | +|-------|--------| +| `on` (default) | `COPY FROM` blocked for non-superusers | +| `off` | `COPY FROM` allowed for non-superusers | + +### GUC: `block_copy_command.block_program` + +Blocks `COPY TO PROGRAM` and `COPY FROM PROGRAM` for **all users**, including superusers. This prevents shell command execution via COPY. Only superusers can change this setting. + +| Value | Effect | +|-------|--------| +| `on` (default) | `COPY ... PROGRAM` blocked for everyone | +| `off` | `COPY ... PROGRAM` allowed (subject to other rules) | + +```sql +-- Temporarily allow COPY TO PROGRAM for a superuser session: +SET block_copy_command.block_program = off; +COPY (SELECT 1) TO PROGRAM 'cat'; +SET block_copy_command.block_program = on; +``` + ### GUC: `block_copy_command.blocked_roles` A comma-separated list of role names that are **always** blocked from running `COPY`, regardless of superuser status or the `enabled` setting. Only superusers can change this setting. @@ -143,7 +191,7 @@ When a COPY command is blocked, the current username is written to the PostgreSQ ``` LOG: current_user = "someuser" -ERROR: COPY command is not allowed +ERROR: COPY TO command is not allowed ``` ## Testing @@ -156,7 +204,11 @@ docker compose up --build --abort-on-container-exit --exit-code-from test This builds the extension inside Docker, starts a PostgreSQL 17 instance with the extension loaded, and runs the integration test suite covering: -- COPY blocked for non-superusers (default) +- COPY TO and COPY FROM blocked for non-superusers (default) +- Direction-specific errors (`COPY TO command is not allowed` / `COPY FROM command is not allowed`) +- `block_from=off`: allows COPY FROM while keeping COPY TO blocked +- `block_to=off`: allows COPY TO while keeping COPY FROM blocked +- `block_program=on`: COPY TO/FROM PROGRAM blocked even for superusers - Superuser bypass - GUC `block_copy_command.enabled` toggle - GUC `block_copy_command.blocked_roles` blocks specific roles including superusers diff --git a/src/lib.rs b/src/lib.rs index b8d07d1..d9f8db3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,11 @@ pg_module_magic!(); static BLOCK_COPY_ENABLED: GucSetting = GucSetting::::new(true); // Comma-separated list of roles that are always blocked, including superusers. static BLOCKED_ROLES: GucSetting> = GucSetting::>::new(None); +// Direction-specific blocking (apply only when enabled=on and user is not a superuser). +static BLOCK_TO: GucSetting = GucSetting::::new(true); +static BLOCK_FROM: GucSetting = GucSetting::::new(true); +// Block COPY TO/FROM PROGRAM for all users, including superusers. +static BLOCK_PROGRAM: GucSetting = GucSetting::::new(true); static mut PREV_PROCESS_UTILITY_HOOK: pg_sys::ProcessUtility_hook_type = None; @@ -28,19 +33,34 @@ struct ProcessUtilityArgs { unsafe fn block_copy_process_utility(args: ProcessUtilityArgs) { let node = (*args.pstmt).utilityStmt; if !node.is_null() && is_a(node, pg_sys::NodeTag::T_CopyStmt) { + let copy_stmt = node as *mut pg_sys::CopyStmt; + let is_from = (*copy_stmt).is_from; + let is_program = (*copy_stmt).is_program; + let username = get_current_username().unwrap_or_else(|| "unknown".to_string()); let in_blocked_list = BLOCKED_ROLES.get() .and_then(|cstr| cstr.to_str().ok().map(|s| s.to_owned())) .map(|list| list.split(',').map(str::trim).any(|r| r == username)) .unwrap_or(false); - // blocked_roles overrides superuser bypass; enabled applies to non-superusers + // COPY TO/FROM PROGRAM is blocked for everyone (including superusers) when block_program=on. + let program_blocked = is_program && BLOCK_PROGRAM.get(); + + // Direction-based blocking applies to non-superusers when enabled=on. + let direction_blocked = if is_from { + BLOCK_FROM.get() + } else { + BLOCK_TO.get() + }; let should_block = in_blocked_list - || (BLOCK_COPY_ENABLED.get() && !pg_sys::superuser()); + || program_blocked + || (BLOCK_COPY_ENABLED.get() && !pg_sys::superuser() && direction_blocked); if should_block { pgrx::log!("current_user = {:?}", username); - pgrx::error!("COPY command is not allowed"); + let direction = if is_from { "FROM" } else { "TO" }; + let suffix = if is_program { " PROGRAM" } else { "" }; + pgrx::error!("COPY {}{} command is not allowed", direction, suffix); } } @@ -113,6 +133,33 @@ pub extern "C-unwind" fn _PG_init() { GucFlags::default(), ); + GucRegistry::define_bool_guc( + c"block_copy_command.block_to", + c"Block COPY TO commands for non-superusers", + c"When on (default), COPY TO (export) is blocked for non-superusers. Set to off to allow COPY TO while keeping COPY FROM blocked.", + &BLOCK_TO, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"block_copy_command.block_from", + c"Block COPY FROM commands for non-superusers", + c"When on (default), COPY FROM (import) is blocked for non-superusers. Set to off to allow COPY FROM while keeping COPY TO blocked.", + &BLOCK_FROM, + GucContext::Suset, + GucFlags::default(), + ); + + GucRegistry::define_bool_guc( + c"block_copy_command.block_program", + c"Block COPY TO/FROM PROGRAM for all users including superusers", + c"When on (default), COPY TO/FROM PROGRAM is blocked for all users including superusers. This prevents shell command execution via COPY.", + &BLOCK_PROGRAM, + GucContext::Suset, + GucFlags::default(), + ); + unsafe { PREV_PROCESS_UTILITY_HOOK = pg_sys::ProcessUtility_hook; pg_sys::ProcessUtility_hook = Some(hook_trampoline); diff --git a/tests/docker/test.sh b/tests/docker/test.sh index 0ccebef..d3f0df4 100755 --- a/tests/docker/test.sh +++ b/tests/docker/test.sh @@ -26,7 +26,7 @@ echo "=== Block for non-superuser (GUC enabled, default) ===" echo "" echo "--- Test 1: non-superuser COPY TO STDOUT is blocked ---" out=$(PGPASSWORD=testpass $RU -c "COPY pg_class TO STDOUT;" 2>&1 || true) -if echo "$out" | grep -q "COPY command is not allowed"; then +if echo "$out" | grep -q "COPY TO command is not allowed"; then pass "non-superuser COPY TO STDOUT blocked" else fail "non-superuser COPY TO STDOUT not blocked; got: $out" @@ -35,7 +35,7 @@ fi echo "" echo "--- Test 2: non-superuser COPY FROM STDIN is blocked ---" out=$(PGPASSWORD=testpass $RU -c "COPY pg_class FROM STDIN;" 2>&1 || true) -if echo "$out" | grep -q "COPY command is not allowed"; then +if echo "$out" | grep -q "COPY FROM command is not allowed"; then pass "non-superuser COPY FROM STDIN blocked" else fail "non-superuser COPY FROM STDIN not blocked; got: $out" @@ -44,7 +44,7 @@ fi echo "" echo "--- Test 3: non-superuser COPY (query) TO STDOUT is blocked ---" out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO STDOUT;" 2>&1 || true) -if echo "$out" | grep -q "COPY command is not allowed"; then +if echo "$out" | grep -q "COPY TO command is not allowed"; then pass "non-superuser COPY (query) TO STDOUT blocked" else fail "non-superuser COPY (query) TO STDOUT not blocked; got: $out" @@ -79,7 +79,7 @@ echo "" echo "--- Test 6: re-enable GUC -> non-superuser COPY is blocked again ---" psql -c "ALTER ROLE testuser RESET block_copy_command.enabled;" out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO STDOUT;" 2>&1 || true) -if echo "$out" | grep -q "COPY command is not allowed"; then +if echo "$out" | grep -q "COPY TO command is not allowed"; then pass "non-superuser COPY blocked after GUC re-enabled" else fail "non-superuser COPY not blocked after GUC re-enabled; got: $out" @@ -90,9 +90,8 @@ echo "=== GUC block_copy_command.blocked_roles ===" echo "" echo "--- Test 7: superuser in blocked_roles is blocked ---" -psql -c "SET block_copy_command.blocked_roles = 'postgres'; COPY (SELECT 1) TO STDOUT;" > /dev/null 2>&1 || true out=$(psql -c "SET block_copy_command.blocked_roles = 'postgres'; COPY (SELECT 1) TO STDOUT;" 2>&1 || true) -if echo "$out" | grep -q "COPY command is not allowed"; then +if echo "$out" | grep -q "COPY TO command is not allowed"; then pass "superuser blocked when listed in blocked_roles" else fail "superuser not blocked when listed in blocked_roles; got: $out" @@ -112,7 +111,7 @@ echo "--- Test 9: non-superuser in blocked_roles is blocked even when enabled=of psql -c "ALTER ROLE testuser SET block_copy_command.enabled = off;" psql -c "ALTER ROLE testuser SET block_copy_command.blocked_roles = 'testuser';" out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO STDOUT;" 2>&1 || true) -if echo "$out" | grep -q "COPY command is not allowed"; then +if echo "$out" | grep -q "COPY TO command is not allowed"; then pass "non-superuser in blocked_roles is blocked even when enabled=off" else fail "non-superuser in blocked_roles not blocked when enabled=off; got: $out" @@ -120,11 +119,73 @@ fi psql -c "ALTER ROLE testuser RESET block_copy_command.enabled;" psql -c "ALTER ROLE testuser RESET block_copy_command.blocked_roles;" +echo "" +echo "=== GUC block_copy_command.block_from ===" + +echo "" +echo "--- Test 10: block_from=off -> COPY FROM allowed, COPY TO still blocked ---" +psql -c "ALTER ROLE testuser SET block_copy_command.block_from = off;" +out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO STDOUT;" 2>&1 || true) +if echo "$out" | grep -q "COPY TO command is not allowed"; then + pass "COPY TO still blocked when block_from=off" +else + fail "COPY TO not blocked when block_from=off; got: $out" +fi +psql -c "CREATE TABLE IF NOT EXISTS _bcc_from_test (id int);" +psql -c "GRANT INSERT ON _bcc_from_test TO testuser;" +out=$(PGPASSWORD=testpass $RU -c "COPY _bcc_from_test FROM STDIN;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "COPY FROM allowed when block_from=off" +else + fail "COPY FROM blocked when block_from=off; got: $out" +fi +psql -c "DROP TABLE _bcc_from_test;" +psql -c "ALTER ROLE testuser RESET block_copy_command.block_from;" + +echo "" +echo "--- Test 11: block_to=off -> COPY TO allowed, COPY FROM still blocked ---" +psql -c "ALTER ROLE testuser SET block_copy_command.block_to = off;" +psql -c "GRANT SELECT ON pg_class TO testuser;" +out=$(PGPASSWORD=testpass $RU -c "COPY (SELECT 1) TO STDOUT;" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "COPY TO allowed when block_to=off" +else + fail "COPY TO blocked when block_to=off; got: $out" +fi +out=$(PGPASSWORD=testpass $RU -c "COPY pg_class FROM STDIN;" 2>&1 || true) +if echo "$out" | grep -q "COPY FROM command is not allowed"; then + pass "COPY FROM still blocked when block_to=off" +else + fail "COPY FROM not blocked when block_to=off; got: $out" +fi +psql -c "ALTER ROLE testuser RESET block_copy_command.block_to;" + +echo "" +echo "=== GUC block_copy_command.block_program ===" + +echo "" +echo "--- Test 12: COPY TO PROGRAM blocked for superuser by default ---" +out=$(psql -c "COPY (SELECT 1) TO PROGRAM 'cat';" 2>&1 || true) +if echo "$out" | grep -q "COPY TO PROGRAM command is not allowed"; then + pass "COPY TO PROGRAM blocked for superuser (block_program=on)" +else + fail "COPY TO PROGRAM not blocked for superuser; got: $out" +fi + +echo "" +echo "--- Test 13: block_program=off -> COPY TO PROGRAM allowed for superuser ---" +out=$(psql -c "SET block_copy_command.block_program = off; COPY (SELECT 1) TO PROGRAM 'cat';" 2>&1) +if ! echo "$out" | grep -q "not allowed"; then + pass "COPY TO PROGRAM allowed for superuser when block_program=off" +else + fail "COPY TO PROGRAM blocked for superuser when block_program=off; got: $out" +fi + echo "" echo "=== Regular SQL unaffected ===" echo "" -echo "--- Test 10: SELECT works ---" +echo "--- Test 14: SELECT works ---" result=$(psql -t -A -c "SELECT 42;") if [ "$result" = "42" ]; then pass "Regular SELECT works" @@ -133,7 +194,7 @@ else fi echo "" -echo "--- Test 11: DDL and DML work ---" +echo "--- Test 15: DDL and DML work ---" count=$(psql -t -A <<'SQL' | tail -1 CREATE TEMP TABLE _docker_test (id int); INSERT INTO _docker_test VALUES (1), (2), (3); diff --git a/tests/pg_regress/expected/copy_blocked.out b/tests/pg_regress/expected/copy_blocked.out index 931b078..ea13a46 100644 --- a/tests/pg_regress/expected/copy_blocked.out +++ b/tests/pg_regress/expected/copy_blocked.out @@ -1,17 +1,17 @@ --- Test that COPY TO is blocked by the hook +-- Test that COPY TO and COPY FROM are blocked by the hook CREATE TEMP TABLE copy_test (id int); INSERT INTO copy_test VALUES (1), (2), (3); \set ON_ERROR_STOP off COPY copy_test TO STDOUT; -ERROR: COPY command is not allowed +ERROR: COPY TO command is not allowed COPY copy_test FROM STDIN; -ERROR: COPY command is not allowed +ERROR: COPY FROM command is not allowed COPY pg_class TO STDOUT; -ERROR: COPY command is not allowed +ERROR: COPY TO command is not allowed \set ON_ERROR_STOP on diff --git a/tests/pg_regress/sql/copy_blocked.sql b/tests/pg_regress/sql/copy_blocked.sql index d08c243..876900b 100644 --- a/tests/pg_regress/sql/copy_blocked.sql +++ b/tests/pg_regress/sql/copy_blocked.sql @@ -1,4 +1,4 @@ --- Test that COPY TO is blocked by the hook +-- Test that COPY TO and COPY FROM are blocked by the hook CREATE TEMP TABLE copy_test (id int); INSERT INTO copy_test VALUES (1), (2), (3);