Skip to content

Commit 2e4b4ad

Browse files
authored
Fix segfault on DETACH DELETE under RLS with a function-based edge policy (#2475)
A non-superuser subject to row-level security crashed the backend with SIGSEGV when running DETACH DELETE on a vertex that has a connected edge, if the edge label carried an RLS policy whose USING/WITH CHECK qual invokes a function (e.g. a STABLE tenant/owner accessor). Reported in issue #2474; reproduces on 1.7.0 and master. Mechanism: AGE deletes a vertex's connected edges in check_for_connected_edges(), which is called from end_cypher_delete() -- i.e. during executor shutdown (ExecEndPlan, reached via PortalCleanup -> ExecutorEnd when the portal is dropped). By that point the portal's active snapshot has already been popped, so the active-snapshot stack is empty. RLS for Cypher DELETE is enforced at the executor level (added in #2309): check_for_connected_edges() compiles the edge label's security quals and evaluates them per candidate edge with check_security_quals() -> ExecQual(). When the qual calls a SQL-language function, ExecQual() dispatches into fmgr_sql() -> postquel_start(), which runs the function's query and reads the current snapshot with GetActiveSnapshot(). With no snapshot on the stack that is a NULL-pointer dereference -> SIGSEGV (an assert build trips the Assert(ActiveSnapshotSet()) in postquel_start first). This is why the narrowing in the report holds: the vertex path (process_delete_list) runs during normal execution while a snapshot is active; edge-only DELETE and node-only DETACH DELETE never evaluate an edge qual at teardown; a superuser bypasses RLS; and a constant-only edge policy never enters fmgr_sql, so only DETACH DELETE of an edge-connected vertex under a function-bearing edge policy trips it. Fix: Ensure an active snapshot for the duration of the connected-edge scan in check_for_connected_edges(). es_snapshot is still valid there (the EState is not torn down until this returns) and is the correct snapshot for reading the edges, so push it if none is active and pop it before return. The scans themselves already pass es_snapshot explicitly, so this only affects RLS qual evaluation; non-RLS paths are unchanged. On the error path (e.g. an RLS denial raised mid-scan) transaction abort resets the active-snapshot stack, so the unpaired push is cleaned up. Test: Adds a regression to the security suite (PART 11b): an edge label policy whose qual calls a STABLE SQL function, then DETACH DELETE of an edge-connected vertex as the RLS-bound role. It must delete the vertex and its edge (leaving the other endpoint) instead of crashing. The existing PART 11 uses a constant-only edge policy, which does not exercise the snapshot-dependent function path.
1 parent 22a1dc9 commit 2e4b4ad

3 files changed

Lines changed: 198 additions & 0 deletions

File tree

regress/expected/security.out

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,100 @@ $$) AS (a agtype);
13571357
---
13581358
(0 rows)
13591359

1360+
-- ============================================================================
1361+
-- PART 11b: DETACH DELETE with a function-based edge policy (issue #2474)
1362+
-- ============================================================================
1363+
-- Regression for a SIGSEGV: an edge-label RLS policy whose USING / WITH CHECK
1364+
-- qual invokes a SQL-language function (e.g. a tenant/owner accessor) crashed
1365+
-- the backend on DETACH DELETE of an edge-connected vertex. The connected-edge
1366+
-- RLS check runs from end_cypher_delete() during executor shutdown, after the
1367+
-- portal's active snapshot has been popped; evaluating the qual then called the
1368+
-- function via fmgr_sql()/postquel_start(), which dereferences
1369+
-- GetActiveSnapshot() unconditionally -> NULL deref. A constant-only edge
1370+
-- policy (PART 11 above) never enters that path, so it did not crash. DETACH
1371+
-- DELETE must now complete and delete the vertex and its edge under the policy.
1372+
-- A STABLE SQL-language accessor (LANGUAGE sql is what exercises the
1373+
-- snapshot-dependent postquel executor path; a C/builtin function would not).
1374+
CREATE FUNCTION rls_detach_owner() RETURNS text
1375+
LANGUAGE sql STABLE AS $$ SELECT current_user::text $$;
1376+
ALTER TABLE rls_graph."Person" ENABLE ROW LEVEL SECURITY;
1377+
CREATE POLICY detach_fn_person_all ON rls_graph."Person"
1378+
FOR ALL USING (true) WITH CHECK (true);
1379+
ALTER TABLE rls_graph."KNOWS" ENABLE ROW LEVEL SECURITY;
1380+
CREATE POLICY detach_fn_knows_owner ON rls_graph."KNOWS"
1381+
FOR ALL
1382+
USING (properties->>'"owner"' = rls_detach_owner())
1383+
WITH CHECK (properties->>'"owner"' = rls_detach_owner());
1384+
-- Seed (as superuser, bypassing the WITH CHECK) an edge owned by rls_user1.
1385+
SELECT * FROM cypher('rls_graph', $$
1386+
CREATE (:Person {name: 'DetachFn1', owner: 'rls_user1', department: 'DetachFn'})
1387+
$$) AS (a agtype);
1388+
a
1389+
---
1390+
(0 rows)
1391+
1392+
SELECT * FROM cypher('rls_graph', $$
1393+
CREATE (:Person {name: 'DetachFn2', owner: 'rls_user1', department: 'DetachFn'})
1394+
$$) AS (a agtype);
1395+
a
1396+
---
1397+
(0 rows)
1398+
1399+
SELECT * FROM cypher('rls_graph', $$
1400+
MATCH (a:Person {name: 'DetachFn1'}), (b:Person {name: 'DetachFn2'})
1401+
CREATE (a)-[:KNOWS {since: 2021, owner: 'rls_user1'}]->(b)
1402+
$$) AS (a agtype);
1403+
a
1404+
---
1405+
(0 rows)
1406+
1407+
SET ROLE rls_user1;
1408+
-- Must NOT crash: deletes DetachFn1 and its connected edge under a
1409+
-- function-based edge policy the role satisfies.
1410+
SELECT * FROM cypher('rls_graph', $$
1411+
MATCH (p:Person {name: 'DetachFn1'}) DETACH DELETE p
1412+
$$) AS (a agtype);
1413+
a
1414+
---
1415+
(0 rows)
1416+
1417+
RESET ROLE;
1418+
-- DetachFn1 is gone.
1419+
SELECT * FROM cypher('rls_graph', $$
1420+
MATCH (p:Person {name: 'DetachFn1'}) RETURN p.name
1421+
$$) AS (name agtype);
1422+
name
1423+
------
1424+
(0 rows)
1425+
1426+
-- DetachFn2 (the other endpoint) survives.
1427+
SELECT * FROM cypher('rls_graph', $$
1428+
MATCH (p:Person {name: 'DetachFn2'}) RETURN p.name
1429+
$$) AS (name agtype);
1430+
name
1431+
-------------
1432+
"DetachFn2"
1433+
(1 row)
1434+
1435+
-- The connecting edge is gone (no edge now points into DetachFn2).
1436+
SELECT * FROM cypher('rls_graph', $$
1437+
MATCH ()-[k:KNOWS]->(b:Person {name: 'DetachFn2'}) RETURN k.since
1438+
$$) AS (since agtype);
1439+
since
1440+
-------
1441+
(0 rows)
1442+
1443+
-- cleanup
1444+
DROP POLICY detach_fn_knows_owner ON rls_graph."KNOWS";
1445+
DROP POLICY detach_fn_person_all ON rls_graph."Person";
1446+
DROP FUNCTION rls_detach_owner();
1447+
SELECT * FROM cypher('rls_graph', $$
1448+
MATCH (p:Person) WHERE p.department = 'DetachFn' DETACH DELETE p
1449+
$$) AS (a agtype);
1450+
a
1451+
---
1452+
(0 rows)
1453+
13601454
-- ============================================================================
13611455
-- PART 12: Multiple Labels in Single Query
13621456
-- ============================================================================

regress/sql/security.sql

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,81 @@ SELECT * FROM cypher('rls_graph', $$
11791179
MATCH (p:Person) WHERE p.department = 'Detach' DETACH DELETE p
11801180
$$) AS (a agtype);
11811181

1182+
-- ============================================================================
1183+
-- PART 11b: DETACH DELETE with a function-based edge policy (issue #2474)
1184+
-- ============================================================================
1185+
-- Regression for a SIGSEGV: an edge-label RLS policy whose USING / WITH CHECK
1186+
-- qual invokes a SQL-language function (e.g. a tenant/owner accessor) crashed
1187+
-- the backend on DETACH DELETE of an edge-connected vertex. The connected-edge
1188+
-- RLS check runs from end_cypher_delete() during executor shutdown, after the
1189+
-- portal's active snapshot has been popped; evaluating the qual then called the
1190+
-- function via fmgr_sql()/postquel_start(), which dereferences
1191+
-- GetActiveSnapshot() unconditionally -> NULL deref. A constant-only edge
1192+
-- policy (PART 11 above) never enters that path, so it did not crash. DETACH
1193+
-- DELETE must now complete and delete the vertex and its edge under the policy.
1194+
1195+
-- A STABLE SQL-language accessor (LANGUAGE sql is what exercises the
1196+
-- snapshot-dependent postquel executor path; a C/builtin function would not).
1197+
CREATE FUNCTION rls_detach_owner() RETURNS text
1198+
LANGUAGE sql STABLE AS $$ SELECT current_user::text $$;
1199+
1200+
ALTER TABLE rls_graph."Person" ENABLE ROW LEVEL SECURITY;
1201+
CREATE POLICY detach_fn_person_all ON rls_graph."Person"
1202+
FOR ALL USING (true) WITH CHECK (true);
1203+
1204+
ALTER TABLE rls_graph."KNOWS" ENABLE ROW LEVEL SECURITY;
1205+
CREATE POLICY detach_fn_knows_owner ON rls_graph."KNOWS"
1206+
FOR ALL
1207+
USING (properties->>'"owner"' = rls_detach_owner())
1208+
WITH CHECK (properties->>'"owner"' = rls_detach_owner());
1209+
1210+
-- Seed (as superuser, bypassing the WITH CHECK) an edge owned by rls_user1.
1211+
SELECT * FROM cypher('rls_graph', $$
1212+
CREATE (:Person {name: 'DetachFn1', owner: 'rls_user1', department: 'DetachFn'})
1213+
$$) AS (a agtype);
1214+
1215+
SELECT * FROM cypher('rls_graph', $$
1216+
CREATE (:Person {name: 'DetachFn2', owner: 'rls_user1', department: 'DetachFn'})
1217+
$$) AS (a agtype);
1218+
1219+
SELECT * FROM cypher('rls_graph', $$
1220+
MATCH (a:Person {name: 'DetachFn1'}), (b:Person {name: 'DetachFn2'})
1221+
CREATE (a)-[:KNOWS {since: 2021, owner: 'rls_user1'}]->(b)
1222+
$$) AS (a agtype);
1223+
1224+
SET ROLE rls_user1;
1225+
1226+
-- Must NOT crash: deletes DetachFn1 and its connected edge under a
1227+
-- function-based edge policy the role satisfies.
1228+
SELECT * FROM cypher('rls_graph', $$
1229+
MATCH (p:Person {name: 'DetachFn1'}) DETACH DELETE p
1230+
$$) AS (a agtype);
1231+
1232+
RESET ROLE;
1233+
1234+
-- DetachFn1 is gone.
1235+
SELECT * FROM cypher('rls_graph', $$
1236+
MATCH (p:Person {name: 'DetachFn1'}) RETURN p.name
1237+
$$) AS (name agtype);
1238+
1239+
-- DetachFn2 (the other endpoint) survives.
1240+
SELECT * FROM cypher('rls_graph', $$
1241+
MATCH (p:Person {name: 'DetachFn2'}) RETURN p.name
1242+
$$) AS (name agtype);
1243+
1244+
-- The connecting edge is gone (no edge now points into DetachFn2).
1245+
SELECT * FROM cypher('rls_graph', $$
1246+
MATCH ()-[k:KNOWS]->(b:Person {name: 'DetachFn2'}) RETURN k.since
1247+
$$) AS (since agtype);
1248+
1249+
-- cleanup
1250+
DROP POLICY detach_fn_knows_owner ON rls_graph."KNOWS";
1251+
DROP POLICY detach_fn_person_all ON rls_graph."Person";
1252+
DROP FUNCTION rls_detach_owner();
1253+
SELECT * FROM cypher('rls_graph', $$
1254+
MATCH (p:Person) WHERE p.department = 'DetachFn' DETACH DELETE p
1255+
$$) AS (a agtype);
1256+
11821257
-- ============================================================================
11831258
-- PART 12: Multiple Labels in Single Query
11841259
-- ============================================================================

src/backend/executor/cypher_delete.c

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#include "miscadmin.h"
2626
#include "utils/acl.h"
2727
#include "utils/rls.h"
28+
#include "utils/snapmgr.h"
2829

2930
#include "catalog/ag_label.h"
3031
#include "executor/cypher_executor.h"
@@ -689,6 +690,29 @@ static void check_for_connected_edges(CustomScanState *node)
689690
(cypher_delete_custom_scan_state *)node;
690691
EState *estate = css->css.ss.ps.state;
691692
char *graph_name = css->delete_data->graph_name;
693+
bool pushed_snapshot = false;
694+
695+
/*
696+
* check_for_connected_edges() runs from end_cypher_delete(), i.e. during
697+
* executor shutdown (ExecEndPlan), by which point the portal's active
698+
* snapshot has already been popped. Evaluating an edge-label RLS policy
699+
* whose USING/WITH CHECK qual invokes a function (e.g. a STABLE tenant
700+
* accessor) requires an active snapshot: check_security_quals() ->
701+
* ExecQual() -> fmgr_sql() -> postquel_start() runs the function's query
702+
* with GetActiveSnapshot() and dereferences it unconditionally. With no
703+
* snapshot on the stack that is a NULL dereference -> SIGSEGV (#2474).
704+
*
705+
* Ensure a snapshot is active for the duration of the scan. es_snapshot is
706+
* still valid here (the EState is not torn down until after this returns),
707+
* and is the correct snapshot for reading connected edges. If an error is
708+
* raised mid-scan (e.g. an RLS denial), transaction abort resets the active
709+
* snapshot stack, so the unpaired push on that path is cleaned up.
710+
*/
711+
if (!ActiveSnapshotSet())
712+
{
713+
PushActiveSnapshot(estate->es_snapshot);
714+
pushed_snapshot = true;
715+
}
692716

693717
/* scans each label from css->edge_labels */
694718
foreach (lc, css->edge_labels)
@@ -834,4 +858,9 @@ static void check_for_connected_edges(CustomScanState *node)
834858

835859
destroy_entity_result_rel_info(resultRelInfo);
836860
}
861+
862+
if (pushed_snapshot)
863+
{
864+
PopActiveSnapshot();
865+
}
837866
}

0 commit comments

Comments
 (0)