pg_durable Security Review

Review Date: 2026-03-18
System: pg_durable — PostgreSQL extension for durable SQL function execution
Version: 0.2.0 (current development)
Deployment Model: Single-tenant PostgreSQL instance
Reviewer: Security Review Agent (SDL methodology)
Companion: ThreatModelDFD.md | threat-model.tm7


1. Executive Summary

pg_durable is a PostgreSQL extension (Rust/pgrx) that provides durable SQL function execution within the PostgreSQL server process. Users build function graphs via SQL DSL operators, and a background worker executes them durably via the duroxide runtime. The extension also supports outbound HTTP requests via df.http().

Review Scope

  • Full extension codebase (src/, sql/, tests/)
  • Background worker architecture and privilege model
  • SQL DSL entry points and data flows
  • SSRF protection implementation
  • User isolation and RLS enforcement
  • Existing security documentation and specs

Overall Security Posture: GOOD with identified gaps

The extension demonstrates strong security design for its core threat model:

Strengths: - Privilege isolation via per-user sqlx connections is well-designed and correctly implemented - RLS enforcement on all user-facing tables with appropriate policies - Identity capture via PostgreSQL C API (GetUserId) records current_user at df.start() time - Comprehensive SSRF protection with IP blocklist, DNS rebinding prevention, and redirect disabling - SQL injection mitigated in critical paths (df.status, df.result use parameterized SPI) - Thorough security documentation with explicit threat model

Key Gaps: - No denial-of-service protections (rate limiting, quotas) — P0 - HTTP data exfiltration controls not yet implemented — P0 - Some activity SQL uses string formatting instead of parameterized queries — P1 - No encryption at rest for variables or HTTP credentials stored in node configs — P1 - TLS not enforced on PostgreSQL wire protocol — P2


2. Architecture Overview

System Components

┌──────────────────────────────────────────────────────────────┐
│                    PostgreSQL Server                          │
│                                                              │
│  ┌──────────────────┐     ┌──────────────────────────────┐  │
│  │  User Backend     │     │  Background Worker            │  │
│  │  (per session)    │     │  (single persistent process)  │  │
│  │                   │     │                                │  │
│  │  DSL functions    │     │  duroxide runtime              │  │
│  │  SPI calls        │     │  orchestrations/activities     │  │
│  │  Identity capture │     │  per-user SQL connections      │  │
│  └───────┬───────────┘     │  outbound HTTP (SSRF-safe)    │  │
│          │                 └──────────┬───────────────────┘  │
│          │                            │                      │
│  ┌───────┴────────────────────────────┴───────────────────┐  │
│  │  PostgreSQL Tables                                      │  │
│  │  df.instances  df.nodes  df.vars  duroxide.*           │  │
│  │  (RLS)         (RLS)     (RLS)    (worker-only)        │  │
│  └─────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Trust Boundaries

  1. External ↔ PostgreSQL Server: User connections via pg_hba.conf authentication
  2. User Session ↔ Shared Tables: RLS policies enforce per-user data isolation
  3. Background Worker ↔ User Tables: Worker bypasses RLS (superuser); creates per-user connections for SQL execution
  4. PostgreSQL Server ↔ External HTTP: SSRF-protected outbound HTTP from df.http()

Key Data Flows

# Flow Risk Level
DF-1 User → Backend (SQL DSL calls) Medium
DF-2 Backend → df.tables (graph persistence via SPI) Low
DF-3 Backend → df.vars (variable R/W via SPI) Low
DF-4 Backend → duroxide.* (instance enqueue) Low
DF-5 Worker → duroxide.* (work item polling) Low
DF-6 Worker → df.tables (graph loading) Medium
DF-7 Worker → df.tables (status updates) Low-Medium
DF-8 Worker → user tables (SQL execution) High
DF-9 Worker → external HTTP (outbound requests) High
DF-10 Backend → User (query results) Low

3. Findings by Category

3.1 Spoofing

ID Finding Severity Status
S-1 PostgreSQL authentication delegates to pg_hba.conf — extension does not add its own auth layer Info ✅ Appropriate for trusted extension model
S-2 User identity captured via unforgeable C API call (GetUserId); current_user must have LOGIN attribute Info ✅ Well-implemented
S-3 Per-user SQL connections authenticated directly as submitted_by via trust auth on localhost Medium ✅ Mitigated — pg_hba.conf trust is intentional and appropriate for same-host background worker
S-4 SECURITY DEFINER functions: GetUserId captures current_user, so SECURITY DEFINER submissions run as the definer Info ✅ Tested (E2E test 27_user_isolation)

3.2 Tampering

ID Finding Severity Status
T-1 SQL injection in SPI — FIXED: df.status() and df.result() now use parameterized queries (Spi::get_one_with_args) Critical (fixed) ✅ Mitigated
T-2 String formatting in activity SQL: update_instance_status and update_node_status use format!() for SQL construction. Instance IDs/node IDs come from trusted duroxide orchestration data, not user input, but parameterization would be more robust. Medium ⚠️ Recommend parameterize
T-3 Variable substitution is raw injection by design: {var} substitution replaces variables as-is into SQL. This is intentional (variables are SQL fragments), runs with user’s own privileges, and is documented. Medium ✅ Accepted risk (documented)
T-4 Result substitution ($name) quotes strings: String values from result substitution are properly escaped with single-quote doubling (s.replace('\'', "''")) Info ✅ Implemented
T-5 Role name quote escaping: connect_as_user() escapes double quotes in role names via replace('"', "\"\""). This is correct PostgreSQL identifier escaping. Info ✅ Correct
T-6 RLS prevents cross-user table manipulation: WITH CHECK clauses on all tables prevent user from inserting/updating rows with forged identity Info ✅ Well-implemented
T-7 Column-level UPDATE grant on df.instances: Only (status, updated_at) columns are writable by users; submitted_by, root_node, label are immutable Info ✅ Good defense-in-depth
T-8 search_path pinned on helper functions: PL/pgSQL helpers set search_path = pg_catalog, df, pg_temp Info ✅ Implemented
T-9 SSRF protection comprehensive: IP blocklist, DNS rebinding protection, redirect disabling, IPv6 mapped address handling Info ✅ Well-implemented

3.3 Repudiation

ID Finding Severity Status
R-1 Audit trail for SQL execution: submitted_by stored in df.instances and df.nodes Info ✅ Good
R-2 Audit trail for HTTP requests: submitted_by, URL, and method logged via trace_info Info ✅ Good
R-3 No centralized audit log table: Audit data is distributed across instance/node rows and worker log files. No dedicated, queryable audit log. Medium ⚠️ Recommend for GA
R-4 Worker logs include user SQL: Full query text logged, which aids forensics but may expose sensitive data in log files Low ⚠️ Log protection needed
R-5 No alerting for security events: SSRF blocks, privilege failures, and auth errors are logged but no alerting mechanism exists Low ⚠️ Deferred

3.4 Information Disclosure

ID Finding Severity Status
I-1 RLS on all user-facing tables: df.instances, df.nodes, df.vars all have RLS enabled with appropriate policies Info ✅ Well-implemented
I-2 duroxide.* schema not accessible to users: No GRANT to PUBLIC on duroxide schema Info ✅ Good isolation
I-3 df.vars stores values as plaintext: Users may store sensitive values (API keys, connection strings) in variables. No encryption at rest. High ⛔ Recommend encryption or warning
I-4 HTTP headers (incl. auth tokens) stored in df.nodes query column: When df.http() is called with Authorization headers, the full config JSON including credentials is stored in df.nodes. RLS-protected but no encryption. High ⛔ Recommend credential separation
I-5 TLS not enforced on PostgreSQL connections: Wire protocol uses whatever pg_hba.conf specifies. Extension does not enforce TLS. Medium ⚠️ Document requirement
I-6 Worker role (GUC) visible via current_setting(): pg_durable.worker_role is readable by any user Low ✅ Acceptable — not a secret
I-7 df.debug_connection() exposed connection string: This function returned the worker connection string (postgres://role@host:port/db) — no password or credential. Reclassified as non-security: the worker role is already exposed to any role via native PostgreSQL channels (world-readable pg_durable.worker_role GUC and pg_stat_activity.usename, per I-6), and the remaining fields (database, host/port, fixed schema) are connection-topology metadata, not credentials — the host is read from PGHOST (defaults to loopback). Info ✅ Resolved — function removed in v0.2.4 (#110) as surface-reduction cleanup, not a vulnerability
I-8 Superuser bypasses RLS: By design, superuser sees all users' data. Appropriate for single-tenant. Info ✅ Accepted

3.5 Denial of Service

ID Finding Severity Status
D-1 No rate limiting on df.start(): Any user can create unbounded instances, consuming storage and worker capacity High ⛔ NOT IMPLEMENTED
D-2 No per-user instance/node quotas: Storage can be exhausted by mass creation High ⛔ NOT IMPLEMENTED
D-3 No rate limiting on df.http(): Outbound HTTP connections are unbounded High ⛔ NOT IMPLEMENTED
D-4 No timeout enforcement on user SQL: execute_sql activity has no query timeout. Long-running queries block per-user connections (not worker pool). Medium ⚠️ Recommend statement_timeout
D-5 Worker fixed connection pool: 5 connections, prevents resource exhaustion but limits throughput Info ✅ Appropriate
D-6 HTTP timeout configurable: Default 30s, minimum enforced (>0) Info ✅ Good
D-7 No queue depth limit: duroxide work queue has no maximum depth Medium ⚠️ Recommend limit

3.6 Elevation of Privilege

ID Finding Severity Status
E-1 RESET ROLE cannot escalate: Per-user connections authenticated directly as submitted_by; RESET ROLE returns to user’s own identity Info ✅ Well-designed
E-2 SET ROLE membership-checked: Standard PostgreSQL RBAC applies on per-user connections Info ✅ Correct
E-3 SECURITY DEFINER behavior documented: GetUserId captures current_user, so SECURITY DEFINER submissions run as the definer. Info ✅ Tested
E-4 EXECUTE on all df.* functions granted to PUBLIC: Any database user can use the extension. Consider defaulting to a specific role. Medium ⚠️ Recommend REVOKE from PUBLIC, grant to specific role
E-5 df.http() EXECUTE not restricted by default: Per T9 in threat model, HTTP access should default to restricted High ⛔ NOT IMPLEMENTED
E-6 Worker superuser validates at startup: lib.rs checks if worker role is superuser — warns if not, but does not prevent startup Medium ⚠️ Consider hard-fail

4. Key Considerations Checklist

Hostile Multi-tenancy

Check Status Notes
Hyper-V sandboxes for compute isolation N/A Single-tenant deployment; no VM-level isolation needed
VNET isolation between tenants N/A Single-tenant; not applicable
Dedicated sandboxes for third-party apps N/A Extension runs in PostgreSQL process
Credential isolation per tenant/identity ✅ Per-user sqlx connections with separate authentication
Assume hostile root/SYSTEM code in sandboxes N/A Single-tenant; trusted extension model

Authentication & Authorization

Check Status Notes
Auth request validation ✅ PostgreSQL pg_hba.conf handles all authentication
Token acquisition N/A No token-based auth — PostgreSQL role-based
Authorization before resource access ✅ RLS enforces per-user isolation; RBAC for SQL execution
Least privilege principle ⚠️ PUBLIC has EXECUTE on all df.* functions; df.http() should be restricted
Role-based privilege separation ✅ User ↔ worker role separation; per-user connection isolation

Secrets Management

Check Status Notes
HSM-backed secrets storage ⛔ No secrets management infrastructure
Secrets inventory and rotation ⛔ No rotation mechanism; df.vars stores plaintext
No secrets in code or config ✅ No hardcoded secrets in extension code

Encryption

Check Status Notes
TLS 1.3 support ⚠️ PostgreSQL supports TLS but extension doesn’t enforce it
Data encrypted in transit ⚠️ Depends on pg_hba.conf; localhost trust auth has no encryption
Data encrypted at rest ⛔ df.vars, df.nodes (HTTP config) store plaintext

Data Validation

Check Status Notes
Input validation and sanitization ✅ Node types, HTTP methods, cron expressions, timeouts validated
Protection against injection attacks ✅ Parameterized SPI in critical paths; per-user connection isolation
Safe deserializers only ✅ serde_json for all JSON parsing

Auditability

Check Status Notes
Comprehensive logging ⚠️ HTTP requests logged; SQL logged in trace_info; no centralized audit log
Action attribution (who did what) ✅ submitted_by in all records
Log integrity protection ⛔ Worker logs to PostgreSQL log files; no tamper protection
Alerting for anomalies ⛔ No alerting mechanism

Dependencies & Supply Chain

Check Status Notes
Approved package management ✅ Cargo with pinned dependency versions
Static code analysis ⚠️ cargo clippy in CI; no dedicated security SAST tool
Component governance ⚠️ Key deps (pgrx, duroxide, sqlx, reqwest) are maintained
Code signing ⛔ No code signing for extension .so binary

5. Recommendations

5.1 Critical — Preview Blockers (P0)

# Recommendation Effort Related Finding
1 Implement rate limiting on df.start(): Add df.max_concurrent_per_user GUC and df.max_instances_per_user limit to prevent resource exhaustion Medium D-1, D-2
2 Restrict df.http() by default: REVOKE EXECUTE on df.http() from PUBLIC. Require explicit GRANT for HTTP access. Low E-5, I-3

5.2 High Priority — Before Preview (P1)

# Recommendation Effort Related Finding
3 Parameterize activity SQL: Convert update_instance_status and update_node_status to use sqlx bind parameters instead of format!() Low T-2
4 Add statement_timeout to per-user connections: Set statement_timeout on user SQL connections to prevent runaway queries Low D-4
5 Add rate limiting on df.http(): Implement df.max_http_requests_per_instance or per-user HTTP rate limit Medium D-3
6 Restrict df.debug_connection(): REVOKE EXECUTE from PUBLIC or gate behind superuser check → Superseded. Reclassified non-security (see I-7); function removed in v0.2.4 (#110) rather than restricted. Low I-7

5.3 Medium Priority — Before GA (P2)

# Recommendation Effort Related Finding
7 Document TLS requirements: Add production deployment guide requiring TLS on the PostgreSQL wire protocol Low I-5
8 Credential separation for HTTP headers: Store auth tokens separately from df.nodes query column (future df.secrets table) High I-4
9 Centralized audit log table: Create df.audit_log for security-relevant events (SSRF blocks, auth failures, cancellations) Medium R-3
10 REVOKE EXECUTE on df.* from PUBLIC: Default to a df_user role; require explicit GRANT Low E-4
11 Add SAST scanning to CI: Integrate cargo-audit and/or cargo-deny for supply chain and vulnerability scanning Low —
12 Protect worker logs: Ensure PostgreSQL log directory permissions prevent unauthorized access; consider log rotation Low R-4

5.4 Low Priority — Future Improvements

# Recommendation Effort Related Finding
13 Queue depth limit: Add maximum pending instance count across all users Medium D-7
14 Worker role hard-fail: Make worker startup fail (not just warn) if worker role is not superuser Low E-6
15 HTTP URL allowlist: Implement df.http_allowed_hosts GUC for fine-grained outbound control Medium I-3
16 Anomaly alerting: Add PostgreSQL NOTIFY-based alerting for security events Medium R-5

6. Items Already Well-Addressed

The following security areas are already implemented effectively:

  1. Privilege escalation prevention — Per-user sqlx connections with unforgeable identity capture; RESET ROLE and SET ROLE cannot escalate
  2. RLS data isolation — Proper policies on df.instances, df.nodes, df.vars with both USING and WITH CHECK
  3. SSRF protection — Multi-layer approach (scheme validation, IP blocklist, DNS resolver wrapper, redirect disabling, IPv6 handling)
  4. SQL injection in SPI — Critical paths (df.status, df.result) use parameterized queries
  5. search_path hardening — Helper functions pin search_path
  6. Column-level UPDATE grants — Prevents modification of identity columns
  7. Background worker isolation — Separate process, separate connections, superuser for control-plane only
  8. Deterministic orchestrations — No I/O in orchestration code; all side effects through activities

7. Appendix: Code Locations

Security Control Location
Identity capture src/dsl.rs — df_start() function
Per-user connections src/types.rs — connect_as_user()
RLS policies src/lib.rs — extension_sql! blocks
SSRF protection src/ssrf.rs
HTTP execution src/activities/execute_http.rs
SQL execution src/activities/execute_sql.rs
Worker setup src/worker.rs
Variable substitution src/types.rs — substitute_all_with_options()
Orchestration src/orchestrations/execute_function_graph.rs