From 1967c5313ff9acade5f8e14b10428f09a68d9276 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Mon, 1 Jun 2026 18:42:25 +0000 Subject: [PATCH 1/7] fix(migrations): pin pg_temp last in migration search_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each migration was applied with `SET LOCAL search_path TO `, which left pg_temp at its implicit highest-priority position. A temporary object could shadow the schema-qualified objects a migration references while it runs with elevated DDL privileges — a privilege-escalation vector. Migrations now run with `SET LOCAL search_path TO , pg_temp`, pinning the temporary-object schema to the lowest priority. The statement is built by a new `migration_search_path_stmt` helper covered by a unit test. Bumps the crate to 0.1.35 and updates migration docs. --- CHANGELOG.md | 12 +++++++ Cargo.toml | 2 +- migrations/0002_create_stored_procedures.sql | 2 +- migrations/README.md | 6 ++-- src/migrations.rs | 37 ++++++++++++++++++-- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c06348d..c837aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.35] - 2026-06-01 + +### Security + +- **Pin `pg_temp` last in the migration `search_path`.** Each migration was applied + with `SET LOCAL search_path TO `, which left `pg_temp` at its implicit + highest-priority position. A temporary object could therefore shadow the + schema-qualified objects a migration references while it runs with elevated (DDL) + privileges — a privilege-escalation vector. Migrations now run with + `SET LOCAL search_path TO , pg_temp`, pinning the temporary-object schema + to the lowest priority. No schema or behavioral change for well-behaved callers. + ## [0.1.34] - 2026-05-25 ### Security diff --git a/Cargo.toml b/Cargo.toml index 4ae5b74..3851f31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "pg-stress"] [package] name = "duroxide-pg" -version = "0.1.34" +version = "0.1.35" edition = "2021" authors = ["Affan Dar "] description = "A PostgreSQL-based provider implementation for Duroxide, a durable task orchestration framework" diff --git a/migrations/0002_create_stored_procedures.sql b/migrations/0002_create_stored_procedures.sql index c3427ba..0ebcee1 100644 --- a/migrations/0002_create_stored_procedures.sql +++ b/migrations/0002_create_stored_procedures.sql @@ -1,6 +1,6 @@ -- Migration 0002: Create stored procedures for PostgreSQL provider -- This migration creates schema-qualified stored procedures to replace inline SQL queries --- Note: This migration runs with SET LOCAL search_path TO {schema_name}, so procedures +-- Note: This migration runs with SET LOCAL search_path TO {schema_name}, pg_temp, so procedures -- will be created in the target schema automatically. However, procedures need to use -- schema-qualified table names to work correctly when called from different contexts. diff --git a/migrations/README.md b/migrations/README.md index 6dcee3c..2b7a9fd 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -43,7 +43,8 @@ CREATE TABLE {schema}._duroxide_migrations ( When writing migrations: -1. **Use schema-relative names**: Migrations are executed with `SET LOCAL search_path`, so use unqualified table names: +1. **Use schema-relative names**: Migrations are executed with + `SET LOCAL search_path TO , pg_temp`, so use unqualified table names: ```sql CREATE TABLE instances (...); ``` @@ -93,7 +94,8 @@ Migrations are automatically applied when creating a `PostgresProvider`. Each te - Migrations run inside transactions - Each migration is executed atomically (all-or-nothing) -- The `search_path` is set to the target schema for each migration +- The `search_path` is set to `, pg_temp` for each migration + (`pg_temp` is pinned last so temporary objects cannot shadow schema objects) - Migrations run in the order specified by their version numbers ## Troubleshooting diff --git a/src/migrations.rs b/src/migrations.rs index eb3b6d3..7552948 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -469,6 +469,19 @@ impl MigrationRunner { statements } + /// Build the `SET LOCAL search_path` statement used while applying a migration. + /// + /// `pg_temp` is appended explicitly so that the temporary-object schema sits at + /// the lowest search priority instead of its implicit highest-priority position. + /// This prevents an attacker-created temporary object from shadowing the objects + /// a migration references while it executes with elevated privileges. + /// + /// `schema_name` is validated at provider construction (it must match + /// `^[A-Za-z_][A-Za-z0-9_]*$`), so direct interpolation here is safe. + fn migration_search_path_stmt(schema_name: &str) -> String { + format!("SET LOCAL search_path TO {schema_name}, pg_temp") + } + /// Apply a single migration async fn apply_migration( &self, @@ -478,8 +491,14 @@ impl MigrationRunner { // Start transaction let mut tx = conn.begin().await?; - // Set search_path for this transaction - sqlx::query(&format!("SET LOCAL search_path TO {}", self.schema_name)) + // Set search_path for this transaction. + // + // `pg_temp` is pinned explicitly at the lowest priority. Without it, + // `pg_temp` keeps its implicit highest-priority position, which would let + // a temporary object shadow the schema objects this migration references + // while it runs with elevated (DDL) privileges — a privilege-escalation + // vector. See `migration_search_path_stmt`. + sqlx::query(&Self::migration_search_path_stmt(&self.schema_name)) .execute(&mut *tx) .await?; @@ -560,3 +579,17 @@ impl MigrationRunner { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_search_path_pins_pg_temp_last() { + let stmt = MigrationRunner::migration_search_path_stmt("duroxide"); + assert_eq!(stmt, "SET LOCAL search_path TO duroxide, pg_temp"); + // The security property: pg_temp must be present and last, so temporary + // objects cannot shadow the migration's schema-qualified references. + assert!(stmt.ends_with(", pg_temp")); + } +} From 98604c3c4149fdb57e54fefbc23607d4e700c7c6 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Mon, 1 Jun 2026 21:59:20 +0000 Subject: [PATCH 2/7] docs(migrations): frame pg_temp pinning as defense-in-depth Address code-review feedback: the per-session nature of pg_temp means there is no live privilege-escalation path for the trusted SQL the migration runner executes today, so reframe the source comments and CHANGELOG as defense-in-depth following the PostgreSQL search_path guidance (CVE-2018-1058). Also document why pg_catalog is intentionally left implicit in migration_search_path_stmt. --- CHANGELOG.md | 9 ++++++--- src/migrations.rs | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c837aba..ebafca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with `SET LOCAL search_path TO `, which left `pg_temp` at its implicit highest-priority position. A temporary object could therefore shadow the schema-qualified objects a migration references while it runs with elevated (DDL) - privileges — a privilege-escalation vector. Migrations now run with - `SET LOCAL search_path TO , pg_temp`, pinning the temporary-object schema - to the lowest priority. No schema or behavioral change for well-behaved callers. + privileges. Migrations now run with `SET LOCAL search_path TO , pg_temp`, + pinning the temporary-object schema to the lowest priority. This is + defense-in-depth following the PostgreSQL `search_path` hardening guidance + (CVE-2018-1058); `pg_temp` is per-session, so there is no live escalation path for + the trusted SQL the runner executes today. No schema or behavioral change for + well-behaved callers. ## [0.1.34] - 2026-05-25 diff --git a/src/migrations.rs b/src/migrations.rs index 7552948..1a3e215 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -473,8 +473,17 @@ impl MigrationRunner { /// /// `pg_temp` is appended explicitly so that the temporary-object schema sits at /// the lowest search priority instead of its implicit highest-priority position. - /// This prevents an attacker-created temporary object from shadowing the objects - /// a migration references while it executes with elevated privileges. + /// This is defense-in-depth following the PostgreSQL `search_path` guidance + /// (CVE-2018-1058): it stops a temporary object from shadowing the objects a + /// migration references while it runs with elevated (DDL) privileges. `pg_temp` + /// is per-session, so there is no live escalation path for the trusted, in-repo + /// SQL the runner executes today; this hardens against future reuse of the + /// migration connection or `SECURITY DEFINER` migrations. + /// + /// `pg_catalog` is intentionally *not* listed: when it is unnamed PostgreSQL + /// places it implicitly first, giving the desired `pg_catalog -> -> + /// pg_temp` order. Prepending it explicitly would add a maintenance trap without + /// improving safety, so leave it implicit. /// /// `schema_name` is validated at provider construction (it must match /// `^[A-Za-z_][A-Za-z0-9_]*$`), so direct interpolation here is safe. @@ -495,9 +504,9 @@ impl MigrationRunner { // // `pg_temp` is pinned explicitly at the lowest priority. Without it, // `pg_temp` keeps its implicit highest-priority position, which would let - // a temporary object shadow the schema objects this migration references - // while it runs with elevated (DDL) privileges — a privilege-escalation - // vector. See `migration_search_path_stmt`. + // a temporary object shadow the schema objects this migration references. + // This is defense-in-depth (CVE-2018-1058 guidance); see + // `migration_search_path_stmt` for the threat model. sqlx::query(&Self::migration_search_path_stmt(&self.schema_name)) .execute(&mut *tx) .await?; From d87e70053585c178ad3b049e7ca799de32edca62 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Mon, 1 Jun 2026 22:05:24 +0000 Subject: [PATCH 3/7] docs(migrations): correct shadowing wording to unqualified references Schema-qualified references cannot be shadowed by search_path; the actual risk is to the unqualified object references migrations rely on the search_path to resolve (e.g. CREATE TABLE instances, CREATE INDEX ... ON orchestrator_queue). Reword the helper/call-site comments, CHANGELOG, and migrations README to say "unqualified references" instead of "schema-qualified objects". --- CHANGELOG.md | 10 ++++++---- migrations/README.md | 3 ++- src/migrations.rs | 20 +++++++++++--------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebafca3..e53a4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Pin `pg_temp` last in the migration `search_path`.** Each migration was applied with `SET LOCAL search_path TO `, which left `pg_temp` at its implicit - highest-priority position. A temporary object could therefore shadow the - schema-qualified objects a migration references while it runs with elevated (DDL) - privileges. Migrations now run with `SET LOCAL search_path TO , pg_temp`, - pinning the temporary-object schema to the lowest priority. This is + highest-priority position. Migrations reference objects by unqualified name and + rely on the `search_path` to resolve them to the target schema, so a same-named + temporary object could be resolved instead while a migration runs with elevated + (DDL) privileges. Migrations now run with `SET LOCAL search_path TO , + pg_temp`, pinning the temporary-object schema to the lowest priority so it can no + longer shadow those unqualified references. This is defense-in-depth following the PostgreSQL `search_path` hardening guidance (CVE-2018-1058); `pg_temp` is per-session, so there is no live escalation path for the trusted SQL the runner executes today. No schema or behavioral change for diff --git a/migrations/README.md b/migrations/README.md index 2b7a9fd..3114817 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -95,7 +95,8 @@ Migrations are automatically applied when creating a `PostgresProvider`. Each te - Migrations run inside transactions - Each migration is executed atomically (all-or-nothing) - The `search_path` is set to `, pg_temp` for each migration - (`pg_temp` is pinned last so temporary objects cannot shadow schema objects) + (`pg_temp` is pinned last so temporary objects cannot shadow the unqualified + references migrations rely on the `search_path` to resolve) - Migrations run in the order specified by their version numbers ## Troubleshooting diff --git a/src/migrations.rs b/src/migrations.rs index 1a3e215..3f094b8 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -474,11 +474,13 @@ impl MigrationRunner { /// `pg_temp` is appended explicitly so that the temporary-object schema sits at /// the lowest search priority instead of its implicit highest-priority position. /// This is defense-in-depth following the PostgreSQL `search_path` guidance - /// (CVE-2018-1058): it stops a temporary object from shadowing the objects a - /// migration references while it runs with elevated (DDL) privileges. `pg_temp` - /// is per-session, so there is no live escalation path for the trusted, in-repo - /// SQL the runner executes today; this hardens against future reuse of the - /// migration connection or `SECURITY DEFINER` migrations. + /// (CVE-2018-1058): migrations reference objects by unqualified name and rely on + /// the `search_path` to resolve them to the target schema, so this stops a + /// same-named temporary object from being resolved instead while a migration runs + /// with elevated (DDL) privileges. (Schema-qualified references are never at + /// risk.) `pg_temp` is per-session, so there is no live escalation path for the + /// trusted, in-repo SQL the runner executes today; this hardens against future + /// reuse of the migration connection or `SECURITY DEFINER` migrations. /// /// `pg_catalog` is intentionally *not* listed: when it is unnamed PostgreSQL /// places it implicitly first, giving the desired `pg_catalog -> -> @@ -503,10 +505,10 @@ impl MigrationRunner { // Set search_path for this transaction. // // `pg_temp` is pinned explicitly at the lowest priority. Without it, - // `pg_temp` keeps its implicit highest-priority position, which would let - // a temporary object shadow the schema objects this migration references. - // This is defense-in-depth (CVE-2018-1058 guidance); see - // `migration_search_path_stmt` for the threat model. + // `pg_temp` keeps its implicit highest-priority position, which would let a + // temporary object shadow the unqualified references this migration relies on + // the search_path to resolve. This is defense-in-depth (CVE-2018-1058 + // guidance); see `migration_search_path_stmt` for the threat model. sqlx::query(&Self::migration_search_path_stmt(&self.schema_name)) .execute(&mut *tx) .await?; From 3c640b3a38fe5c2e479dc38a51dab94da1da9650 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Mon, 1 Jun 2026 15:09:49 -0700 Subject: [PATCH 4/7] test(migrations): add pg_temp shadowing integration test; note residual gap Address remaining code-review feedback on the pg_temp search_path hardening: - Add an #[ignore]-gated behavioral test (tests/migration_search_path_tests.rs) that proves the runner's `SET LOCAL search_path TO , pg_temp` resolves an unqualified reference to the schema object, and demonstrates that the legacy statement (pg_temp implicit first) is shadowed by a temp object. The existing unit test only locks the emitted string; this verifies its PostgreSQL semantics. - Document the residual gap in `migration_search_path_stmt`: a name existing only in pg_temp still resolves there, so migrations should reference only objects they define in . - Note in the CHANGELOG that existing deployments need no re-migration. - Correct the unit-test comment to say "unqualified" (not "schema-qualified") references, matching the corrected threat model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 +- src/migrations.rs | 8 +- tests/migration_search_path_tests.rs | 106 +++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 tests/migration_search_path_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e53a4f7..98ca581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 defense-in-depth following the PostgreSQL `search_path` hardening guidance (CVE-2018-1058); `pg_temp` is per-session, so there is no live escalation path for the trusted SQL the runner executes today. No schema or behavioral change for - well-behaved callers. + well-behaved callers. Existing deployments are unaffected and require no + re-migration — the change only governs how future migrations are applied. ## [0.1.34] - 2026-05-25 diff --git a/src/migrations.rs b/src/migrations.rs index 3f094b8..3f750c3 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -482,6 +482,11 @@ impl MigrationRunner { /// trusted, in-repo SQL the runner executes today; this hardens against future /// reuse of the migration connection or `SECURITY DEFINER` migrations. /// + /// This narrows but does not fully eliminate `pg_temp`: an unqualified name + /// that exists *only* in `pg_temp` (with no match in `` or + /// `pg_catalog`) still resolves to the temporary object. Migrations should + /// therefore continue to reference only objects they define in ``. + /// /// `pg_catalog` is intentionally *not* listed: when it is unnamed PostgreSQL /// places it implicitly first, giving the desired `pg_catalog -> -> /// pg_temp` order. Prepending it explicitly would add a maintenance trap without @@ -600,7 +605,8 @@ mod tests { let stmt = MigrationRunner::migration_search_path_stmt("duroxide"); assert_eq!(stmt, "SET LOCAL search_path TO duroxide, pg_temp"); // The security property: pg_temp must be present and last, so temporary - // objects cannot shadow the migration's schema-qualified references. + // objects cannot shadow the unqualified references a migration relies on + // the search_path to resolve. assert!(stmt.ends_with(", pg_temp")); } } diff --git a/tests/migration_search_path_tests.rs b/tests/migration_search_path_tests.rs new file mode 100644 index 0000000..35d7851 --- /dev/null +++ b/tests/migration_search_path_tests.rs @@ -0,0 +1,106 @@ +//! Behavioral verification that pinning `pg_temp` last in the migration +//! `search_path` (see `MigrationRunner::migration_search_path_stmt`) prevents a +//! same-named temporary object from shadowing the unqualified references a +//! migration relies on the `search_path` to resolve. +//! +//! The unit test `migration_search_path_pins_pg_temp_last` locks the *string* +//! the runner emits; this test proves that string has the intended PostgreSQL +//! name-resolution semantics. It is `#[ignore]`-d because it needs a live +//! database (`DATABASE_URL`). + +use sqlx::{Connection, PgConnection, Row}; + +fn get_database_url() -> String { + dotenvy::dotenv().ok(); + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set") +} + +fn unique_schema() -> String { + let guid = uuid::Uuid::new_v4().to_string(); + format!("sp_test_{}", &guid[guid.len() - 8..]) +} + +/// In a single connection we create a real `widget` table in `` and a +/// temporary `widget` that would shadow it. We then show that: +/// 1. with `SET LOCAL search_path TO , pg_temp` (the runner's +/// statement) an unqualified `widget` resolves to the SCHEMA table, and +/// 2. the legacy `SET LOCAL search_path TO ` (pg_temp implicit first) +/// resolves the same reference to the TEMP table — the behavior the fix +/// hardens against. +#[tokio::test] +#[ignore = "requires a live PostgreSQL (DATABASE_URL)"] +async fn pg_temp_pinned_last_does_not_shadow_unqualified_reference() { + let url = get_database_url(); + let schema = unique_schema(); + let mut conn = PgConnection::connect(&url).await.expect("connect"); + + // Real object in the target schema, tagged so we can tell it apart. + sqlx::query(&format!("CREATE SCHEMA {schema}")) + .execute(&mut conn) + .await + .expect("create schema"); + sqlx::query(&format!("CREATE TABLE {schema}.widget (src text NOT NULL)")) + .execute(&mut conn) + .await + .expect("create schema table"); + sqlx::query(&format!( + "INSERT INTO {schema}.widget (src) VALUES ('schema')" + )) + .execute(&mut conn) + .await + .expect("seed schema table"); + + // A temporary `widget` that shadows it unless pg_temp is demoted. + sqlx::query("CREATE TEMP TABLE widget (src text NOT NULL)") + .execute(&mut conn) + .await + .expect("create temp table"); + sqlx::query("INSERT INTO widget (src) VALUES ('temp')") + .execute(&mut conn) + .await + .expect("seed temp table"); + + // (1) The runner's statement: pg_temp pinned last -> schema wins. + let safe_stmt = format!("SET LOCAL search_path TO {schema}, pg_temp"); + let mut tx = conn.begin().await.expect("begin safe tx"); + sqlx::query(&safe_stmt) + .execute(&mut *tx) + .await + .expect("set safe search_path"); + let resolved: String = sqlx::query("SELECT src FROM widget LIMIT 1") + .fetch_one(&mut *tx) + .await + .expect("read unqualified widget (safe)") + .get("src"); + tx.rollback().await.expect("rollback safe tx"); + assert_eq!( + resolved, "schema", + "with pg_temp pinned last, an unqualified reference must resolve to the schema object" + ); + + // (2) Legacy statement: pg_temp implicit first -> temp shadows the schema. + let legacy_stmt = format!("SET LOCAL search_path TO {schema}"); + let mut tx = conn.begin().await.expect("begin legacy tx"); + sqlx::query(&legacy_stmt) + .execute(&mut *tx) + .await + .expect("set legacy search_path"); + let shadowed: String = sqlx::query("SELECT src FROM widget LIMIT 1") + .fetch_one(&mut *tx) + .await + .expect("read unqualified widget (legacy)") + .get("src"); + tx.rollback().await.expect("rollback legacy tx"); + assert_eq!( + shadowed, "temp", + "the legacy search_path leaves pg_temp first, so the temp object shadows the schema \ + object — this is the behavior the fix hardens against" + ); + + // Cleanup (temp table disappears with the session). + sqlx::query(&format!("DROP SCHEMA {schema} CASCADE")) + .execute(&mut conn) + .await + .expect("drop schema"); + conn.close().await.ok(); +} From 57cb770452eae5cabebb727976e149dbc2465704 Mon Sep 17 00:00:00 2001 From: Todd Green Date: Mon, 1 Jun 2026 16:12:52 -0700 Subject: [PATCH 5/7] test(migrations): drop redundant ends_with assertion The assert_eq! on the full statement already covers the pg_temp ordering, so the additional ends_with check was redundant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/migrations.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/migrations.rs b/src/migrations.rs index 3f750c3..9f39341 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -602,11 +602,10 @@ mod tests { #[test] fn migration_search_path_pins_pg_temp_last() { - let stmt = MigrationRunner::migration_search_path_stmt("duroxide"); - assert_eq!(stmt, "SET LOCAL search_path TO duroxide, pg_temp"); // The security property: pg_temp must be present and last, so temporary // objects cannot shadow the unqualified references a migration relies on // the search_path to resolve. - assert!(stmt.ends_with(", pg_temp")); + let stmt = MigrationRunner::migration_search_path_stmt("duroxide"); + assert_eq!(stmt, "SET LOCAL search_path TO duroxide, pg_temp"); } } From 8612d243462887ea5a58fb14423805669595608d Mon Sep 17 00:00:00 2001 From: Todd Green Date: Mon, 1 Jun 2026 16:14:35 -0700 Subject: [PATCH 6/7] chore: revert version bump; defer to release prep Per repo release procedure (prompts/publish-crate.md), the Cargo.toml version bump and a versioned CHANGELOG heading are part of the dedicated publish step, not feature PRs. Revert to 0.1.34 and move the migration search_path security note under an [Unreleased] section so it is picked up at the next release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ca581..3370e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.35] - 2026-06-01 +## [Unreleased] ### Security diff --git a/Cargo.toml b/Cargo.toml index 3851f31..4ae5b74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "pg-stress"] [package] name = "duroxide-pg" -version = "0.1.35" +version = "0.1.34" edition = "2021" authors = ["Affan Dar "] description = "A PostgreSQL-based provider implementation for Duroxide, a durable task orchestration framework" From aeb999da0df9578fcefee68e846b970ef260c07c Mon Sep 17 00:00:00 2001 From: Todd Green Date: Mon, 1 Jun 2026 16:17:46 -0700 Subject: [PATCH 7/7] docs(migrations): tighten migration_search_path_stmt doc comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/migrations.rs | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/src/migrations.rs b/src/migrations.rs index 9f39341..e8c9a7d 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -471,29 +471,14 @@ impl MigrationRunner { /// Build the `SET LOCAL search_path` statement used while applying a migration. /// - /// `pg_temp` is appended explicitly so that the temporary-object schema sits at - /// the lowest search priority instead of its implicit highest-priority position. - /// This is defense-in-depth following the PostgreSQL `search_path` guidance - /// (CVE-2018-1058): migrations reference objects by unqualified name and rely on - /// the `search_path` to resolve them to the target schema, so this stops a - /// same-named temporary object from being resolved instead while a migration runs - /// with elevated (DDL) privileges. (Schema-qualified references are never at - /// risk.) `pg_temp` is per-session, so there is no live escalation path for the - /// trusted, in-repo SQL the runner executes today; this hardens against future - /// reuse of the migration connection or `SECURITY DEFINER` migrations. + /// `pg_temp` is pinned last so it sits at the lowest priority instead of its + /// implicit highest-priority position, preventing a temporary object from + /// shadowing the unqualified references a migration resolves via the + /// `search_path`. This is defense-in-depth (CVE-2018-1058); `pg_catalog` is left + /// unlisted so PostgreSQL keeps it implicitly first. /// - /// This narrows but does not fully eliminate `pg_temp`: an unqualified name - /// that exists *only* in `pg_temp` (with no match in `` or - /// `pg_catalog`) still resolves to the temporary object. Migrations should - /// therefore continue to reference only objects they define in ``. - /// - /// `pg_catalog` is intentionally *not* listed: when it is unnamed PostgreSQL - /// places it implicitly first, giving the desired `pg_catalog -> -> - /// pg_temp` order. Prepending it explicitly would add a maintenance trap without - /// improving safety, so leave it implicit. - /// - /// `schema_name` is validated at provider construction (it must match - /// `^[A-Za-z_][A-Za-z0-9_]*$`), so direct interpolation here is safe. + /// `schema_name` is validated at provider construction + /// (`^[A-Za-z_][A-Za-z0-9_]*$`), so direct interpolation here is safe. fn migration_search_path_stmt(schema_name: &str) -> String { format!("SET LOCAL search_path TO {schema_name}, pg_temp") }