Skip to content

Harden pg_query_state against non-local exits and unauthorized polling - #2038

Open
leborchuk wants to merge 1 commit into
apache:REL_2_STABLEfrom
leborchuk:QueryProgressPG14
Open

leborchuk wants to merge 1 commit into
apache:REL_2_STABLEfrom
leborchuk:QueryProgressPG14

Conversation

@leborchuk

Copy link
Copy Markdown
Contributor

Port fixes from #2029 back to REL_2_STABLE branch

Five issues from review of the PG16 port. Three share a root cause worth stating plainly: the collection path runs inside PG_TRY() from a signal handler, mixes C and C++ frames, and dismisses its own errors. Every non-local exit that skips cleanup therefore leaves damage behind in a backend that then keeps serving.

  1. Leaked interrupt holdoff -- longjmp over RESUME_INTERRUPTS

SendQueryState() holds interrupts for the whole collection, and build_plan_doc() took a second, nested hold. ExplainPrintPlan() can raise an error, and the longjmp out of it skips the inner RESUME_INTERRUPTS(); the PG_CATCH path that calls elog_dismiss(WARNING) then resumes only the caller's hold. InterruptHoldoffCount stays at 1 for the rest of the session and CHECK_FOR_INTERRUPTS() becomes a no-op, so the backend can no longer be cancelled or terminated. The re-throw path self-heals, because transaction abort resets the count -- the dismiss path, which is the common one here, does not.

build_plan_doc() has exactly one caller, so drop the redundant pair rather than wrap it in PG_FINALLY, and assert the precondition so the coupling is checkable instead of implicit.

  1. C++ exceptions escaping extern "C"

gpsc_qs_sync_config(), gpsc_emit_node_batch() and gpsc_emit_query_plan() are reached from C on the signal path with no exception boundary. Config::sync(), protobuf construction and serialization, and UDSConnector::report_extended() -- which has no try/catch of its own and uses std::string plus an RAII socket guard -- can all throw. An exception crossing an extern "C" frame is undefined behaviour and in practice calls std::terminate(), taking the backend down.

Add qs_emit_guard() and route all three entry points through it. cpp_call() in hook_wrappers.cpp cannot be reused as-is: it is a file-static template shaped for member-function pointers. It also differs deliberately in what it does once it has caught something. cpp_call() raises a PostgreSQL error, but an ereport(ERROR) longjmp from here would unwind C++ frames without running their destructors -- leaking the socket guard's fd -- and abandon the rest of the snapshot. This is best-effort telemetry taken while somebody else's query is mid-flight, so report at WARNING and return; a missing batch is just a missed sample.

  1. Missing STRICT on the trace-bearing functions

pg_query_state(), cbdb_mpp_query_state() and pg_query_state_backends() were declared without STRICT while every other function in both scripts has it. PG_GETARG_BYTEA_P() and PG_GETARG_ARRAYTYPE_P() do not consult isnull; they detoast a zero Datum, so passing NULL dereferences a null pointer instead of raising a controlled error. Mark all three STRICT in the fresh-install and the 1.1-to-1.2 script.

  1. Unauthorized polling through the PUBLIC QE entry point

cbdb_mpp_query_state() is granted to PUBLIC, carries no EXECUTE ON clause so it also runs on the coordinator, and selects targets by comparing the caller-supplied segid against GpIdentity.segindex -- which is -1 on the QD. An unprivileged session could therefore name (-1, victim_pid) directly and have another role's live plan collected and pushed to the UDS sink, bypassing the superuser-or-owner gate that pg_query_state() applies on the coordinator.

It cannot simply be revoked, because CdbDispatchCommand() runs it on the QEs as the session user. Apply the same per-target check inside the function instead. This cannot reject a legitimate dispatch: the coordinator has already authorized the caller, and a query's QEs run under the same role as its coordinator backend.

  1. Pending custom-signal flags not volatile sig_atomic_t

CustomSignalPendings is written by procsignal_sigusr1_handler() and read by CheckAndHandleCustomSignals() in normal code -- the same contract as pss_signalFlags in the same file, which is volatile sig_atomic_t. As a plain bool array the compiler may keep a stale copy across the read and silently drop notifications. CustomSignalProcessing (a recursion guard) and CustomInterruptHandlers (set once at _PG_init) are never touched from the handler and stay ordinary variables; say so in a comment.

Port fixes from apache#2029 back to REL_2_STABLE branch

Five issues from review of the PG16 port.  Three share a root cause worth
stating plainly: the collection path runs inside PG_TRY() from a signal
handler, mixes C and C++ frames, and dismisses its own errors.  Every
non-local exit that skips cleanup therefore leaves damage behind in a
backend that then keeps serving.

1. Leaked interrupt holdoff -- longjmp over RESUME_INTERRUPTS

SendQueryState() holds interrupts for the whole collection, and
build_plan_doc() took a second, nested hold.  ExplainPrintPlan() can raise
an error, and the longjmp out of it skips the inner RESUME_INTERRUPTS();
the PG_CATCH path that calls elog_dismiss(WARNING) then resumes only the
caller's hold.  InterruptHoldoffCount stays at 1 for the rest of the
session and CHECK_FOR_INTERRUPTS() becomes a no-op, so the backend can no
longer be cancelled or terminated.  The re-throw path self-heals, because
transaction abort resets the count -- the dismiss path, which is the
common one here, does not.

build_plan_doc() has exactly one caller, so drop the redundant pair rather
than wrap it in PG_FINALLY, and assert the precondition so the coupling is
checkable instead of implicit.

2. C++ exceptions escaping extern "C"

gpsc_qs_sync_config(), gpsc_emit_node_batch() and gpsc_emit_query_plan()
are reached from C on the signal path with no exception boundary.
Config::sync(), protobuf construction and serialization, and
UDSConnector::report_extended() -- which has no try/catch of its own and
uses std::string plus an RAII socket guard -- can all throw.  An exception
crossing an extern "C" frame is undefined behaviour and in practice calls
std::terminate(), taking the backend down.

Add qs_emit_guard() and route all three entry points through it.
cpp_call() in hook_wrappers.cpp cannot be reused as-is: it is a
file-static template shaped for member-function pointers.  It also differs
deliberately in what it does once it has caught something.  cpp_call()
raises a PostgreSQL error, but an ereport(ERROR) longjmp from here would
unwind C++ frames without running their destructors -- leaking the socket
guard's fd -- and abandon the rest of the snapshot.  This is best-effort
telemetry taken while somebody else's query is mid-flight, so report at
WARNING and return; a missing batch is just a missed sample.

3. Missing STRICT on the trace-bearing functions

pg_query_state(), cbdb_mpp_query_state() and pg_query_state_backends()
were declared without STRICT while every other function in both scripts
has it.  PG_GETARG_BYTEA_P() and PG_GETARG_ARRAYTYPE_P() do not consult
isnull; they detoast a zero Datum, so passing NULL dereferences a null
pointer instead of raising a controlled error.  Mark all three STRICT in
the fresh-install and the 1.1-to-1.2 script.

4. Unauthorized polling through the PUBLIC QE entry point

cbdb_mpp_query_state() is granted to PUBLIC, carries no EXECUTE ON clause
so it also runs on the coordinator, and selects targets by comparing the
caller-supplied segid against GpIdentity.segindex -- which is -1 on the
QD.  An unprivileged session could therefore name (-1, victim_pid)
directly and have another role's live plan collected and pushed to the UDS
sink, bypassing the superuser-or-owner gate that pg_query_state() applies
on the coordinator.

It cannot simply be revoked, because CdbDispatchCommand() runs it on the
QEs as the session user.  Apply the same per-target check inside the
function instead.  This cannot reject a legitimate dispatch: the
coordinator has already authorized the caller, and a query's QEs run under
the same role as its coordinator backend.

5. Pending custom-signal flags not volatile sig_atomic_t

CustomSignalPendings is written by procsignal_sigusr1_handler() and read
by CheckAndHandleCustomSignals() in normal code -- the same contract as
pss_signalFlags in the same file, which is volatile sig_atomic_t.  As a
plain bool array the compiler may keep a stale copy across the read and
silently drop notifications.  CustomSignalProcessing (a recursion guard)
and CustomInterruptHandlers (set once at _PG_init) are never touched from
the handler and stay ordinary variables; say so in a comment.
Copilot AI lite review requested due to automatic review settings September 21, 2026 16:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 1 High severity · 2 Medium severity · 1 Low severity

Open (4)
What changed in this PR

Backports multiple hardening fixes to pg_query_state for REL_2_STABLE, focusing on signal-path robustness, privilege enforcement, and making plan node typing stable across major PostgreSQL versions.

Changes:

  • Prevent backend corruption from non-local exits by removing nested interrupt holdoffs and adding C++ exception guards on extern "C" entry points.
  • Enforce per-target ownership/superuser checks in the QE entry point to prevent unauthorized query-state polling.
  • Introduce a stable, protocol-defined plan node type (decoupled from NodeTag) and plumb it through sampling + protobuf.
File Description
src/​backend/​storage/​ipc/​procsignal.c Makes custom-signal pending flags safe for signal handler communication.
src/​backend/​commands/​explain.c Adjusts worker-detail gating and filtered-row reporting to avoid unsafe states/div-by-zero.
gpcontrib/​gp_stats_collector/​src/​pg_query_state/​signal_handler.c Adds stable node-type mapping and walker support for T_Sequence; removes nested interrupt hold.
gpcontrib/​gp_stats_collector/​src/​pg_query_state/​qs_types.h Adds protocol-stable QsPlanNodeType and replaces raw NodeTag storage in samples.
gpcontrib/​gp_stats_collector/​src/​pg_query_state/​pg_query_state.c Adds per-target permission checks to prevent unauthorized polling via PUBLIC entry point.
gpcontrib/​gp_stats_collector/​src/​PlanNodeEmitter.cpp Adds exception boundary for extern "C" entry points; maps QsPlanNodeType to protobuf enum.
gpcontrib/​gp_stats_collector/​protos/​yagpcc_set_per_node.proto Changes per-node batch node_type to protocol PlanNodeType (not raw NodeTag).
gpcontrib/​gp_stats_collector/​protos/​yagpcc_plan.proto Defines PlanNodeType enum and documents remaining raw-NodeTag field as unused/unsafe.
gpcontrib/​gp_stats_collector/​gp_stats_collector--1.2.sql Marks trace-bearing functions STRICT to avoid NULL-datum crashes.
gpcontrib/​gp_stats_collector/​gp_stats_collector--1.1--1.2.sql Same STRICT fix for upgrade path.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +2978 to 2979
if (es->workers_state && (es->buffers || es->wal) && es->verbose
&& !es->runtime)
Comment on lines +166 to +167
QsPlanNodeType node_type; /* qs_map_node_type(nodeTag(plan)); a
* protocol value, not a raw NodeTag */
Comment on lines +4515 to +4535

if (which == 2)
nfiltered = ((nloops > 0) ? planstate->instrument->nfiltered2 / nloops : 0);
nfiltered = planstate->instrument->nfiltered2;
else
nfiltered = ((nloops > 0) ? planstate->instrument->nfiltered1 / nloops : 0);
nfiltered = planstate->instrument->nfiltered1;
nloops = planstate->instrument->nloops;

/* In text mode, suppress zero counts; they're not interesting enough */
/*
* In text mode, suppress zero counts; they're not interesting enough.
*
* The nloops == 0 case is what runtime mode hits for the whole of the first
* loop, so the counters cannot be averaged there; report 0 rather than
* dividing by zero.
*/
if (nfiltered > 0 || es->format != EXPLAIN_FORMAT_TEXT)
ExplainPropertyFloat(qlabel, NULL, nfiltered, 0, es);
{
if (nloops > 0)
ExplainPropertyFloat(qlabel, NULL, nfiltered / nloops, 0, es);
else
ExplainPropertyFloat(qlabel, NULL, 0.0, 0, es);
}
Comment on lines +315 to +317
default:
elog(DEBUG1, "pg_query_state: unmapped plan NodeTag %d", (int) tag);
return QS_PLAN_NODE_TYPE_UNSPECIFIED;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants