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
6 changes: 4 additions & 2 deletions async_postgres/pg_client/transaction.nim
Original file line number Diff line number Diff line change
Expand Up @@ -483,12 +483,13 @@ macro withSavepoint*(conn: PgConnection, args: varargs[untyped]): untyped =
let (fireCleanupSkippedSym, csrConnInvalidatedSym, csrCleanupFailedSym) =
bindCleanupSkippedSyms()
let ckSpRollbackSym = bindSym"ckSavepointRollback"
let quoteIdentSym = bindSym"quoteIdentifier"

let nameExpr = savepointNameExpr(connSym, spName)

result = quote:
let `connSym` = `connExpr`
let `spNameSym` = `nameExpr`
let `spNameSym` = `quoteIdentSym`(`nameExpr`)
try:
discard
await `connSym`.simpleExec("SAVEPOINT " & `spNameSym`, timeout = `spTimeout`)
Expand Down Expand Up @@ -780,12 +781,13 @@ macro withSavepointDeadline*(conn: PgConnection, args: varargs[untyped]): untype
let (fireCleanupSkippedSym, csrConnInvalidatedSym, csrCleanupFailedSym) =
bindCleanupSkippedSyms()
let ckSpRollbackSym = bindSym"ckSavepointRollback"
let quoteIdentSym = bindSym"quoteIdentifier"

let nameExpr = savepointNameExpr(connSym, spName)

result = quote:
let `connSym` = `connExpr`
let `spNameSym` = `nameExpr`
let `spNameSym` = `quoteIdentSym`(`nameExpr`)
let `totalDurSym` = `deadline`
let `deadlineMomentSym` = Moment.now() + `totalDurSym`
proc `bodyFnSym`(): Future[void] {.async.} =
Expand Down
90 changes: 89 additions & 1 deletion tests/test_e2e_transaction.nim
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,7 @@ suite "E2E: Transaction":
raised = true

doAssert raised
doAssert "SAVEPOINT sp_outer_done" in queries
doAssert "SAVEPOINT \"sp_outer_done\"" in queries
var hasRollbackToSp = false
for q in queries:
if q.startsWith("ROLLBACK TO SAVEPOINT"):
Expand All @@ -978,6 +978,45 @@ suite "E2E: Transaction":

waitFor t()

test "withSavepoint quotes savepoint name (SQL injection guard)":
proc t() {.async.} =
let conn = await connect(plainConfig())
discard await conn.exec("DROP TABLE IF EXISTS sp_injection_probe2")
discard
await conn.exec("CREATE TABLE sp_injection_probe2 (id serial PRIMARY KEY)")

var queries = newSeq[string]()
let tracer = PgTracer()
tracer.onQueryStart = proc(
c: PgConnection, data: TraceQueryStartData
): TraceContext {.gcsafe, raises: [].} =
{.cast(gcsafe).}:
queries.add(data.sql)
return nil
conn.tracer = tracer

# withSavepoint requires a string-literal name; still worth verifying it
# is emitted quoted so identifier-legal characters (e.g. dashes) round-trip.
conn.withTransaction:
conn.withSavepoint("odd-name"):
discard await conn.exec("SELECT 1")

var sawSavepoint = false
var sawRelease = false
for q in queries:
if q == "SAVEPOINT \"odd-name\"":
sawSavepoint = true
elif q == "RELEASE SAVEPOINT \"odd-name\"":
sawRelease = true
doAssert sawSavepoint
doAssert sawRelease

conn.tracer = nil
discard await conn.exec("DROP TABLE sp_injection_probe2")
await conn.close()

waitFor t()

suite "E2E: Deadline-bounded Transaction":
test "withTransactionDeadline commits on success":
proc t() {.async.} =
Expand Down Expand Up @@ -1535,6 +1574,55 @@ suite "E2E: Deadline-bounded Transaction":

waitFor t()

test "withSavepointDeadline quotes savepoint name (SQL injection guard)":
proc t() {.async.} =
let conn = await connect(plainConfig())
discard await conn.exec("DROP TABLE IF EXISTS sp_injection_probe")
discard await conn.exec("CREATE TABLE sp_injection_probe (id serial PRIMARY KEY)")

# Name containing ';' and embedded '"' — if concatenated unquoted into
# simpleExec it would execute the trailing DROP TABLE via the simple
# query protocol's multi-statement support.
let hostileName = "sp\"; DROP TABLE sp_injection_probe; --"

var queries = newSeq[string]()
let tracer = PgTracer()
tracer.onQueryStart = proc(
c: PgConnection, data: TraceQueryStartData
): TraceContext {.gcsafe, raises: [].} =
{.cast(gcsafe).}:
queries.add(data.sql)
return nil
conn.tracer = tracer

conn.withTransaction:
conn.withSavepointDeadline(hostileName, seconds(5)):
discard await conn.exec("SELECT 1")

# Probe table must survive — injection payload never executed.
let res = await conn.query(
"SELECT 1 FROM information_schema.tables WHERE table_name = 'sp_injection_probe'"
)
doAssert res.rows.len == 1

# Quoted-identifier form: embedded '"' doubled per SQL rules.
let quoted = "\"sp\"\"; DROP TABLE sp_injection_probe; --\""
var sawSavepoint = false
var sawRelease = false
for q in queries:
if q == "SAVEPOINT " & quoted:
sawSavepoint = true
elif q == "RELEASE SAVEPOINT " & quoted:
sawRelease = true
doAssert sawSavepoint
doAssert sawRelease

conn.tracer = nil
discard await conn.exec("DROP TABLE sp_injection_probe")
await conn.close()

waitFor t()

suite "E2E: execInTransaction / queryInTransaction":
test "execInTransaction commits successfully":
proc t() {.async.} =
Expand Down