Skip to content

Commit cd643de

Browse files
authored
Merge pull request #1622 from constructive-io/feat/safegres-with-check-option
feat(safegres): L18 — a writable view's filter is a read filter without WITH CHECK OPTION
2 parents f039aab + 11f2f90 commit cd643de

20 files changed

Lines changed: 353 additions & 17 deletions

File tree

.agents/skills/safegres/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ The score only improves by being **explicit** (declaring exposure and intent) or
8888
| L15 | info | neutral | **Unreadable body** — an untrusted role reaches through a definer view *or function* whose body could not be followed |
8989
| L16 | info | fail-open | **Sequence privilege**`nextval`/`setval`/`last_value` reach, which no policy filters |
9090
| L17 | info | fail-open | **Foreign-table grant** — a relation Postgres will not let you protect with RLS at all |
91-
| L18 | | | *(claimed by a concurrent PR: `WITH CHECK OPTION` on writable views)* |
91+
| L18 | info | fail-open | **Writable filtering view without `WITH CHECK OPTION`** — writes escape the view's own row filter |
9292
| L19 | info | fail-open | **Definer-function reach** — a SECURITY DEFINER body touches a relation as its owner, and EXECUTE is the only grant the caller needed |
9393
| L20 | info | fail-open | **`INSTEAD OF` trigger write** — the write becomes a trigger function's body, and a definer one lands it as the function owner |
9494
| W1 | medium | meta | No exposure surface configured — DB assumed reachable, score capped |
@@ -102,7 +102,7 @@ The score only improves by being **explicit** (declaring exposure and intent) or
102102

103103
Reach is modelled as cells in `checks/role-reach.ts`, each carrying the role the access **executes as** (`effectiveRole`), the **path** of edges it arrived by (`grant` / `setrole` / `view` / `matview` / `rule` / `function` / `trigger`), and a **proof** bit: `catalog` (an ACL row or `pg_auth_members`) or `ast` (read out of a SQL body). `opaque-tainted` marks a proven read whose downstream effects are unknown, and L15 has two producers: a definer view, or a definer function, whose body executes SQL the analysis cannot see through yields a cell naming the *view or function itself*, since what lies past it cannot be named.
104104

105-
**L8–L12 are the AST half: a view is not what its definition looks like.** A view without `security_invoker` runs as its owner, so a caller's SELECT on the view reads base relations under the *owner's* privileges (L8), and if the view is auto-updatable, writes land the same way (L9). Rewrite rules are worse: their actions are **not** governed by `security_invoker`, so L10 fires on invoker views where L9 does not. A materialized view stores rows computed at REFRESH time, so the bases are never consulted and their policies never run (L11). And a filtering view that is not `security_barrier` lets a leaky caller predicate be pushed below the filter (L12).
105+
**L8–L12 are the AST half: a view is not what its definition looks like.** A view without `security_invoker` runs as its owner, so a caller's SELECT on the view reads base relations under the *owner's* privileges (L8), and if the view is auto-updatable, writes land the same way (L9). Rewrite rules are worse: their actions are **not** governed by `security_invoker`, so L10 fires on invoker views where L9 does not. A materialized view stores rows computed at REFRESH time, so the bases are never consulted and their policies never run (L11). And a filtering view that is not `security_barrier` lets a leaky caller predicate be pushed below the filter (L12) — or, on the write side, lets a writer store rows outside it, because `WITH CHECK OPTION` is not the default (L18).
106106

107107
**Which columns escape is a catalog fact, not a parsing problem.** `ViewSnapshot.columnDeps` reads the `pg_depend` rows the rewriter wrote for the view's `_RETURN` rule: `SELECT *` arrives expanded, a column used only in a `WHERE` counts as read, and a nested view depends on the *inner view's* columns. L8 puts that set in the message and in `context.columns`, and suppresses itself when every escaping column is one the role already holds by column grant **and** the base has no RLS — with RLS on, the owner reads rows the caller's policies hide, so the projection is beside the point. An absent column set is unknown, never narrow.
108108

@@ -120,7 +120,7 @@ New L-rules ship `info` and **score-neutral** on purpose: the honest severity of
120120

121121
Perf-dimension rules (only collected with `--perf`, scored on their own axis; `S*` additionally need `--stats`): **X1** FK with no covering index (medium), **X2** policy filters on a column that leads no index (medium), **X3** policy casts/wraps its own column with no matching expression index (medium), **X4** policy calls a non-LEAKPROOF function (low), **X5** redundant/duplicate index (low), **X6** no primary key and no usable replica identity (low), **X7** search column with no index the search can use — `tsvector` w/o GIN/GiST, `vector` w/o HNSW/IVFFlat (medium), **X8** sort-shaped `timestamptz`/`date` column leading no index (info, heuristic), **X9** policy calls a STABLE function per row because it is not wrapped in a scalar sub-select (medium), plus P1/P1b and the runtime-statistics rules **S1**-**S4**.
122122

123-
**Direction is the key idea:** `fail-open` = real exposure (untrusted side reaches more than intended). `fail-closed` = denied at runtime (hygiene/availability, not a leak) — contributes **0** to the score by default. R1/R2 and the untrusted-role L-rules are no-ops until you configure a role list: `"L8": ["info", { "roles": ["anonymous"] }]`, or `{ "rolesFrom": "anon" }` to take them from `exposure.anonRoles`, which is what `safegres:recommended` does for L5 and L7–L17. `safegres:constructive` sets R1/R2 for `anonymous`.
123+
**Direction is the key idea:** `fail-open` = real exposure (untrusted side reaches more than intended). `fail-closed` = denied at runtime (hygiene/availability, not a leak) — contributes **0** to the score by default. R1/R2 and the untrusted-role L-rules are no-ops until you configure a role list: `"L8": ["info", { "roles": ["anonymous"] }]`, or `{ "rolesFrom": "anon" }` to take them from `exposure.anonRoles`, which is what `safegres:recommended` does for L5 and L7–L20. `safegres:constructive` sets R1/R2 for `anonymous`.
124124

125125
## Configuration (confstash)
126126

packages/safegres/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ family, **not** the dimension: `P1`/`P1b` are performance, `P5` is security.
153153
| L15 | info | neutral | **Unreadable body** — an untrusted role reaches through a definer view or function whose body the analysis could not follow † |
154154
| L16 | info | fail-open | **Sequence privilege** — an untrusted role can advance or read a sequence, which no policy filters † |
155155
| L17 | info | fail-open | **Foreign-table grant** — an untrusted role reaches a relation that cannot carry RLS at all † |
156+
| L18 | info | fail-open | **Writable filtering view without `WITH CHECK OPTION`** — an untrusted role writes rows the view's own filter excludes † |
156157
| L19 | info | fail-open | **Definer-function reach** — an untrusted role touches a relation by executing a `SECURITY DEFINER` function, which runs as its owner † |
157158
| L20 | info | fail-open | **`INSTEAD OF` trigger write** — a write against a view becomes a trigger function's body, and a definer one lands it as the function's owner † |
158159
| W1 | medium || **No exposure surface configured** — whole database assumed reachable, score capped |

packages/safegres/__tests__/definer-function.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
7272
grants: [grant('anon', 'INSERT')],
7373
definition: 'SELECT id, body FROM app.secrets',
7474
writable: ['INSERT'],
75+
checkOption: 'none',
7576
insteadOfTriggers: false,
7677
insteadOf: [],
7778
rules: [],

packages/safegres/__tests__/definer-view.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
3333
grants: [grant('anon', 'SELECT')],
3434
definition: 'SELECT id, total FROM app.orders',
3535
writable: [],
36+
checkOption: 'none',
3637
insteadOfTriggers: false,
3738
insteadOf: [],
3839
rules: [],

packages/safegres/__tests__/unaudited-reach.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
3535
grants: [{ role: 'anon', privilege: 'SELECT', grantable: false, bypassRls: false }],
3636
definition: 'SELECT rolname FROM private.secrets',
3737
writable: [],
38+
checkOption: 'none',
3839
insteadOfTriggers: false,
3940
insteadOf: [],
4041
rules: [],

packages/safegres/__tests__/unreadable-view.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
3333
grants: [{ role: 'anon', privilege: 'SELECT', grantable: false, bypassRls: false }],
3434
definition: 'SELECT id FROM app.orders',
3535
writable: [],
36+
checkOption: 'none',
3637
insteadOfTriggers: false,
3738
insteadOf: [],
3839
rules: [],

packages/safegres/__tests__/view-columns.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
3333
definition: 'SELECT id, email FROM app.people',
3434
columnDeps: [{ schema: 'app', table: 'people', columns: ['email', 'id'] }],
3535
writable: [],
36+
checkOption: 'none',
3637
insteadOfTriggers: false,
3738
insteadOf: [],
3839
rules: [],

packages/safegres/__tests__/view-exposure.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
3636
grants: [grant('anon', 'SELECT')],
3737
definition: 'SELECT id, body FROM app.secrets',
3838
writable: [],
39+
checkOption: 'none',
3940
insteadOfTriggers: false,
4041
insteadOf: [],
4142
rules: [],

packages/safegres/__tests__/view-writes.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { computeViewWriteReach } from '../src/checks/role-reach';
33
import {
44
analyzeViewWrites,
55
checkDefinerViewWrite,
6+
checkUncheckedViewWrite,
67
checkViewRuleBypass
78
} from '../src/checks/view-writes';
89
import type { RoleAttributes } from '../src/pg/acl';
@@ -37,6 +38,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
3738
grants: [grant('anon', 'INSERT')],
3839
definition: 'SELECT id, body FROM app.submissions',
3940
writable: ['INSERT', 'UPDATE', 'DELETE'],
41+
checkOption: 'none',
4042
insteadOfTriggers: false,
4143
insteadOf: [],
4244
rules: [],
@@ -303,6 +305,50 @@ describe('checkDefinerViewWrite (L9)', () => {
303305
});
304306
});
305307

308+
describe('checkUncheckedViewWrite (L18)', () => {
309+
const FILTERED = "SELECT id, body FROM app.submissions WHERE tenant_id = current_setting('app.tenant')";
310+
311+
async function check(views: ViewSnapshot[], tables: TableSnapshot[], roles: string[]) {
312+
const { unchecked } = await analyzeViewWrites(views, tables);
313+
return checkUncheckedViewWrite(unchecked, tables, GRAPH, { roles });
314+
}
315+
316+
it('flags a filtering view a role writes through without WITH CHECK OPTION', async () => {
317+
const writable = view({
318+
definition: FILTERED,
319+
grants: [grant('anon', 'INSERT'), grant('anon', 'UPDATE')]
320+
});
321+
const findings = await check([writable], [table()], ['anon']);
322+
expect(findings.map((f) => f.privilege).sort()).toEqual(['INSERT', 'UPDATE']);
323+
expect(findings[0]).toMatchObject({ code: 'L18', severity: 'info', table: 'submissions' });
324+
expect(findings[0].message).toContain('no WITH CHECK OPTION');
325+
});
326+
327+
it('stays silent once the view carries a check option', async () => {
328+
const checked = view({ definition: FILTERED, checkOption: 'cascaded' });
329+
expect(await check([checked], [table()], ['anon'])).toEqual([]);
330+
});
331+
332+
it('stays silent for a writable view with no row filter to escape', async () => {
333+
expect(await check([view()], [table()], ['anon'])).toEqual([]);
334+
});
335+
336+
it('suppresses a view whose body cannot be read rather than clearing it', async () => {
337+
const { unchecked, suppressed } = await analyzeViewWrites(
338+
[view({ definition: 'SELECT ((( FROM app.submissions' })],
339+
[table()]
340+
);
341+
expect(unchecked).toEqual([]);
342+
expect(suppressed).not.toEqual([]);
343+
});
344+
345+
it('never recommends revoking a grant', async () => {
346+
const [finding] = await check([view({ definition: FILTERED })], [table()], ['anon']);
347+
expect(finding.hint).toContain('CHECK OPTION');
348+
expect(finding.hint).toContain('Do not revoke');
349+
});
350+
});
351+
306352
describe('checkViewRuleBypass (L10)', () => {
307353
async function check(views: ViewSnapshot[], tables: TableSnapshot[], roles: string[]) {
308354
const { ruleDriven } = await analyzeViewWrites(views, tables);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"title": "A writable view's row filter is a read filter: no WITH CHECK OPTION, so writes escape it",
3+
"dimension": "security",
4+
"exposure": {
5+
"schemas": [
6+
"c_unchecked_view_write"
7+
],
8+
"roles": [
9+
"corpus_anon",
10+
"corpus_user"
11+
],
12+
"anonRoles": [
13+
"corpus_anon"
14+
]
15+
},
16+
"expect": [
17+
{
18+
"code": "L18",
19+
"relation": "c_unchecked_view_write.notes",
20+
"note": "the view filters on `tenant` and carries no WITH CHECK OPTION, so corpus_anon's INSERT/UPDATE through it stores rows for any tenant \u2014 rows the view will not then show it"
21+
},
22+
{
23+
"code": "L9",
24+
"relation": "c_unchecked_view_write.notes",
25+
"note": "the same view is auto-updatable and not security_invoker, so the write reaches `notes` as c_unchecked_write_owner at all \u2014 L9 is *whether* the write lands, L18 is whether the view's own condition constrains it"
26+
},
27+
{
28+
"code": "A3",
29+
"relation": "c_unchecked_view_write.notes",
30+
"note": "RLS is not FORCEd, which is why the owner's rewritten write is not filtered by the table's own policy either"
31+
}
32+
],
33+
"forbid": [
34+
"L8",
35+
"L10",
36+
"L11",
37+
"L12"
38+
],
39+
"worstSeverity": "low",
40+
"fix": "Recreate c_unchecked_view_write.tenant_notes WITH LOCAL CHECK OPTION so a row written through it must satisfy the condition it is served under, or put the tenant predicate in an RLS policy's WITH CHECK clause on `notes`, which applies however the row arrives. Do not revoke corpus_anon's INSERT on the view \u2014 that grant is what the API serves."
41+
}

0 commit comments

Comments
 (0)