From d9a2c748927d371ec71f4e9d17d881c4c7510bfc Mon Sep 17 00:00:00 2001
From: Lucas Jeffrey <lucas.jeffrey@anachronics.com>
Date: Mon, 5 Oct 2026 09:27:04 -0300
Subject: [PATCH v8 PG15 2/2] Fix use-after-free of RI query plans in reentrant
 RI checks

RI checks can be reentered while running: for example, with an ON DELETE
CASCADE foreign key on a self-referencing table and a BEFORE DELETE
trigger that deletes from the same table, the cascade's DELETE fires the
trigger, whose own DELETE fires the cascade again, which runs the same
cached plan.  If that plan has been invalidated in the meantime, the
nested call's ri_FetchPreparedPlan() sees it as invalid and frees it
with SPI_freeplan(), while the outer call is still executing it.  The
outer call then crashes in _SPI_execute_plan().

To fix, add a backend-local hash table,
ri_query_plan_cache_executing_refcount, keyed by the plan pointer, that
counts the executions in progress of each RI plan.  ri_PerformCheck()
creates the plan's entry when it starts executing a plan that has none,
or increments its count otherwise, and decrements the count when the
execution finishes.  When ri_FetchPreparedPlan() finds an invalid plan
that has an entry, it removes it from the query hash table as before,
but defers freeing it.

When the last execution of a plan finishes:

- If freeing the plan was not deferred, its entry is removed, and the
  plan stays in the query hash table as usual.

- If freeing it was deferred and the execution finished normally, the
  entry is removed and the plan is freed right away.

- If freeing it was deferred and the execution was aborted by an
  error, the plan is not freed while the error is being propagated.
  Its entry is kept with a zero count, and the plan is freed by the next
  RI query execution, which scans the table for such entries.  The plan
  is no longer in the query hash table, so nothing can be using it.

To decrement the count when the query throws an error, ri_PerformCheck()
wraps the execution in PG_TRY().  The error passes through every
ri_PerformCheck() call it unwinds, and each one drops the count of its
own execution.  If the error is caught by a PL/pgSQL exception block in
a trigger, only the executions inside that block are aborted, and those
in the outer levels keep their count.

ri_query_plan_cache_executing_refcount only has entries while RI
queries are being executed, so it is small, and it is empty outside of
nested RI checks, in which case the scan done by each RI query execution
is skipped.

Signed-off-by: Lucas Jeffrey <lucas.jeffrey@anachronics.com>
---
 src/backend/utils/adt/ri_triggers.c | 235 +++++++++++++++++++++++++++-
 src/tools/pgindent/typedefs.list    |   1 +
 2 files changed, 229 insertions(+), 7 deletions(-)

diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c
index 51b3fdc9a01..bd49ce5d2d6 100644
--- a/src/backend/utils/adt/ri_triggers.c
+++ b/src/backend/utils/adt/ri_triggers.c
@@ -62,6 +62,7 @@
 
 #define RI_INIT_CONSTRAINTHASHSIZE		64
 #define RI_INIT_QUERYHASHSIZE			(RI_INIT_CONSTRAINTHASHSIZE * 4)
+#define RI_INIT_PLANEXECHASHSIZE		16
 
 #define RI_KEYS_ALL_NULL				0
 #define RI_KEYS_SOME_NULL				1
@@ -170,12 +171,39 @@ typedef struct RI_CompareHashEntry
 } RI_CompareHashEntry;
 
 
+/*
+ * RI_QueryPlanCacheExecutingRefCountEntry
+ *
+ * Tracks a saved RI plan while ri_PerformCheck() is executing it.  RI checks
+ * can nest: a trigger fired by an RI query may run another RI query using the
+ * same plan (e.g. ON DELETE CASCADE on a self-referencing table, with a BEFORE
+ * DELETE trigger that deletes from that table).  If the inner call finds the
+ * plan invalid, it must not free it while an outer call is still executing
+ * it; instead it defers freeing it, and the last execution frees it.
+ *
+ * An entry exists only while the plan is being executed, plus, for a plan
+ * whose freeing was deferred and whose last execution was aborted by an
+ * error, until the next RI query execution, possibly in a later transaction.
+ * A plan is never freed while it has an entry, so its address cannot be
+ * reused by another plan in the meantime.
+ *
+ * ri_PerformCheck() catches errors thrown by the query, so that the count is
+ * decremented even then.
+ */
+typedef struct RI_QueryPlanCacheExecutingRefCountEntry
+{
+	SPIPlanPtr	plan;			/* hash key */
+	bool		freeDeferred;	/* free when refcount reaches 0 */
+	uint32		refcount;		/* # of executions in progress */
+} RI_QueryPlanCacheExecutingRefCountEntry;
+
 /*
  * Local data
  */
 static HTAB *ri_constraint_cache = NULL;
 static HTAB *ri_query_cache = NULL;
 static HTAB *ri_compare_cache = NULL;
+static HTAB *ri_query_plan_cache_executing_refcount = NULL;
 static dlist_head ri_constraint_cache_valid_list;
 static int	ri_constraint_cache_valid_count = 0;
 
@@ -212,6 +240,13 @@ static SPIPlanPtr ri_FetchPreparedPlan(RI_QueryKey *key);
 static void ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr plan);
 static RI_CompareHashEntry *ri_HashCompareOp(Oid eq_opr, Oid typeid);
 
+static void ri_PreparedPlanExecutionStarted(SPIPlanPtr plan);
+static void ri_PreparedPlanExecutionFinished(SPIPlanPtr plan);
+static void ri_PreparedPlanExecutionAborted(SPIPlanPtr plan);
+static void ri_PreparedPlanDecrementRefCount(SPIPlanPtr plan, bool canFree);
+static void ri_PreparedPlanFreeOrDefer(SPIPlanPtr plan);
+static void ri_PreparedPlanFreeDeferred(void);
+
 static void ri_CheckTrigger(FunctionCallInfo fcinfo, const char *funcname,
 							int tgkind);
 static const RI_ConstraintInfo *ri_FetchConstraintInfo(Trigger *trigger,
@@ -2404,11 +2439,27 @@ ri_PerformCheck(const RI_ConstraintInfo *riinfo,
 						   save_sec_context | SECURITY_LOCAL_USERID_CHANGE |
 						   SECURITY_NOFORCE_RLS);
 
-	/* Finally we can run the query. */
-	spi_result = SPI_execute_snapshot(qplan,
-									  vals, nulls,
-									  test_snapshot, crosscheck_snapshot,
-									  false, false, limit);
+	/*
+	 * Triggers fired by the query may reach ri_FetchPreparedPlan() for the
+	 * same query key and find the plan invalid, so tell it that the plan is
+	 * in use while it runs.
+	 */
+	ri_PreparedPlanExecutionStarted(qplan);
+	PG_TRY();
+	{
+		/* Finally we can run the query. */
+		spi_result = SPI_execute_snapshot(qplan,
+										  vals, nulls,
+										  test_snapshot, crosscheck_snapshot,
+										  false, false, limit);
+	}
+	PG_CATCH();
+	{
+		ri_PreparedPlanExecutionAborted(qplan);
+		PG_RE_THROW();
+	}
+	PG_END_TRY();
+	ri_PreparedPlanExecutionFinished(qplan);
 
 	/* Restore UID and security context */
 	SetUserIdAndSecContext(save_userid, save_sec_context);
@@ -2693,6 +2744,13 @@ ri_InitHashTables(void)
 	ri_compare_cache = hash_create("RI compare cache",
 								   RI_INIT_QUERYHASHSIZE,
 								   &ctl, HASH_ELEM | HASH_BLOBS);
+
+	ctl.keysize = sizeof(SPIPlanPtr);
+	ctl.entrysize = sizeof(RI_QueryPlanCacheExecutingRefCountEntry);
+	ri_query_plan_cache_executing_refcount =
+		hash_create("RI plan execution refcount",
+					RI_INIT_PLANEXECHASHSIZE,
+					&ctl, HASH_ELEM | HASH_BLOBS);
 }
 
 
@@ -2739,11 +2797,13 @@ ri_FetchPreparedPlan(RI_QueryKey *key)
 
 	/*
 	 * Otherwise we might as well flush the cached plan now, to free a little
-	 * memory space before we make a new one.
+	 * memory space before we make a new one.  An RI check further up the
+	 * stack may still be executing it, though, in which case it's freed only
+	 * once that execution finishes.
 	 */
 	entry->plan = NULL;
 	if (plan)
-		SPI_freeplan(plan);
+		ri_PreparedPlanFreeOrDefer(plan);
 
 	return NULL;
 }
@@ -2778,6 +2838,167 @@ ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr plan)
 }
 
 
+/*
+ * ri_PreparedPlanExecutionStarted -
+ *
+ * Record that ri_PerformCheck() is about to execute a saved plan, so that it
+ * is not freed under us.  If the execution is aborted by an error,
+ * ri_PreparedPlanExecutionAborted() undoes this instead.
+ *
+ * This is also where plans left over by aborted executions are freed, if any.
+ * The plan about to be executed cannot be one of them: those are no longer in
+ * the query hashtable, and their addresses cannot have been reused yet.
+ */
+static void
+ri_PreparedPlanExecutionStarted(SPIPlanPtr plan)
+{
+	RI_QueryPlanCacheExecutingRefCountEntry *entry;
+	bool		found;
+
+	/* The plan came from ri_FetchPreparedPlan() or ri_PlanCheck() */
+	Assert(ri_query_plan_cache_executing_refcount != NULL);
+
+	ri_PreparedPlanFreeDeferred();
+
+	entry = (RI_QueryPlanCacheExecutingRefCountEntry *)
+		hash_search(ri_query_plan_cache_executing_refcount,
+					&plan, HASH_ENTER, &found);
+	if (!found)
+	{
+		entry->freeDeferred = false;
+		entry->refcount = 0;
+	}
+	entry->refcount++;
+}
+
+/*
+ * ri_PreparedPlanExecutionFinished -
+ *
+ * Record that an execution started by ri_PreparedPlanExecutionStarted() has
+ * finished normally.  If it was the last execution of a plan whose freeing
+ * was deferred in the meantime, the plan is freed.
+ */
+static void
+ri_PreparedPlanExecutionFinished(SPIPlanPtr plan)
+{
+	ri_PreparedPlanDecrementRefCount(plan, true);
+}
+
+/*
+ * ri_PreparedPlanExecutionAborted -
+ *
+ * Record that an execution started by ri_PreparedPlanExecutionStarted() is
+ * being aborted by an error.  This is called while the error propagates, so
+ * we only drop the count here: a plan whose freeing was deferred and whose
+ * last execution this was is freed later by ri_PreparedPlanFreeDeferred(),
+ * called by the next RI query execution.
+ */
+static void
+ri_PreparedPlanExecutionAborted(SPIPlanPtr plan)
+{
+	ri_PreparedPlanDecrementRefCount(plan, false);
+}
+
+/*
+ * ri_PreparedPlanDecrementRefCount -
+ *
+ * Drop one execution of a plan, removing its entry once no execution is left.
+ * If freeing the plan was deferred, it is freed at that point if canFree;
+ * otherwise its entry is left in place, with a zero count, for
+ * ri_PreparedPlanFreeDeferred() to find.
+ */
+static void
+ri_PreparedPlanDecrementRefCount(SPIPlanPtr plan, bool canFree)
+{
+	RI_QueryPlanCacheExecutingRefCountEntry *entry;
+	bool		freeDeferred;
+
+	entry = (RI_QueryPlanCacheExecutingRefCountEntry *)
+		hash_search(ri_query_plan_cache_executing_refcount,
+					&plan, HASH_FIND, NULL);
+	Assert(entry != NULL);
+	Assert(entry->refcount > 0);
+
+	entry->refcount--;
+	if (entry->refcount > 0)
+		return;
+
+	freeDeferred = entry->freeDeferred;
+	if (freeDeferred && !canFree)
+		return;
+
+	hash_search(ri_query_plan_cache_executing_refcount,
+				&plan, HASH_REMOVE, NULL);
+	if (freeDeferred)
+		SPI_freeplan(plan);
+}
+
+/*
+ * ri_PreparedPlanFreeOrDefer -
+ *
+ * Free a saved plan that has just been removed from the query hashtable, or,
+ * if it is being executed, defer freeing it until the last execution
+ * finishes.
+ */
+static void
+ri_PreparedPlanFreeOrDefer(SPIPlanPtr plan)
+{
+	RI_QueryPlanCacheExecutingRefCountEntry *entry;
+
+	/* Called from ri_FetchPreparedPlan(), so the hashtables exist */
+	Assert(ri_query_plan_cache_executing_refcount != NULL);
+
+	entry = (RI_QueryPlanCacheExecutingRefCountEntry *)
+		hash_search(ri_query_plan_cache_executing_refcount,
+					&plan, HASH_FIND, NULL);
+	if (entry == NULL)
+	{
+		SPI_freeplan(plan);
+		return;
+	}
+
+	Assert(entry->refcount > 0);
+	Assert(!entry->freeDeferred);
+	entry->freeDeferred = true;
+}
+
+/*
+ * ri_PreparedPlanFreeDeferred -
+ *
+ * Free the plans whose freeing was deferred and whose last execution was
+ * aborted by an error, which ri_PreparedPlanDecrementRefCount() left in the
+ * hash table with a zero count.
+ *
+ * This is called for every RI query execution, so it returns right away when
+ * the hash table is empty, which is the case unless an RI query is already
+ * being executed or a plan left by an aborted execution is still pending.
+ */
+static void
+ri_PreparedPlanFreeDeferred(void)
+{
+	HASH_SEQ_STATUS status;
+	RI_QueryPlanCacheExecutingRefCountEntry *entry;
+
+	if (ri_query_plan_cache_executing_refcount == NULL ||
+		hash_get_num_entries(ri_query_plan_cache_executing_refcount) == 0)
+		return;
+
+	hash_seq_init(&status, ri_query_plan_cache_executing_refcount);
+	while ((entry = hash_seq_search(&status)) != NULL)
+	{
+		SPIPlanPtr	plan = entry->plan;
+
+		if (entry->refcount > 0)
+			continue;
+		Assert(entry->freeDeferred);
+
+		if (hash_search(ri_query_plan_cache_executing_refcount,
+						&plan, HASH_REMOVE, NULL) == NULL)
+			elog(ERROR, "hash table corrupted");
+		SPI_freeplan(plan);
+	}
+}
+
 /*
  * ri_KeysEqual -
  *
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index d0f927a9376..7cfc36a9948 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2189,6 +2189,7 @@ RI_CompareKey
 RI_ConstraintInfo
 RI_QueryHashEntry
 RI_QueryKey
+RI_QueryPlanCacheExecutingRefCountEntry
 RTEKind
 RWConflict
 RWConflictPoolHeader
-- 
2.34.1

