Skip to content

Commit 1527d0a

Browse files
craig[bot]spilchen
andcommitted
Merge #147348
147348: sql/rls: prevent leak of hidden rows in RLS due to predicate reordering r=spilchen a=spilchen RLS policies are applied as filters to scan operations before any user-defined predicates. Previously, the optimizer could reorder these predicates freely, which could result in information leakage: users could infer the existence of hidden rows based on query behavior. This change wraps the RLS filter in a Barrier operator, which prevents it from being reordered across non-leak-proof expressions. This ensures that evaluation order is preserved and RLS protections remain intact. The Barrier is marked as permeable, allowing optgen rules to push the Barrier up the plan tree for expressions that are leakproof. Only optgen rules for the Select operator were added in this change. Subsequent changes will handle joins and projections. Informs #146952 Epic: CRDB-48807 Release note (bug fix): Fixed a security issue where optimizer predicate reordering could leak information about hidden rows protected by RLS policies. Co-authored-by: Matt Spilchen <[email protected]>
2 parents 0b18551 + b49eb0d commit 1527d0a

File tree

17 files changed

+785
-243
lines changed

17 files changed

+785
-243
lines changed

pkg/sql/logictest/testdata/logic_test/row_level_security

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5522,3 +5522,116 @@ statement ok
55225522
DROP ROLE can_createdb_global;
55235523

55245524
subtest end
5525+
5526+
subtest filter_pushdown_leaks
5527+
5528+
statement ok
5529+
CREATE ROLE alice;
5530+
5531+
statement ok
5532+
CREATE TABLE t (x INT PRIMARY KEY, y INT, alice_has_access BOOL);
5533+
5534+
statement ok
5535+
CREATE INDEX ON t(y);
5536+
5537+
statement ok
5538+
INSERT INTO t VALUES (1, 10, true), (2, 20, false);
5539+
5540+
statement ok
5541+
GRANT SELECT, INSERT, UPDATE, DELETE ON t TO alice;
5542+
5543+
statement ok
5544+
ALTER TABLE t ENABLE ROW LEVEL SECURITY;
5545+
5546+
statement ok
5547+
CREATE POLICY select_policy_alice
5548+
ON t
5549+
FOR SELECT
5550+
TO alice
5551+
USING (alice_has_access);
5552+
5553+
statement ok
5554+
SET ROLE alice;
5555+
5556+
# Attempt various runtime errors to test whether the system leaks the existence
5557+
# of a row with y = 20, which is hidden from the user by the RLS policy.
5558+
5559+
# Division by zero
5560+
query IIB
5561+
SELECT * FROM t WHERE y = 20 AND y/0 = 0;
5562+
----
5563+
5564+
# Out of range error
5565+
query IIB
5566+
SELECT * FROM t WHERE y = 20 AND ARRAY[1,2,3][10] = 1;
5567+
----
5568+
5569+
# Out of range error
5570+
query IIB
5571+
UPDATE t SET y = y WHERE y = 20 AND ARRAY[1,2,3][10] = 1 RETURNING *;
5572+
----
5573+
5574+
# Custom function that raise an exception.
5575+
statement ok
5576+
CREATE FUNCTION fail() RETURNS INT AS $$ BEGIN RAISE EXCEPTION 'fail'; END; $$ LANGUAGE plpgsql;
5577+
5578+
query IIB
5579+
DELETE FROM t WHERE y = 20 AND fail() = 1 RETURNING *;
5580+
----
5581+
5582+
# Custom function that is designated as leakproof
5583+
statement ok
5584+
CREATE FUNCTION divbyzero() RETURNS INT IMMUTABLE LEAKPROOF
5585+
AS $$
5586+
BEGIN
5587+
SELECT 20 / 0;
5588+
RETURN 1;
5589+
END;
5590+
$$ LANGUAGE plpgsql;
5591+
5592+
# Show that queries on rows we can see will cause the query to fail
5593+
statement error pq: division by zero
5594+
SELECT * FROM t WHERE y = 10 AND divbyzero() = 1;
5595+
5596+
# But queries on rows we cannot see won't see any error.
5597+
query IIB
5598+
SELECT * FROM t WHERE y = 20 AND divbyzero() = 1;
5599+
----
5600+
5601+
# Invalid regular expression
5602+
query IIB
5603+
SELECT * FROM t WHERE y = 20 AND 'a' ~ '(';
5604+
----
5605+
5606+
# Unicode errors
5607+
query IIB
5608+
UPDATE t SET y = y WHERE y = 20 AND power(1e400, 1) > 0 RETURNING *;
5609+
----
5610+
5611+
# CTEs
5612+
query IIB
5613+
WITH rows AS (
5614+
SELECT x FROM t WHERE y = 20 AND y/0 = 0
5615+
)
5616+
DELETE FROM t WHERE x IN (SELECT x FROM rows) RETURNING *;
5617+
----
5618+
5619+
# INSERT w/ ON CONFLICT. This leaks information because the update attempts
5620+
# to modify a row that is filtered out by RLS, and fails. Postgres behaves the
5621+
# same, so this is left here as an example of that behaviour.
5622+
statement error pq: new row violates row-level security policy for table "t"
5623+
INSERT INTO t VALUES (2, 20, false) ON CONFLICT(x) DO UPDATE SET y = 20;
5624+
5625+
statement ok
5626+
RESET ROLE;
5627+
5628+
statement ok
5629+
DROP TABLE t;
5630+
5631+
statement ok
5632+
DROP FUNCTION fail, divbyzero;
5633+
5634+
statement ok
5635+
DROP ROLE alice;
5636+
5637+
subtest end

pkg/sql/opt/exec/explain/testdata/row_level_security

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,13 @@ select t1.c1 from t1, t2 where t1.c1 = t2.c1 and t1.c1 = 1;
174174
----
175175
• cross join
176176
177-
├── • scan
178-
│ table: t1@t1_idx
179-
│ spans: 1+ spans
180-
│ policies: policy 1, p2, p3, r1, r2
177+
├── • index join
178+
│ │ table: t1@t1_pkey
179+
│ │
180+
│ └── • scan
181+
│ table: t1@t1_idx
182+
│ spans: 1+ spans
183+
│ policies: policy 1, p2, p3, r1, r2
181184
182185
└── • filter
183186
│ filter: c1 = _
@@ -303,6 +306,9 @@ exec-ddl
303306
CREATE POLICY p_update2 ON writer FOR UPDATE WITH CHECK (value IN ('updater: a', 'updater: b', 'updater: c'))
304307
----
305308

309+
# TODO(mgartner): We used to record the locking strength in this plan, but it
310+
# appears that optimization no longer applies since introducing the barrier for
311+
# the RLS expression.
306312
plan
307313
UPDATE writer SET key = key * 11;
308314
----
@@ -317,7 +323,6 @@ UPDATE writer SET key = key * 11;
317323
└── • scan
318324
table: writer@writer_pkey
319325
spans: 1+ spans
320-
locking strength: for update
321326
policies: p_select, p_update1
322327

323328
plan
@@ -353,7 +358,6 @@ UPDATE writer SET value = 'updated' WHERE key between 1 and 100;
353358
└── • scan
354359
table: writer@writer_pkey
355360
spans: 1+ spans
356-
locking strength: for update
357361
policies: p_select, p_update1
358362

359363
plan
@@ -370,7 +374,6 @@ UPDATE writer SET value = value WHERE key = 5;
370374
└── • scan
371375
table: writer@writer_pkey
372376
spans: 1+ spans
373-
locking strength: for update
374377
policies: p_select, p_update1
375378

376379
# Should not apply select policy for the new row because columns weren't
@@ -389,7 +392,6 @@ UPDATE writer SET key = 10 WHERE true;
389392
└── • scan
390393
table: writer@writer_pkey
391394
spans: 1+ spans
392-
locking strength: for update
393395
policies: p_select, p_update1
394396

395397

@@ -434,11 +436,14 @@ CREATE POLICY p_delete ON writer FOR DELETE USING (key = 1);
434436
plan
435437
DELETE FROM writer WHERE key = 1;
436438
----
437-
• delete range
438-
from: writer
439-
auto commit
440-
spans: 1+ spans
441-
policies: p_select, p_delete
439+
• delete
440+
│ from: writer
441+
│ auto commit
442+
443+
└── • scan
444+
table: writer@writer_pkey
445+
spans: 1+ spans
446+
policies: p_select, p_delete
442447

443448
plan
444449
DELETE FROM writer WHERE value = 'some-val' or value = 'other-val';

pkg/sql/opt/memo/testdata/logprops/tail-calls

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -444,14 +444,13 @@ values
444444
│ └── const: '00000'
445445
└── project
446446
├── barrier
447-
│ └── barrier
448-
│ └── values
449-
│ └── tuple
450-
│ └── udf: nested
451-
│ └── body
452-
│ └── values
453-
│ └── tuple
454-
│ └── const: 1
447+
│ └── values
448+
│ └── tuple
449+
│ └── udf: nested
450+
│ └── body
451+
│ └── values
452+
│ └── tuple
453+
│ └── const: 1
455454
└── projections
456455
└── udf: _stmt_raise_3
457456
├── tail-call

pkg/sql/opt/norm/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
33
go_library(
44
name = "norm",
55
srcs = [
6+
"barrier_funcs.go",
67
"bool_funcs.go",
78
"comp_funcs.go",
89
"decorrelate_funcs.go",

pkg/sql/opt/norm/barrier_funcs.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2025 The Cockroach Authors.
2+
//
3+
// Use of this software is governed by the CockroachDB Software License
4+
// included in the /LICENSE file.
5+
6+
package norm
7+
8+
import "github.com/cockroachdb/cockroach/pkg/sql/opt/memo"
9+
10+
// BarriersEqual returns true if the two BarrierPrivate structs are the same.
11+
func (c *CustomFuncs) BarriersEqual(left, right *memo.BarrierPrivate) bool {
12+
if left == nil || right == nil {
13+
return left == right // true if both nil, false otherwise
14+
}
15+
return *left == *right
16+
}

pkg/sql/opt/norm/general_funcs.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,3 +1560,34 @@ func (c *CustomFuncs) DuplicateJoinPrivate(jp *memo.JoinPrivate) *memo.JoinPriva
15601560
SkipReorderJoins: jp.SkipReorderJoins,
15611561
}
15621562
}
1563+
1564+
// IsBarrierLeakproofPermeable returns true if the barrier allows for leakproof
1565+
// filters to permeate through it.
1566+
func (c *CustomFuncs) IsBarrierLeakproofPermeable(priv *memo.BarrierPrivate) bool {
1567+
return priv.LeakproofPermeable
1568+
}
1569+
1570+
// SplitLeakproofFilters separates a list of filters into two groups: those that
1571+
// are leakproof and those that are not. Leakproof filters are expressions that
1572+
// do not reveal information about underlying data through their evaluation
1573+
// behavior.
1574+
//
1575+
// This function is typically used to determine which filters can be safely
1576+
// reordered or pushed past a Barrier marked as LeakproofPermeable. It returns
1577+
// the leakproof filters, the remaining filters, and a boolean indicating
1578+
// whether any leakproof filters were found.
1579+
func (c *CustomFuncs) SplitLeakproofFilters(
1580+
filters memo.FiltersExpr,
1581+
) (leakproofFilters, remainingFilters memo.FiltersExpr, hasLeakproofFilters bool) {
1582+
for i := range filters {
1583+
if filters[i].ScalarProps().VolatilitySet.IsLeakproof() {
1584+
leakproofFilters = append(leakproofFilters, filters[i])
1585+
} else {
1586+
remainingFilters = append(remainingFilters, filters[i])
1587+
}
1588+
}
1589+
if len(leakproofFilters) > 0 {
1590+
return leakproofFilters, remainingFilters, true
1591+
}
1592+
return nil, filters, false
1593+
}

pkg/sql/opt/norm/rules/barrier.opt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# =============================================================================
2+
# barrier.opt contains normalization rules for Barrier operators.
3+
# =============================================================================
4+
5+
# EliminateRedundantBarrier removes a Barrier operator when it wraps another
6+
# identical Barrier. This deduplication avoids unnecessary nesting of
7+
# equivalent Barriers. The rule applies only when both Barrier operators have
8+
# the same configuration.
9+
[EliminateRedundantBarrier, Normalize]
10+
(Barrier
11+
(Barrier $input:* $innerBarrierPrivate:*)
12+
$outerBarrierPrivate:* &
13+
(BarriersEqual $innerBarrierPrivate $outerBarrierPrivate)
14+
)
15+
=>
16+
(Barrier $input $outerBarrierPrivate)

pkg/sql/opt/norm/rules/select.opt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,32 @@ $input
448448
)
449449
(RemoveFiltersItem $filter $item)
450450
)
451+
452+
# PushLeakproofFiltersIntoPermeableBarrier splits filter expressions based on
453+
# leakproofness and pushes only the leakproof filters beneath a permeable
454+
# Barrier. The remaining filters stay above the Barrier.
455+
#
456+
# This allows safe reordering of leakproof expressions while preserving the
457+
# Barrier to block unsafe transformations involving non-leakproof filters.
458+
# The Barrier must be marked as LeakproofPermeable to allow this behavior.
459+
[PushLeakproofFiltersIntoPermeableBarrier, Normalize]
460+
(Select
461+
(Barrier
462+
$input:*
463+
$private:* & (IsBarrierLeakproofPermeable $private)
464+
)
465+
$filters:* &
466+
(Let
467+
(
468+
$leakproofFilters
469+
$remainingFilters
470+
$ok
471+
):(SplitLeakproofFilters $filters)
472+
$ok
473+
)
474+
)
475+
=>
476+
(Select
477+
(Barrier (Select $input $leakproofFilters) $private)
478+
$remainingFilters
479+
)

0 commit comments

Comments
 (0)