Skip to content

Commit d0ddfe1

Browse files
committed
blog: rls footgun minor tweak
1 parent 3e08a84 commit d0ddfe1

File tree

1 file changed

+58
-26
lines changed

1 file changed

+58
-26
lines changed

content/blog/postgres-row-level-security-footguns.md

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ feature_image: /content/blog/postgres-row-level-security-footguns/cover.webp
66
tags: Explanation
77
description: 'An engineering perspective to evaluate Postgres row-level-security footguns'
88
---
9+
910
Postgres's Row-Level Security (RLS) is a powerful feature for implementing fine-grained access control, but it's riddled with subtle traps that can destroy performance or completely bypass security. This comprehensive guide covers all the major footguns with practical fixes and real-world examples.
1011

1112
---
@@ -19,6 +20,7 @@ Postgres's Row-Level Security (RLS) is a powerful feature for implementing fine-
1920
**Why it happens:** Postgres must apply RLS filtering first, then non-LEAKPROOF functions, preventing the query planner from using indexes.
2021

2122
**Example problem:**
23+
2224
```sql
2325
-- This will cause full table scans even with an index on title
2426
CREATE POLICY user_documents ON documents
@@ -27,12 +29,13 @@ USING (owner_id = current_user_id() AND title ILIKE '%search%');
2729
```
2830

2931
**The fix:**
32+
3033
```sql
3134
-- Use LEAKPROOF functions or move complex logic out of policies
32-
CREATE OR REPLACE FUNCTION safe_ilike(text, text)
33-
RETURNS boolean
34-
LANGUAGE sql
35-
LEAKPROOF
35+
CREATE OR REPLACE FUNCTION safe_ilike(text, text)
36+
RETURNS boolean
37+
LANGUAGE sql
38+
LEAKPROOF
3639
AS $$ SELECT $1 ILIKE $2 $$;
3740

3841
CREATE POLICY user_documents ON documents
@@ -47,19 +50,21 @@ USING (owner_id = current_user_id() AND safe_ilike(title, '%search%'));
4750
**The footgun:** Complex RLS policies with subqueries execute for every row, multiplying query cost exponentially.
4851

4952
**Bad example:**
53+
5054
```sql
5155
CREATE POLICY complex_access ON orders
5256
USING (
5357
EXISTS (
54-
SELECT 1 FROM user_permissions up
55-
JOIN departments d ON up.dept_id = d.id
56-
WHERE up.user_id = current_user_id()
58+
SELECT 1 FROM user_permissions up
59+
JOIN departments d ON up.dept_id = d.id
60+
WHERE up.user_id = current_user_id()
5761
AND d.region = orders.region
5862
)
5963
);
6064
```
6165

6266
**Better approach:**
67+
6368
```sql
6469
-- Move complexity to a LEAKPROOF function
6570
CREATE OR REPLACE FUNCTION user_has_region_access(region_name text)
@@ -69,9 +74,9 @@ LEAKPROOF
6974
STABLE
7075
AS $$
7176
SELECT EXISTS (
72-
SELECT 1 FROM user_permissions up
73-
JOIN departments d ON up.dept_id = d.id
74-
WHERE up.user_id = current_user_id()
77+
SELECT 1 FROM user_permissions up
78+
JOIN departments d ON up.dept_id = d.id
79+
WHERE up.user_id = current_user_id()
7580
AND d.region = region_name
7681
);
7782
$$;
@@ -85,6 +90,7 @@ USING (user_has_region_access(region));
8590
**The footgun:** Forgetting to index columns used in RLS policies forces sequential scans.
8691

8792
**Essential indexes:**
93+
8894
```sql
8995
-- Always index columns used in policies
9096
CREATE INDEX ON orders(tenant_id);
@@ -103,6 +109,7 @@ CREATE INDEX ON orders(tenant_id, owner_id); -- composite for AND conditions
103109
**Why it's dangerous:** Testing with superuser accounts makes RLS appear to work when it's actually being ignored.
104110

105111
**The fix:**
112+
106113
```sql
107114
-- Force RLS even for table owners
108115
ALTER TABLE sensitive_data ENABLE ROW LEVEL SECURITY;
@@ -113,6 +120,7 @@ ALTER TABLE sensitive_data FORCE ROW LEVEL SECURITY;
113120
```
114121

115122
**Testing pattern:**
123+
116124
```sql
117125
-- Create proper test user
118126
CREATE ROLE test_user;
@@ -130,16 +138,18 @@ RESET ROLE;
130138
**The footgun:** Views are SECURITY DEFINER by default, running with creator's privileges and bypassing RLS.
131139

132140
**Dangerous example:**
141+
133142
```sql
134143
-- Created by superuser - bypasses ALL RLS policies!
135-
CREATE VIEW all_patient_data AS
144+
CREATE VIEW all_patient_data AS
136145
SELECT * FROM patients;
137146
```
138147

139148
**Secure approaches:**
149+
140150
```sql
141151
-- Postgres 15+: Use SECURITY INVOKER
142-
CREATE VIEW patient_data
152+
CREATE VIEW patient_data
143153
WITH (security_invoker = true)
144154
AS SELECT * FROM patients;
145155

@@ -154,7 +164,7 @@ BEGIN
154164
IF NOT row_security_active('patients') THEN
155165
RAISE EXCEPTION 'Row security must be active';
156166
END IF;
157-
167+
158168
RETURN QUERY SELECT p.id, p.name, p.doctor_id FROM patients p;
159169
END;
160170
$$ LANGUAGE plpgsql;
@@ -167,19 +177,22 @@ $$ LANGUAGE plpgsql;
167177
**Attack scenario:** In a multi-tenant medical database, an attacker measures query times to determine if patients with specific conditions exist in other tenants' data.
168178

169179
**Technical details:**
180+
170181
- RLS policy enforcement creates measurable timing differences
171182
- Attackers can infer secret cardinality information
172183
- Works even across network latency in cloud environments
173184

174185
**Example vulnerable query:**
186+
175187
```sql
176188
-- Timing reveals if forbidden patients exist
177-
SELECT COUNT(*) FROM patients
178-
WHERE condition = 'rare_disease'
189+
SELECT COUNT(*) FROM patients
190+
WHERE condition = 'rare_disease'
179191
AND tenant_id = current_setting('app.tenant_id')::uuid;
180192
```
181193

182194
**Mitigation strategies:**
195+
183196
1. Use data-oblivious query patterns (performance cost)
184197
1. Add artificial delays to normalize timing
185198
1. Limit query complexity for untrusted users
@@ -189,9 +202,10 @@ AND tenant_id = current_setting('app.tenant_id')::uuid;
189202

190203
### 7. CVE-2019-10130: Statistics Leakage
191204

192-
**The footgun:** Postgres's query planner statistics could leak sampled data from RLS-protected rows.
205+
**The footgun:** [CVE-2019-10130](https://www.postgresql.org/support/security/CVE-2019-10130/). Postgres's query planner statistics could leak sampled data from RLS-protected rows.
193206

194207
**Technical details:**
208+
195209
- Query planner collects statistics by sampling column data
196210
- Users could craft operators to read statistics containing forbidden data
197211
- Affected Postgres 9.5-11 before May 2019 patches
@@ -209,12 +223,14 @@ AND tenant_id = current_setting('app.tenant_id')::uuid;
209223
**The footgun:** Enabling RLS without FORCE allows table owners to bypass policies.
210224

211225
**Problem:**
226+
212227
```sql
213228
-- Table owner still sees everything!
214229
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
215230
```
216231

217232
**Solution:**
233+
218234
```sql
219235
-- Force RLS for everyone, including owners
220236
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
@@ -226,6 +242,7 @@ ALTER TABLE orders FORCE ROW LEVEL SECURITY;
226242
**The footgun:** USING filters existing rows for SELECT/UPDATE/DELETE, but WITH CHECK validates new/modified rows for INSERT/UPDATE.
227243

228244
**Dangerous example:**
245+
229246
```sql
230247
-- Users can INSERT data they can't see!
231248
CREATE POLICY tenant_data ON orders
@@ -234,6 +251,7 @@ USING (tenant_id = current_setting('app.tenant_id')::uuid);
234251
```
235252

236253
**Correct approach:**
254+
237255
```sql
238256
CREATE POLICY tenant_isolation ON orders
239257
FOR ALL
@@ -246,13 +264,15 @@ WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
246264
**The footgun:** With connection pooling, `current_user` is a shared database role, useless for tenant isolation.
247265

248266
**Problem:**
267+
249268
```sql
250269
-- Useless with PgBouncer - all connections share same user
251270
CREATE POLICY user_data ON orders
252271
USING (owner_id = current_user);
253272
```
254273

255274
**Solution:**
275+
256276
```sql
257277
-- Use application-controlled session variables
258278
-- App sets per transaction:
@@ -267,6 +287,7 @@ WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
267287
```
268288

269289
**Security hardening:**
290+
270291
```sql
271292
-- Prevent clients from setting app.* directly
272293
REVOKE ALL ON SCHEMA pg_catalog FROM app_user;
@@ -278,14 +299,16 @@ REVOKE ALL ON SCHEMA pg_catalog FROM app_user;
278299
**The footgun:** INSERT into child tables fails FK checks because RLS blocks SELECT on parent rows.
279300

280301
**Example failure:**
302+
281303
```sql
282304
-- This INSERT fails even if customer exists
283-
INSERT INTO orders (customer_id, tenant_id)
305+
INSERT INTO orders (customer_id, tenant_id)
284306
VALUES ('existing-customer-id', 'my-tenant');
285307
-- ERROR: insert or update on table "orders" violates foreign key constraint
286308
```
287309

288310
**Solution:**
311+
289312
```sql
290313
-- Parent table needs SELECT policy for FK checks
291314
CREATE POLICY customer_fk_visibility ON customers
@@ -298,16 +321,18 @@ USING (tenant_id = current_setting('app.tenant_id')::uuid);
298321
**The footgun:** Global unique constraints reveal data existence across tenants.
299322

300323
**Problem:**
324+
301325
```sql
302326
-- This reveals that email exists in ANY tenant
303327
CREATE UNIQUE INDEX users_email_unique ON users(email);
304328
-- INSERT fails with "duplicate key" even for other tenants
305329
```
306330

307331
**Solution:**
332+
308333
```sql
309334
-- Scope uniqueness to tenant
310-
CREATE UNIQUE INDEX users_email_per_tenant
335+
CREATE UNIQUE INDEX users_email_per_tenant
311336
ON users(tenant_id, lower(email));
312337
```
313338

@@ -318,6 +343,7 @@ ON users(tenant_id, lower(email));
318343
**Example:** An UPDATE that should modify 100 rows silently affects 0 rows due to RLS policy.
319344

320345
**Debugging approach:**
346+
321347
```sql
322348
-- Temporarily disable RLS to test
323349
SET row_security = off;
@@ -333,22 +359,24 @@ SELECT row_security_active('table_name');
333359
**The footgun:** RLS filters rows, not columns. Sensitive columns remain visible in allowed rows.
334360

335361
**Problem:**
362+
336363
```sql
337364
-- Users can see SSN in their own records
338365
SELECT * FROM users WHERE tenant_id = current_setting('app.tenant_id')::uuid;
339366
```
340367

341368
**Solutions:**
369+
342370
```sql
343371
-- Option 1: Column privileges
344372
REVOKE SELECT (ssn, salary) ON users FROM app_user;
345373

346374
-- Option 2: Secure views
347375
CREATE VIEW users_safe AS
348-
SELECT id, name, email,
349-
CASE WHEN has_role('hr_role')
350-
THEN ssn
351-
ELSE 'XXX-XX-' || right(ssn, 4)
376+
SELECT id, name, email,
377+
CASE WHEN has_role('hr_role')
378+
THEN ssn
379+
ELSE 'XXX-XX-' || right(ssn, 4)
352380
END as ssn_masked
353381
FROM users;
354382
```
@@ -358,6 +386,7 @@ FROM users;
358386
**The footgun:** Data copied to materialized views or exported by jobs isn't automatically protected by source table policies.
359387

360388
**Problems:**
389+
361390
```sql
362391
-- Materialized view bypasses RLS
363392
CREATE MATERIALIZED VIEW order_summary AS
@@ -368,16 +397,17 @@ COPY (SELECT * FROM orders) TO '/tmp/backup.csv';
368397
```
369398

370399
**Solutions:**
400+
371401
```sql
372402
-- Apply filtering in materialized views
373403
CREATE MATERIALIZED VIEW tenant_order_summary AS
374404
SELECT tenant_id, COUNT(*), SUM(amount)
375-
FROM orders
405+
FROM orders
376406
GROUP BY tenant_id;
377407

378408
-- Use RLS-aware exports
379409
COPY (
380-
SELECT * FROM orders
410+
SELECT * FROM orders
381411
WHERE tenant_id = 'specific-tenant'
382412
) TO '/tmp/tenant_backup.csv';
383413
```
@@ -387,16 +417,18 @@ COPY (
387417
**The footgun:** Multiple permissive policies are OR-ed together; one broad policy can override stricter ones.
388418

389419
**Problem:**
420+
390421
```sql
391422
-- These policies are OR-ed - users get access if EITHER is true
392423
CREATE POLICY user_own_data ON orders
393424
USING (owner_id = current_user_id());
394425

395-
CREATE POLICY admin_all_data ON orders
426+
CREATE POLICY admin_all_data ON orders
396427
USING (has_role('admin')); -- Oops, too broad!
397428
```
398429

399430
**Solutions:**
431+
400432
```sql
401433
-- Option 1: Use restrictive policies (AND-ed)
402434
CREATE POLICY tenant_restriction ON orders
@@ -408,7 +440,7 @@ CREATE POLICY combined_access ON orders
408440
USING (
409441
tenant_id = current_setting('app.tenant_id')::uuid
410442
AND (
411-
owner_id = current_user_id()
443+
owner_id = current_user_id()
412444
OR has_role('tenant_admin')
413445
)
414446
);

0 commit comments

Comments
 (0)