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
74 changes: 63 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand All @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
53 changes: 50 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ pg_module_magic!();
static BLOCK_COPY_ENABLED: GucSetting<bool> = GucSetting::<bool>::new(true);
// Comma-separated list of roles that are always blocked, including superusers.
static BLOCKED_ROLES: GucSetting<Option<CString>> = GucSetting::<Option<CString>>::new(None);
// Direction-specific blocking (apply only when enabled=on and user is not a superuser).
static BLOCK_TO: GucSetting<bool> = GucSetting::<bool>::new(true);
static BLOCK_FROM: GucSetting<bool> = GucSetting::<bool>::new(true);
// Block COPY TO/FROM PROGRAM for all users, including superusers.
static BLOCK_PROGRAM: GucSetting<bool> = GucSetting::<bool>::new(true);

static mut PREV_PROCESS_UTILITY_HOOK: pg_sys::ProcessUtility_hook_type = None;

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

Expand Down Expand Up @@ -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);
Expand Down
79 changes: 70 additions & 9 deletions tests/docker/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -112,19 +111,81 @@ 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"
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"
Expand All @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions tests/pg_regress/expected/copy_blocked.out
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/pg_regress/sql/copy_blocked.sql
Original file line number Diff line number Diff line change
@@ -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);

Expand Down