@rebasepro/rls-check 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/checks/anonymous-write-allowed.d.ts +3 -2
- package/dist/checks/policy-anonymous-tautology.d.ts +29 -1
- package/dist/checks/sql.d.ts +2 -1
- package/dist/checks/unqualified-column-in-subquery.d.ts +15 -5
- package/dist/checks/util.d.ts +42 -2
- package/dist/index.es.js +420 -78
- package/dist/index.es.js.map +1 -1
- package/dist/introspect.d.ts +19 -0
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
package/dist/index.es.js
CHANGED
|
@@ -398,6 +398,42 @@ var ANONYMOUS_ROLES = [
|
|
|
398
398
|
];
|
|
399
399
|
var isPublicRole = (role) => role.toLowerCase() === "public";
|
|
400
400
|
var sameRole = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
401
|
+
function grouped(cache, items, key) {
|
|
402
|
+
const hit = cache.get(items);
|
|
403
|
+
if (hit && hit.length === items.length) return hit.groups;
|
|
404
|
+
const groups = /* @__PURE__ */ new Map();
|
|
405
|
+
for (const item of items) {
|
|
406
|
+
const k = key(item);
|
|
407
|
+
const list = groups.get(k);
|
|
408
|
+
if (list) list.push(item);
|
|
409
|
+
else groups.set(k, [item]);
|
|
410
|
+
}
|
|
411
|
+
cache.set(items, {
|
|
412
|
+
length: items.length,
|
|
413
|
+
groups
|
|
414
|
+
});
|
|
415
|
+
return groups;
|
|
416
|
+
}
|
|
417
|
+
/** A NUL cannot appear in a Postgres identifier, so it cannot make two pairs collide. */
|
|
418
|
+
var pairKey = (schema, name) => `${schema}\u0000${name}`;
|
|
419
|
+
var RELATIONS = /* @__PURE__ */ new WeakMap();
|
|
420
|
+
var GRANTS = /* @__PURE__ */ new WeakMap();
|
|
421
|
+
var POLICIES = /* @__PURE__ */ new WeakMap();
|
|
422
|
+
var FOREIGN_KEYS = /* @__PURE__ */ new WeakMap();
|
|
423
|
+
var ROLES = /* @__PURE__ */ new WeakMap();
|
|
424
|
+
var SCHEMA_USAGE = /* @__PURE__ */ new WeakMap();
|
|
425
|
+
/** Every grant on one relation, in snapshot order. */
|
|
426
|
+
function grantsOn(snapshot, schema, table) {
|
|
427
|
+
return grouped(GRANTS, snapshot.grants, (g) => pairKey(g.schema, g.table)).get(pairKey(schema, table)) ?? [];
|
|
428
|
+
}
|
|
429
|
+
/** The foreign keys declared on one table, in snapshot order. */
|
|
430
|
+
function foreignKeysOf(snapshot, schema, table) {
|
|
431
|
+
return grouped(FOREIGN_KEYS, snapshot.foreignKeys, (fk) => pairKey(fk.schema, fk.table)).get(pairKey(schema, table)) ?? [];
|
|
432
|
+
}
|
|
433
|
+
/** A role by name, compared the way {@link sameRole} compares. */
|
|
434
|
+
function roleNamed(snapshot, name) {
|
|
435
|
+
return grouped(ROLES, snapshot.roles, (r) => r.name.toLowerCase()).get(name.toLowerCase())?.[0];
|
|
436
|
+
}
|
|
401
437
|
/**
|
|
402
438
|
* Every role whose grants `role` actually receives: itself, PUBLIC, and the
|
|
403
439
|
* transitive closure of its memberships.
|
|
@@ -409,16 +445,34 @@ var sameRole = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
|
409
445
|
*/
|
|
410
446
|
function rolesUsableBy(snapshot, role) {
|
|
411
447
|
const out = /* @__PURE__ */ new Set(["public", role.toLowerCase()]);
|
|
412
|
-
const def = snapshot
|
|
448
|
+
const def = roleNamed(snapshot, role);
|
|
413
449
|
for (const m of def?.memberOf ?? []) out.add(m.toLowerCase());
|
|
414
450
|
return out;
|
|
415
451
|
}
|
|
416
|
-
/**
|
|
417
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Can `role` name objects in `schema` at all?
|
|
454
|
+
*
|
|
455
|
+
* Without USAGE on the schema a role gets "permission denied for schema" before
|
|
456
|
+
* any table privilege is looked at, so a grant on a table there reaches
|
|
457
|
+
* nothing. Unknown — the read failed, or the schema has no record — counts as
|
|
458
|
+
* yes: a scanner that cannot tell must report, not stay quiet.
|
|
459
|
+
*/
|
|
460
|
+
function hasSchemaUsage(snapshot, schema, role) {
|
|
461
|
+
if (!snapshot.schemaUsage) return true;
|
|
462
|
+
const holders = grouped(SCHEMA_USAGE, snapshot.schemaUsage, (u) => u.schema).get(schema);
|
|
463
|
+
if (!holders) return true;
|
|
418
464
|
const via = rolesUsableBy(snapshot, role);
|
|
465
|
+
return holders.some((u) => via.has(u.grantee.toLowerCase()));
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Privileges `role` effectively holds on a relation, memberships included —
|
|
469
|
+
* and none at all when it cannot use the relation's schema.
|
|
470
|
+
*/
|
|
471
|
+
function effectivePrivileges(snapshot, schema, table, role) {
|
|
419
472
|
const out = /* @__PURE__ */ new Set();
|
|
420
|
-
|
|
421
|
-
|
|
473
|
+
if (!hasSchemaUsage(snapshot, schema, role)) return out;
|
|
474
|
+
const via = rolesUsableBy(snapshot, role);
|
|
475
|
+
for (const g of grantsOn(snapshot, schema, table)) {
|
|
422
476
|
if (!via.has(g.grantee.toLowerCase())) continue;
|
|
423
477
|
for (const p of g.privileges) out.add(p);
|
|
424
478
|
}
|
|
@@ -459,6 +513,28 @@ function policyTargetsExposedRole(snapshot, policy) {
|
|
|
459
513
|
}
|
|
460
514
|
return [...new Set(hits)];
|
|
461
515
|
}
|
|
516
|
+
/** The privileges a policy's command is exercised with. */
|
|
517
|
+
var COMMAND_PRIVILEGES = {
|
|
518
|
+
ALL: DML,
|
|
519
|
+
SELECT: ["SELECT"],
|
|
520
|
+
INSERT: ["INSERT"],
|
|
521
|
+
UPDATE: ["UPDATE"],
|
|
522
|
+
DELETE: ["DELETE"]
|
|
523
|
+
};
|
|
524
|
+
/**
|
|
525
|
+
* The exposed callers a policy applies to *and* that can reach its table for
|
|
526
|
+
* its command: {@link policyTargetsExposedRole}, kept to the roles holding a
|
|
527
|
+
* privilege the command needs (and USAGE on the schema).
|
|
528
|
+
*
|
|
529
|
+
* A policy nobody can reach the table through is a latent problem, not an
|
|
530
|
+
* exposure, and every check claims an exposure: "a caller can read every row"
|
|
531
|
+
* is false for a role Postgres answers "permission denied". `PUBLIC` stays when
|
|
532
|
+
* the policy is TO PUBLIC and any exposed role reaches the table.
|
|
533
|
+
*/
|
|
534
|
+
function policyReachedBy(snapshot, policy) {
|
|
535
|
+
const wanted = COMMAND_PRIVILEGES[policy.command] ?? DML;
|
|
536
|
+
return policyTargetsExposedRole(snapshot, policy).filter((target) => target === "PUBLIC" ? exposedGrantees(snapshot, policy.schema, policy.table, wanted).length > 0 : wanted.some((p) => effectivePrivileges(snapshot, policy.schema, policy.table, target).has(p)));
|
|
537
|
+
}
|
|
462
538
|
var TABLE_KINDS$1 = ["table", "partitioned_table"];
|
|
463
539
|
var isTable = (r) => TABLE_KINDS$1.includes(r.kind);
|
|
464
540
|
function scannedTables(snapshot) {
|
|
@@ -472,10 +548,10 @@ function scannedForeignTables(snapshot) {
|
|
|
472
548
|
return snapshot.relations.filter((r) => r.kind === "foreign_table" && snapshot.schemas.includes(r.schema));
|
|
473
549
|
}
|
|
474
550
|
function relationAt(snapshot, schema, name) {
|
|
475
|
-
return snapshot.relations
|
|
551
|
+
return grouped(RELATIONS, snapshot.relations, (r) => pairKey(r.schema, r.name)).get(pairKey(schema, name))?.[0];
|
|
476
552
|
}
|
|
477
553
|
function policiesFor(snapshot, schema, table) {
|
|
478
|
-
return snapshot.policies
|
|
554
|
+
return [...grouped(POLICIES, snapshot.policies, (p) => pairKey(p.schema, p.table)).get(pairKey(schema, table)) ?? []];
|
|
479
555
|
}
|
|
480
556
|
var hasColumn = (r, name) => r.columns.some((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
481
557
|
/** `≈ 12,000 rows` — only ever used to convey blast radius, never severity. */
|
|
@@ -510,6 +586,23 @@ function qi(ident) {
|
|
|
510
586
|
var qrel = (schema, name) => `${qi(schema)}.${qi(name)}`;
|
|
511
587
|
/** PUBLIC is a keyword, not an identifier — quoting it changes what it means. */
|
|
512
588
|
var qrole = (role) => isPublicRole(role) ? "PUBLIC" : qi(role);
|
|
589
|
+
/**
|
|
590
|
+
* The REVOKEs that take `privileges` on a relation away from the exposed roles:
|
|
591
|
+
* one per grant that reaches them, made to the role the grant names.
|
|
592
|
+
*
|
|
593
|
+
* Revoking from the exposed role by name is not the same thing. A grant to
|
|
594
|
+
* `app_reader` that `anon` inherits survives `REVOKE … FROM anon`, which is a
|
|
595
|
+
* no-op, and a relation granted to `anon` and `authenticated` keeps the second
|
|
596
|
+
* grant when the fix names only the first. Every fix that takes a grant away
|
|
597
|
+
* builds it here, so applying it and rescanning clears the finding.
|
|
598
|
+
*/
|
|
599
|
+
function revokesReaching(snapshot, schema, table, exposed, privileges) {
|
|
600
|
+
const reaching = new Set(exposed.flatMap((role) => [...rolesUsableBy(snapshot, role)]));
|
|
601
|
+
return grantsOn(snapshot, schema, table).filter((g) => reaching.has(g.grantee.toLowerCase())).map((g) => ({
|
|
602
|
+
grantee: g.grantee,
|
|
603
|
+
held: privileges.filter((p) => g.privileges.includes(p))
|
|
604
|
+
})).filter((g) => g.held.length > 0).map((g) => `REVOKE ${g.held.join(", ")} ON ${qrel(schema, table)} FROM ${qrole(g.grantee)};`);
|
|
605
|
+
}
|
|
513
606
|
function finding(f) {
|
|
514
607
|
return {
|
|
515
608
|
...f,
|
|
@@ -623,8 +716,9 @@ var COMMANDS_FOR = {
|
|
|
623
716
|
* correct one — satisfies condition 1 on almost every project out there.
|
|
624
717
|
* Flagging it would make this check fire on the whole ecosystem.
|
|
625
718
|
*
|
|
626
|
-
* So what is reported is the narrow, certain case: the
|
|
627
|
-
*
|
|
719
|
+
* So what is reported is the narrow, certain case: the clause that decides the
|
|
720
|
+
* command is constant-true, which in Postgres means "accept any row". An
|
|
721
|
+
* *absent* clause is the opposite — see {@link writesAcceptingAnyRow}. Policies
|
|
628
722
|
* whose expression is an anonymous *tautology* rather than a constant are the
|
|
629
723
|
* business of `policy-anonymous-tautology`, which can weigh the platform.
|
|
630
724
|
*/
|
|
@@ -638,11 +732,11 @@ var anonymousWriteAllowed = {
|
|
|
638
732
|
for (const policy of snapshot.policies) {
|
|
639
733
|
if (!snapshot.schemas.includes(policy.schema)) continue;
|
|
640
734
|
if (!policy.permissive) continue;
|
|
641
|
-
const
|
|
735
|
+
const open = writesAcceptingAnyRow(policy);
|
|
736
|
+
const wanted = (COMMANDS_FOR[policy.command] ?? []).filter((p) => open.includes(p));
|
|
642
737
|
if (wanted.length === 0) continue;
|
|
643
738
|
const identities = anonymousIdentities(snapshot, policy.roles);
|
|
644
739
|
if (identities.length === 0) continue;
|
|
645
|
-
if (!acceptsAnyRow(policy)) continue;
|
|
646
740
|
const granted = /* @__PURE__ */ new Set();
|
|
647
741
|
const grantedTo = [];
|
|
648
742
|
for (const role of identities) {
|
|
@@ -665,9 +759,9 @@ var anonymousWriteAllowed = {
|
|
|
665
759
|
table: policy.table,
|
|
666
760
|
policy: policy.name
|
|
667
761
|
},
|
|
668
|
-
detail: `Policy "${policy.name}" is a permissive ${policy.command} policy for ${listAnd(grantedTo)}, and
|
|
762
|
+
detail: `Policy "${policy.name}" is a permissive ${policy.command} policy for ${listAnd(grantedTo)}, and the expression that decides ${listAnd(commands)} is a constant truth, so every row satisfies it. ${listAnd(grantedTo)} also ${grantedTo.length > 1 ? "hold" : "holds"} ${listAnd(commands)} on the table, so both the privilege check and the row check pass for a request that carries no credentials.`,
|
|
669
763
|
impact: `An unauthenticated caller reaching this database over an API can ${listAnd(verbs)} rows in ${policy.schema}.${policy.table} at will — inserting records attributed to other users, or ${commands.includes("DELETE") ? "deleting the table's contents" : "modifying rows they do not own"}.`,
|
|
670
|
-
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the write rule to the caller (an `ownerField`) or restrict it to `roles` — and if anonymous writes are never intended, say so there rather than by revoking the grant, which boot re-makes") : `-- Scope the write to the caller, or take the privilege away entirely:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n WITH CHECK (user_id = ${uidCall});\n-- and if anonymous writes are never intended:\
|
|
764
|
+
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the write rule to the caller (an `ownerField`) or restrict it to `roles` — and if anonymous writes are never intended, say so there rather than by revoking the grant, which boot re-makes") : `-- Scope the write to the caller, or take the privilege away entirely:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n WITH CHECK (user_id = ${uidCall});\n-- and if anonymous writes are never intended:\n` + revokesReaching(snapshot, policy.schema, policy.table, grantedTo, commands).join("\n")
|
|
671
765
|
}));
|
|
672
766
|
}
|
|
673
767
|
return findings;
|
|
@@ -688,16 +782,30 @@ function anonymousIdentities(snapshot, policyRoles) {
|
|
|
688
782
|
return ["public", ...snapshot.roles.map((r) => r.name).filter((name) => ANONYMOUS_ROLES.includes(name.toLowerCase()) && !isPublicRole(name))];
|
|
689
783
|
}
|
|
690
784
|
/**
|
|
691
|
-
*
|
|
785
|
+
* The writes this policy admits for any row whatsoever.
|
|
786
|
+
*
|
|
787
|
+
* Postgres ORs together the expressions the permissive policies for a command
|
|
788
|
+
* *have*, and denies when there are none — so a clause a policy lacks admits
|
|
789
|
+
* nothing, it does not admit everything. Reading an absent clause as true
|
|
790
|
+
* reported `FOR INSERT TO anon` with no WITH CHECK (the insert is refused),
|
|
791
|
+
* `FOR DELETE TO anon` with no USING (no row is deleted) and `FOR UPDATE TO anon
|
|
792
|
+
* WITH CHECK (true)` with no USING (no row is reached) as open writes.
|
|
692
793
|
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
794
|
+
* - INSERT is decided by WITH CHECK (USING is not allowed on an INSERT policy,
|
|
795
|
+
* and a `FOR ALL` policy's USING stands in for a missing WITH CHECK);
|
|
796
|
+
* - UPDATE needs USING to reach the row and the check — WITH CHECK, or USING
|
|
797
|
+
* again — to accept the new one;
|
|
798
|
+
* - DELETE is decided by USING.
|
|
696
799
|
*/
|
|
697
|
-
function
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
800
|
+
function writesAcceptingAnyRow(policy) {
|
|
801
|
+
const using = policy.using != null && isUnconditionalTrue(policy.using);
|
|
802
|
+
const checkExpr = policy.withCheck ?? policy.using;
|
|
803
|
+
const check = checkExpr != null && isUnconditionalTrue(checkExpr);
|
|
804
|
+
const out = [];
|
|
805
|
+
if (check) out.push("INSERT");
|
|
806
|
+
if (using && check) out.push("UPDATE");
|
|
807
|
+
if (using) out.push("DELETE");
|
|
808
|
+
return out;
|
|
701
809
|
}
|
|
702
810
|
//#endregion
|
|
703
811
|
//#region src/checks/current-setting-throws.ts
|
|
@@ -856,13 +964,14 @@ var junctionTableUnprotected = {
|
|
|
856
964
|
const findings = [];
|
|
857
965
|
for (const rel of scannedTables(snapshot)) {
|
|
858
966
|
if (rel.rlsEnabled) continue;
|
|
859
|
-
const fks = snapshot
|
|
967
|
+
const fks = foreignKeysOf(snapshot, rel.schema, rel.name);
|
|
860
968
|
if (fks.length !== 2) continue;
|
|
861
969
|
const endpoints = fks.map((fk) => `${fk.refSchema}.${fk.refTable}`);
|
|
862
970
|
if (endpoints[0] === endpoints[1]) continue;
|
|
863
971
|
if (endpoints.some((e) => e === `${rel.schema}.${rel.name}`)) continue;
|
|
864
972
|
if (!fks.map((fk) => relationAt(snapshot, fk.refSchema, fk.refTable)).every((t) => t?.rlsEnabled)) continue;
|
|
865
973
|
if (!isMostlyKeys(rel, fks.flatMap((fk) => fk.columns))) continue;
|
|
974
|
+
if (exposedGrantees(snapshot, rel.schema, rel.name, DML).length === 0) continue;
|
|
866
975
|
findings.push(finding({
|
|
867
976
|
id: ID$11,
|
|
868
977
|
severity: "high",
|
|
@@ -942,7 +1051,7 @@ var viewBypassesRls = {
|
|
|
942
1051
|
impact: `If this view is reachable over an API as ${listAnd(roles)}, a caller reads rows from ${listAnd(bases)} that the policies on ${bases.length > 1 ? "those tables" : "that table"} were written to withhold — the view is an unfiltered path around them.`,
|
|
943
1052
|
fix: legacy ? `-- \`security_invoker\` requires PostgreSQL 15 or newer. On this server, either:
|
|
944
1053
|
-- 1. revoke access and let callers query the base table directly:
|
|
945
|
-
|
|
1054
|
+
${revokesReaching(snapshot, view.schema, view.name, roles, ["SELECT"]).join("\n")}\n-- 2. or set FORCE ROW LEVEL SECURITY on the base tables and add policies\n-- that apply to ${qi(view.owner)}, so the view's own execution is filtered.` : `ALTER VIEW ${qrel(view.schema, view.name)} SET (security_invoker = true);\n-- Callers then need their own SELECT privilege on ${listAnd(baseTables.map((r) => qrel(r.schema, r.name)))}, and the\n-- policies there apply to them.`
|
|
946
1055
|
}));
|
|
947
1056
|
}
|
|
948
1057
|
return findings;
|
|
@@ -987,7 +1096,7 @@ var matviewBypassesRls = {
|
|
|
987
1096
|
},
|
|
988
1097
|
detail: `This materialized view reads ${listAnd(bases)}, which ${bases.length > 1 ? "have" : "has"} row-level security enabled. Materialized views cannot have policies of their own, and \`security_invoker\` does not apply to them — the rows were computed once, by ${view.owner}, and are stored unfiltered. Every caller with SELECT reads the same stored rows.`,
|
|
989
1098
|
impact: `If this materialized view is reachable over an API as ${listAnd(roles)}, a caller reads a full copy of the protected data in ${listAnd(bases)} as of the last REFRESH. No policy can restrict this; only the grant can.`,
|
|
990
|
-
fix: `-- There is no RLS for materialized views. Restrict the grant:\
|
|
1099
|
+
fix: `-- There is no RLS for materialized views. Restrict the grant:\n${revokesReaching(snapshot, view.schema, view.name, roles, ["SELECT"]).join("\n")}\n-- and, if callers need this data, expose it through a view that applies the\n-- caller's own privileges:\n-- CREATE VIEW ${qrel(view.schema, `${view.name}_scoped`)} WITH (security_invoker = true)\n-- AS SELECT * FROM ${qrel(baseTables[0].schema, baseTables[0].name)} WHERE ...;`
|
|
991
1100
|
}));
|
|
992
1101
|
}
|
|
993
1102
|
return findings;
|
|
@@ -1021,7 +1130,7 @@ var policyAlwaysTrue = {
|
|
|
1021
1130
|
for (const policy of snapshot.policies) {
|
|
1022
1131
|
if (!snapshot.schemas.includes(policy.schema)) continue;
|
|
1023
1132
|
if (!policy.permissive) continue;
|
|
1024
|
-
const exposed =
|
|
1133
|
+
const exposed = policyReachedBy(snapshot, policy);
|
|
1025
1134
|
if (exposed.length === 0) continue;
|
|
1026
1135
|
const clauses = [];
|
|
1027
1136
|
if (isUnconditionalTrue(policy.using)) clauses.push("USING");
|
|
@@ -1037,7 +1146,8 @@ var policyAlwaysTrue = {
|
|
|
1037
1146
|
} : null;
|
|
1038
1147
|
const rel = relationAt(snapshot, policy.schema, policy.table);
|
|
1039
1148
|
const verb = policy.command === "SELECT" ? "read" : "act on";
|
|
1040
|
-
const
|
|
1149
|
+
const checkOnly = !clauses.includes("USING") && (policy.command === "UPDATE" || policy.command === "ALL");
|
|
1150
|
+
const severity = gate ? "medium" : checkOnly && policy.command === "UPDATE" ? "high" : "critical";
|
|
1041
1151
|
findings.push(finding({
|
|
1042
1152
|
id: ID$8,
|
|
1043
1153
|
severity,
|
|
@@ -1048,8 +1158,8 @@ var policyAlwaysTrue = {
|
|
|
1048
1158
|
table: policy.table,
|
|
1049
1159
|
policy: policy.name
|
|
1050
1160
|
},
|
|
1051
|
-
detail: `This permissive ${policy.command} policy's ${listAnd(clauses)} expression is a constant truth, so it matches every row for ${listAnd(exposed)}. Permissive policies are ORed together, so this one alone satisfies the table's row filter no matter how strict the others are.` + (gate ? ` The ${gate.name} also ${gate.verb} to this command and to every role it reaches, ANDed after it, so access may still be gated — verify that the restrictive side covers the rows you expect, because nothing else here does.` : ""),
|
|
1052
|
-
impact: gate ? `Row filtering on this table rests entirely on the ${gate.name}. If it does not cover a case, ${listAnd(exposed)} can ${verb} every row${rowsPhrase(rel)}.` : `If this table is reachable over an API as ${listAnd(exposed)}, a caller can ${verb} every row${rowsPhrase(rel)} — the policy applies no scoping whatsoever.`,
|
|
1161
|
+
detail: (checkOnly ? `This permissive ${policy.command} policy's WITH CHECK expression is a constant truth, so any new row value passes it for ${listAnd(exposed)}. ` + (policy.using == null ? "It has no USING clause, so on its own it lets no row be updated — but permissive policies' checks are ORed, so every UPDATE another policy lets through may write any values at all." : "Its USING clause still decides which rows can be touched; the check decides what they may become, and it accepts anything. Permissive policies' checks are ORed, so this one overrides any stricter check beside it.") + (policy.command === "ALL" ? " On a FOR ALL policy the same check governs INSERT, so any row at all can be inserted." : "") : `This permissive ${policy.command} policy's ${listAnd(clauses)} expression is a constant truth, so it matches every row for ${listAnd(exposed)}. Permissive policies are ORed together, so this one alone satisfies the table's row filter no matter how strict the others are.`) + (gate ? ` The ${gate.name} also ${gate.verb} to this command and to every role it reaches, ANDed after it, so access may still be gated — verify that the restrictive side covers the rows you expect, because nothing else here does.` : ""),
|
|
1162
|
+
impact: gate ? `Row filtering on this table rests entirely on the ${gate.name}. If it does not cover a case, ${listAnd(exposed)} can ${verb} every row${rowsPhrase(rel)}.` : checkOnly ? `If this table is reachable over an API as ${listAnd(exposed)}, a caller can rewrite any row it is allowed to update into anything — reassign it to another user, move it to another tenant, set any flag it carries` + (policy.command === "ALL" ? ` — and insert rows with any values, attributed to anyone.` : `. Which rows it can reach is still scoped by USING.`) : `If this table is reachable over an API as ${listAnd(exposed)}, a caller can ${verb} every row${rowsPhrase(rel)} — the policy applies no scoping whatsoever.`,
|
|
1053
1163
|
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, `replace the rule that grants unconditional ${policy.command === "SELECT" ? "reads" : "access"} with one that scopes the rows — \`ownerField\`, \`roles\`, or a \`condition\``) : `-- Replace the constant with the scoping you intended, e.g.:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clauses.includes("USING") ? `USING (user_id = ${uidCall})` : `WITH CHECK (user_id = ${uidCall})`};\n-- or, if unconditional access really is intended, drop the policy and say so\n-- with an explicit grant instead:\n-- DROP POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)};`
|
|
1054
1164
|
}));
|
|
1055
1165
|
}
|
|
@@ -1095,7 +1205,7 @@ function callersOf(snapshot, policy) {
|
|
|
1095
1205
|
* restrictive policies together have to apply to every caller it lets in.
|
|
1096
1206
|
*/
|
|
1097
1207
|
function restrictiveGate(snapshot, policy) {
|
|
1098
|
-
const gates = snapshot.
|
|
1208
|
+
const gates = policiesFor(snapshot, policy.schema, policy.table).filter((p) => !p.permissive && (p.command === "ALL" || p.command === policy.command));
|
|
1099
1209
|
const callers = callersOf(snapshot, policy);
|
|
1100
1210
|
if (callers.length === 0) return null;
|
|
1101
1211
|
const covering = gates.filter((gate) => callers.some((role) => policyAppliesTo(snapshot, gate, role)));
|
|
@@ -1136,7 +1246,10 @@ var ID$7 = "policy-anonymous-tautology";
|
|
|
1136
1246
|
* - Supabase: `auth.uid()` reads a JWT claim and returns NULL for an anonymous
|
|
1137
1247
|
* caller, so the expression is a legitimate "signed in" test. Its only real
|
|
1138
1248
|
* failing is that it does not scope rows to their owner — worth `low`, worded
|
|
1139
|
-
* as a design observation rather than a vulnerability.
|
|
1249
|
+
* as a design observation rather than a vulnerability. That is a fact about
|
|
1250
|
+
* `auth.uid()`, not about Supabase: an anonymous caller there still carries
|
|
1251
|
+
* the anon key's JWT, so `auth.role()` and `auth.jwt()` are non-null for it,
|
|
1252
|
+
* and the same shape built on them is a bypass — see {@link CallerCall}.
|
|
1140
1253
|
* - Rebase / PostgREST-style stacks that coerce a missing id to a sentinel
|
|
1141
1254
|
* (`'anonymous'`, `''`): the expression is true for signed-out callers, so it
|
|
1142
1255
|
* is a straight authentication bypass. This exact policy shipped in this
|
|
@@ -1155,18 +1268,19 @@ var policyAnonymousTautology = {
|
|
|
1155
1268
|
run(snapshot) {
|
|
1156
1269
|
const uidCall = callerIdCall(snapshot);
|
|
1157
1270
|
const findings = [];
|
|
1158
|
-
const { severity: baseSeverity, meaning, impactSuffix } = platformReading(snapshot.platform);
|
|
1159
1271
|
for (const policy of snapshot.policies) {
|
|
1160
1272
|
if (!snapshot.schemas.includes(policy.schema)) continue;
|
|
1161
1273
|
if (!policy.permissive) continue;
|
|
1162
|
-
if (
|
|
1274
|
+
if (policyReachedBy(snapshot, policy).length === 0) continue;
|
|
1163
1275
|
const clauses = [];
|
|
1164
1276
|
const usingMatch = matchTautology(policy.using);
|
|
1165
1277
|
const checkMatch = matchTautology(policy.withCheck);
|
|
1166
1278
|
if (usingMatch) clauses.push("USING");
|
|
1167
1279
|
if (checkMatch) clauses.push("WITH CHECK");
|
|
1168
1280
|
if (clauses.length === 0) continue;
|
|
1169
|
-
const
|
|
1281
|
+
const matched = worstMatch(snapshot.platform, usingMatch, checkMatch);
|
|
1282
|
+
const { severity: baseSeverity, meaning, impactSuffix } = platformReading(snapshot.platform, matched);
|
|
1283
|
+
const shape = matched.shape;
|
|
1170
1284
|
const decoys = [.../* @__PURE__ */ new Set([...usingMatch?.decoyGuards ?? [], ...checkMatch?.decoyGuards ?? []])];
|
|
1171
1285
|
const severity = forCommand(baseSeverity, policy.command);
|
|
1172
1286
|
const written = decoys.map((d) => `\`${shape} <> '${d}'\``);
|
|
@@ -1180,7 +1294,7 @@ var policyAnonymousTautology = {
|
|
|
1180
1294
|
table: policy.table,
|
|
1181
1295
|
policy: policy.name
|
|
1182
1296
|
},
|
|
1183
|
-
detail: (decoys.length > 0 ? `The ${listAnd(clauses)} expression of this ${policy.command} policy reads as "signed in": it tests that ${shape} is non-null and excludes ${listAnd(written)}. But the
|
|
1297
|
+
detail: (decoys.length > 0 ? `The ${listAnd(clauses)} expression of this ${policy.command} policy reads as "signed in": it tests that ${shape} is non-null and excludes ${listAnd(written)}. But ` + (matched.call.sentinels.length > 0 ? `the ${matched.call.noun} a signed-out caller actually arrives with is ${listAnd(matched.call.sentinels.map(describeSentinel))}, and neither is ${listAnd(decoys.map((d) => `'${d}'`))}` : `no literal stands for a signed-out caller in ${shape}`) + ` — so the guard excludes nobody and the null test stands on its own. ` : `The ${listAnd(clauses)} expression of this ${policy.command} policy tests only that ${shape} is non-null. `) + `It does not compare anything to a column, so every row of the table satisfies it equally — the policy distinguishes signed-in from signed-out callers and nothing else. ${meaning}` + (policy.command === "ALL" || policy.command === "UPDATE" || policy.command === "DELETE" ? ` This policy governs ${policy.command === "ALL" ? "every command, writes included" : policy.command}, so the same expression decides who may change rows, not only who may read them.` : ""),
|
|
1184
1298
|
impact: `Any caller for whom ${shape} is non-null can reach every row this policy covers, including rows belonging to other users or tenants. ${impactSuffix}` + (decoys.length > 0 ? " A policy in this shape reads as safe on review, which is why it survives: the guard is present, spelled plausibly, and matches nothing." : ""),
|
|
1185
1299
|
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, "scope the rule to the row's owner (an `ownerField`) rather than to the existence of an id — `access: \"authenticated\"` compiled to exactly this shape before 1.0, so a database pushed then still carries it") : `-- Scope the policy to the row's owner rather than to the existence of an id:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n USING (user_id = ${uidCall});\n-- If the intent really is "any signed-in user", exclude every id your stack has\n-- ever used for "nobody" — one literal is not enough:\n-- USING (${uidCall} IS NOT NULL AND ${uidCall} <> ALL (ARRAY['anonymous', 'anon']));`
|
|
1186
1300
|
}));
|
|
@@ -1203,26 +1317,55 @@ function forCommand(base, command) {
|
|
|
1203
1317
|
return SEVERITIES[Math.min(SEVERITIES.indexOf(base) + 1, SEVERITIES.length - 1)];
|
|
1204
1318
|
}
|
|
1205
1319
|
var describeSentinel = (s) => s === "" ? "the empty string" : `'${s}'`;
|
|
1206
|
-
|
|
1320
|
+
/** Of the USING and WITH CHECK clauses that matched, the one whose reading is worse. */
|
|
1321
|
+
function worstMatch(platform, using, check) {
|
|
1322
|
+
if (!using) return check;
|
|
1323
|
+
if (!check) return using;
|
|
1324
|
+
const rank = (m) => SEVERITIES.indexOf(platformReading(platform, m).severity);
|
|
1325
|
+
return rank(check) > rank(using) ? check : using;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* What the expression means on this platform, for the call it is built on.
|
|
1329
|
+
*
|
|
1330
|
+
* The platform alone does not decide it. On Supabase `auth.uid()` is NULL for a
|
|
1331
|
+
* signed-out request, so a null test on it is a working "signed in" check — but
|
|
1332
|
+
* a signed-out request there still carries a JWT, the project's public anon
|
|
1333
|
+
* key, so `auth.role()` is `'anon'` and `auth.jwt()` is that key's claims. A
|
|
1334
|
+
* null test on either is true for everyone. This used to be read per platform
|
|
1335
|
+
* only, which graded `USING (auth.jwt() IS NOT NULL)` low and told the reader
|
|
1336
|
+
* that anonymous callers were correctly excluded.
|
|
1337
|
+
*/
|
|
1338
|
+
function platformReading(platform, match) {
|
|
1339
|
+
const { shape, call } = match;
|
|
1207
1340
|
switch (platform) {
|
|
1208
|
-
case "supabase":
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1341
|
+
case "supabase":
|
|
1342
|
+
if (call.onSupabase === "null") return {
|
|
1343
|
+
severity: "low",
|
|
1344
|
+
meaning: `On Supabase, \`${shape}\` returns NULL for an anonymous request, so this is a working authenticated-only check rather than a bypass. It is listed because it only distinguishes signed-in from signed-out; it does not scope rows to their owner.`,
|
|
1345
|
+
impactSuffix: "Anonymous callers are correctly excluded on Supabase, so this is a data-scoping gap between signed-in users, not an anonymous-access hole."
|
|
1346
|
+
};
|
|
1347
|
+
if (call.onSupabase === "present") return {
|
|
1348
|
+
severity: "critical",
|
|
1349
|
+
meaning: `On Supabase a signed-out request still carries a JWT — the project's anon key, which ships in every client — and for it \`${shape}\` is ${call.signedOutValue}, not NULL. So this expression is true for signed-out callers too: it authorises everyone.`,
|
|
1350
|
+
impactSuffix: "That includes unauthenticated callers: the anon key is public, and a request carrying nothing else satisfies the expression."
|
|
1351
|
+
};
|
|
1352
|
+
return unknownReading(shape);
|
|
1213
1353
|
case "rebase":
|
|
1214
1354
|
case "postgrest": return {
|
|
1215
1355
|
severity: "critical",
|
|
1216
1356
|
meaning: "On this stack a request without a session is given a sentinel id (an 'anonymous' string rather than NULL), so this expression is true for signed-out callers too — it authorises everyone.",
|
|
1217
1357
|
impactSuffix: "Because signed-out requests arrive with a sentinel id rather than NULL, this includes unauthenticated callers."
|
|
1218
1358
|
};
|
|
1219
|
-
default: return
|
|
1220
|
-
severity: "medium",
|
|
1221
|
-
meaning: "Whether this excludes anonymous callers depends on the layer in front of the database: if it leaves the setting unset for signed-out requests the expression is false and this is merely loose; if it coerces them to a sentinel id (an empty string or 'anonymous') the expression is true and this authorises everyone.",
|
|
1222
|
-
impactSuffix: "Whether unauthenticated callers are included depends on whether your stack coerces a missing caller id to a sentinel value — check that before judging the severity."
|
|
1223
|
-
};
|
|
1359
|
+
default: return unknownReading(shape);
|
|
1224
1360
|
}
|
|
1225
1361
|
}
|
|
1362
|
+
function unknownReading(shape) {
|
|
1363
|
+
return {
|
|
1364
|
+
severity: "medium",
|
|
1365
|
+
meaning: `Whether this excludes anonymous callers depends on the layer in front of the database: if it leaves \`${shape}\` unset for signed-out requests the expression is false and this is merely loose; if it gives them a value — a sentinel id such as an empty string or 'anonymous', or the claims of a public anon key — the expression is true and this authorises everyone.`,
|
|
1366
|
+
impactSuffix: "Whether unauthenticated callers are included depends on whether your stack coerces a missing caller id to a sentinel value — check that before judging the severity."
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1226
1369
|
/**
|
|
1227
1370
|
* Both schema spellings, deliberately.
|
|
1228
1371
|
*
|
|
@@ -1235,23 +1378,28 @@ function platformReading(platform) {
|
|
|
1235
1378
|
var CALLER_ID_CALLS = [
|
|
1236
1379
|
{
|
|
1237
1380
|
re: /(?:rebase|auth)\.uid\s*\(\s*\)/g,
|
|
1238
|
-
label: (m) => m[0].replace(/\s+/g, "")
|
|
1381
|
+
label: (m) => m[0].replace(/\s+/g, ""),
|
|
1382
|
+
call: () => CALLER_ID
|
|
1239
1383
|
},
|
|
1240
1384
|
{
|
|
1241
1385
|
re: /auth\.role\s*\(\s*\)/g,
|
|
1242
|
-
label: () => "auth.role()"
|
|
1386
|
+
label: () => "auth.role()",
|
|
1387
|
+
call: () => CALLER_ROLE
|
|
1243
1388
|
},
|
|
1244
1389
|
{
|
|
1245
1390
|
re: /(?:rebase|auth)\.roles\s*\(\s*\)/g,
|
|
1246
|
-
label: (m) => m[0].replace(/\s+/g, "")
|
|
1391
|
+
label: (m) => m[0].replace(/\s+/g, ""),
|
|
1392
|
+
call: () => CALLER_ROLE_LIST
|
|
1247
1393
|
},
|
|
1248
1394
|
{
|
|
1249
1395
|
re: /(?:rebase|auth)\.jwt\s*\(\s*\)/g,
|
|
1250
|
-
label: (m) => m[0].replace(/\s+/g, "")
|
|
1396
|
+
label: (m) => m[0].replace(/\s+/g, ""),
|
|
1397
|
+
call: () => CALLER_CLAIMS
|
|
1251
1398
|
},
|
|
1252
1399
|
{
|
|
1253
1400
|
re: /current_setting\s*\(\s*('[^']*')\s*(?:,\s*[a-z]+\s*)?\)/g,
|
|
1254
|
-
label: (m) => `current_setting(${m[1]})
|
|
1401
|
+
label: (m) => `current_setting(${m[1]})`,
|
|
1402
|
+
call: (m) => settingCall(m[1].slice(1, -1))
|
|
1255
1403
|
}
|
|
1256
1404
|
];
|
|
1257
1405
|
/**
|
|
@@ -1266,6 +1414,73 @@ var CALLER_ID_CALLS = [
|
|
|
1266
1414
|
* `'anon'` as clearing is exactly the mistake this check now exists to catch.
|
|
1267
1415
|
*/
|
|
1268
1416
|
var CLEARING_SENTINELS = ["anonymous", ""];
|
|
1417
|
+
var CALLER_ID = {
|
|
1418
|
+
onSupabase: "null",
|
|
1419
|
+
noun: "id",
|
|
1420
|
+
sentinels: CLEARING_SENTINELS
|
|
1421
|
+
};
|
|
1422
|
+
/**
|
|
1423
|
+
* `auth.role()`, and the claim it reads. `'anon'` is a decoy for an *id* — no
|
|
1424
|
+
* signed-out caller arrives with that id — but it is precisely the *role* one
|
|
1425
|
+
* arrives as on Supabase (`web_anon` on a stock PostgREST), so excluding it is
|
|
1426
|
+
* the real guard here.
|
|
1427
|
+
*/
|
|
1428
|
+
var CALLER_ROLE = {
|
|
1429
|
+
onSupabase: "present",
|
|
1430
|
+
signedOutValue: "'anon'",
|
|
1431
|
+
noun: "role",
|
|
1432
|
+
sentinels: ["anon", "web_anon"]
|
|
1433
|
+
};
|
|
1434
|
+
/** Rebase's `roles()`, which coalesces an unset list to the empty string. */
|
|
1435
|
+
var CALLER_ROLE_LIST = {
|
|
1436
|
+
onSupabase: "present",
|
|
1437
|
+
signedOutValue: "the empty string",
|
|
1438
|
+
noun: "role list",
|
|
1439
|
+
sentinels: CLEARING_SENTINELS
|
|
1440
|
+
};
|
|
1441
|
+
var CALLER_CLAIMS = {
|
|
1442
|
+
onSupabase: "present",
|
|
1443
|
+
signedOutValue: "the anon key's claims",
|
|
1444
|
+
noun: "claim set",
|
|
1445
|
+
sentinels: []
|
|
1446
|
+
};
|
|
1447
|
+
/** A setting nothing on Supabase defines: whatever sets it decides. */
|
|
1448
|
+
var CALLER_SETTING = {
|
|
1449
|
+
onSupabase: "unknown",
|
|
1450
|
+
noun: "id",
|
|
1451
|
+
sentinels: CLEARING_SENTINELS
|
|
1452
|
+
};
|
|
1453
|
+
/** The claims Supabase's anon key carries. Every other claim is unset for it. */
|
|
1454
|
+
var ANON_KEY_CLAIMS = [
|
|
1455
|
+
"role",
|
|
1456
|
+
"iss",
|
|
1457
|
+
"ref",
|
|
1458
|
+
"iat",
|
|
1459
|
+
"exp"
|
|
1460
|
+
];
|
|
1461
|
+
/**
|
|
1462
|
+
* `current_setting('…')` by the setting it reads. PostgREST publishes the JWT as
|
|
1463
|
+
* `request.jwt.claims`, and older releases also as one `request.jwt.claim.<name>`
|
|
1464
|
+
* per claim — so the claim name decides whether the anon key fills it.
|
|
1465
|
+
*/
|
|
1466
|
+
function settingCall(name) {
|
|
1467
|
+
if (name === "request.jwt.claims") return CALLER_CLAIMS;
|
|
1468
|
+
const claim = /^request\.jwt\.claim\.(.+)$/.exec(name)?.[1];
|
|
1469
|
+
if (claim === void 0) return CALLER_SETTING;
|
|
1470
|
+
if (claim === "sub") return CALLER_ID;
|
|
1471
|
+
if (claim === "role") return CALLER_ROLE;
|
|
1472
|
+
if (ANON_KEY_CLAIMS.includes(claim)) return {
|
|
1473
|
+
onSupabase: "present",
|
|
1474
|
+
signedOutValue: `the anon key's \`${claim}\` claim`,
|
|
1475
|
+
noun: "claim",
|
|
1476
|
+
sentinels: []
|
|
1477
|
+
};
|
|
1478
|
+
return {
|
|
1479
|
+
onSupabase: "null",
|
|
1480
|
+
noun: "claim",
|
|
1481
|
+
sentinels: CLEARING_SENTINELS
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1269
1484
|
/**
|
|
1270
1485
|
* Recognise "the policy tests that a caller id exists, and nothing that narrows
|
|
1271
1486
|
* which rows" — and report which no-op guards it wears while doing so.
|
|
@@ -1293,9 +1508,10 @@ var CLEARING_SENTINELS = ["anonymous", ""];
|
|
|
1293
1508
|
function callerIdOnlyClause(clause) {
|
|
1294
1509
|
if (!clause) return null;
|
|
1295
1510
|
const flat = clause.toLowerCase().replace(/::\s*[a-z0-9_]+(?:\s*\[\s*\])*/g, "").replace(/\s+/g, " ").trim();
|
|
1296
|
-
for (const { re, label } of CALLER_ID_CALLS) {
|
|
1511
|
+
for (const { re, label, call: callFor } of CALLER_ID_CALLS) {
|
|
1297
1512
|
const first = new RegExp(re.source).exec(flat);
|
|
1298
1513
|
if (!first) continue;
|
|
1514
|
+
const call = callFor(first);
|
|
1299
1515
|
const substituted = flat.replace(new RegExp(re.source, "g"), " callerid ");
|
|
1300
1516
|
if (/\bor\b/.test(substituted)) return null;
|
|
1301
1517
|
let sawNullTest = false;
|
|
@@ -1310,19 +1526,15 @@ function callerIdOnlyClause(clause) {
|
|
|
1310
1526
|
}
|
|
1311
1527
|
const excluded = excludedLiterals(conjunct);
|
|
1312
1528
|
if (!excluded) return null;
|
|
1313
|
-
if (excluded.some((lit) =>
|
|
1529
|
+
if (excluded.some((lit) => call.sentinels.includes(lit))) cleared = true;
|
|
1314
1530
|
decoys.push(...excluded);
|
|
1315
1531
|
}
|
|
1316
1532
|
if (!sawNullTest) continue;
|
|
1317
|
-
if (cleared) return {
|
|
1318
|
-
shape: label(first),
|
|
1319
|
-
decoyGuards: [...new Set(decoys)],
|
|
1320
|
-
guardsSentinel: true
|
|
1321
|
-
};
|
|
1322
1533
|
return {
|
|
1323
1534
|
shape: label(first),
|
|
1535
|
+
call,
|
|
1324
1536
|
decoyGuards: [...new Set(decoys)],
|
|
1325
|
-
guardsSentinel:
|
|
1537
|
+
guardsSentinel: cleared
|
|
1326
1538
|
};
|
|
1327
1539
|
}
|
|
1328
1540
|
return null;
|
|
@@ -1469,7 +1681,7 @@ var policyAuthenticatedTautology = {
|
|
|
1469
1681
|
for (const policy of snapshot.policies) {
|
|
1470
1682
|
if (!snapshot.schemas.includes(policy.schema)) continue;
|
|
1471
1683
|
if (!policy.permissive) continue;
|
|
1472
|
-
if (
|
|
1684
|
+
if (policyReachedBy(snapshot, policy).length === 0) continue;
|
|
1473
1685
|
const usingMatch = callerIdOnlyClause(policy.using);
|
|
1474
1686
|
const checkMatch = callerIdOnlyClause(policy.withCheck);
|
|
1475
1687
|
const clauses = [];
|
|
@@ -1610,17 +1822,9 @@ var rlsDisabled = {
|
|
|
1610
1822
|
return findings;
|
|
1611
1823
|
}
|
|
1612
1824
|
};
|
|
1613
|
-
/**
|
|
1614
|
-
* The REVOKEs that take a foreign table away from the exposed roles: one per
|
|
1615
|
-
* grant that reaches them, made to the role the grant names, which is not
|
|
1616
|
-
* always the exposed role itself.
|
|
1617
|
-
*/
|
|
1825
|
+
/** Take a foreign table away from the exposed roles: it cannot have row-level security. */
|
|
1618
1826
|
function foreignTableFix(snapshot, schema, table, exposed) {
|
|
1619
|
-
|
|
1620
|
-
return `-- A foreign table cannot have row-level security, so take the grant away:\n${snapshot.grants.filter((g) => g.schema === schema && g.table === table && reaching.has(g.grantee.toLowerCase())).map((g) => ({
|
|
1621
|
-
grantee: g.grantee,
|
|
1622
|
-
privileges: DML.filter((p) => g.privileges.includes(p))
|
|
1623
|
-
})).filter((g) => g.privileges.length > 0).map((g) => `REVOKE ${g.privileges.join(", ")} ON ${qrel(schema, table)} FROM ${qrole(g.grantee)};`).join("\n")}\n-- and keep it in a schema your API does not expose, reading it through a\n-- function that checks the caller.`;
|
|
1827
|
+
return `-- A foreign table cannot have row-level security, so take the grant away:\n${revokesReaching(snapshot, schema, table, exposed, DML).join("\n")}\n-- and keep it in a schema your API does not expose, reading it through a\n-- function that checks the caller.`;
|
|
1624
1828
|
}
|
|
1625
1829
|
//#endregion
|
|
1626
1830
|
//#region src/checks/rls-enabled-no-policies.ts
|
|
@@ -1833,11 +2037,21 @@ var ID = "unqualified-column-in-subquery";
|
|
|
1833
2037
|
* flagging it merely because the outer table also has a `user_id` would fire
|
|
1834
2038
|
* on a large fraction of correct policies.
|
|
1835
2039
|
*
|
|
1836
|
-
*
|
|
1837
|
-
*
|
|
1838
|
-
* it
|
|
1839
|
-
*
|
|
1840
|
-
*
|
|
2040
|
+
* What a live database hands this check is not what anyone typed.
|
|
2041
|
+
* `pg_policies.qual` is Postgres's re-rendering of the parse tree, and inside a
|
|
2042
|
+
* subquery it qualifies *every* column — so the bare name is never in the text.
|
|
2043
|
+
* The bare-name scan below only ever fires on text that did not come out of the
|
|
2044
|
+
* catalog. Against a real database the mistake shows up as its effect: the bare
|
|
2045
|
+
* `organization_id` bound to the inner relation, so the stored comparison reads
|
|
2046
|
+
* `m.organization_id = m.organization_id` — a column compared with itself. That
|
|
2047
|
+
* shape is what {@link selfComparisons} looks for, and it is the half of this
|
|
2048
|
+
* check that can fire on a scan. It was the half missing until it was noticed
|
|
2049
|
+
* that the check could not fire at all; the e2e suite now holds it to that.
|
|
2050
|
+
*
|
|
2051
|
+
* Confidence is always heuristic, and an absence still proves nothing: a bare
|
|
2052
|
+
* name compared with a *different* inner column (`organization_id = id`) comes
|
|
2053
|
+
* back as `m.organization_id = m.id`, which is indistinguishable from a
|
|
2054
|
+
* comparison somebody meant.
|
|
1841
2055
|
*/
|
|
1842
2056
|
var unqualifiedColumnInSubquery = {
|
|
1843
2057
|
id: ID,
|
|
@@ -1912,6 +2126,12 @@ function scanExpression(snapshot, outer, expr) {
|
|
|
1912
2126
|
}
|
|
1913
2127
|
const from = parseFrom(tokens, own);
|
|
1914
2128
|
if (from.length === 0) continue;
|
|
2129
|
+
for (const hit of selfComparisons(snapshot, outer, tokens, own, from)) {
|
|
2130
|
+
const key = `${hit.column}|${hit.inner}`;
|
|
2131
|
+
if (seen.has(key)) continue;
|
|
2132
|
+
seen.add(key);
|
|
2133
|
+
out.push(hit);
|
|
2134
|
+
}
|
|
1915
2135
|
const relations = from.map((item) => resolveRelation(snapshot, outer.schema, item)).filter((r) => Boolean(r)).filter((r) => !(r.schema === outer.schema && r.name === outer.name));
|
|
1916
2136
|
if (relations.length === 0) continue;
|
|
1917
2137
|
const aliases = new Set(from.map((f) => f.alias).filter((a) => Boolean(a)));
|
|
@@ -1929,6 +2149,7 @@ function scanExpression(snapshot, outer, expr) {
|
|
|
1929
2149
|
if (seen.has(key)) continue;
|
|
1930
2150
|
seen.add(key);
|
|
1931
2151
|
out.push({
|
|
2152
|
+
kind: "bare",
|
|
1932
2153
|
column: token.value,
|
|
1933
2154
|
inner: `${inner.schema}.${inner.name}`,
|
|
1934
2155
|
innerRelation: {
|
|
@@ -1941,6 +2162,54 @@ function scanExpression(snapshot, outer, expr) {
|
|
|
1941
2162
|
}
|
|
1942
2163
|
return out;
|
|
1943
2164
|
}
|
|
2165
|
+
/**
|
|
2166
|
+
* `q.col <op> q.col` in this subquery's predicate, where `q` is one of the
|
|
2167
|
+
* subquery's own FROM items and the policy's table has a `col` too.
|
|
2168
|
+
*
|
|
2169
|
+
* Nobody writes a column compared with itself; Postgres does, when it stores
|
|
2170
|
+
* `WHERE m.user_id = auth.uid() AND org_id = org_id` and the second `org_id`
|
|
2171
|
+
* bound to `m`. The outer table having the column is what makes it this bug
|
|
2172
|
+
* rather than some other one: it is the column the author could have meant.
|
|
2173
|
+
*/
|
|
2174
|
+
function selfComparisons(snapshot, outer, tokens, own, from) {
|
|
2175
|
+
const inPredicate = new Set(predicateIndices(tokens, own));
|
|
2176
|
+
const out = [];
|
|
2177
|
+
const at = (k) => tokens[own[k]];
|
|
2178
|
+
const isDot = (k) => at(k)?.kind === "punct" && at(k)?.value === ".";
|
|
2179
|
+
const isIdent = (k) => at(k)?.kind === "ident";
|
|
2180
|
+
for (let k = 0; k + 6 < own.length; k++) {
|
|
2181
|
+
if (!inPredicate.has(own[k])) continue;
|
|
2182
|
+
if (isDot(k - 1)) continue;
|
|
2183
|
+
if (!isIdent(k) || !isDot(k + 1) || !isIdent(k + 2)) continue;
|
|
2184
|
+
const op = at(k + 3);
|
|
2185
|
+
if (!op || !COMPARISONS.has(op.value) || op.value === "in" || op.value === "is") continue;
|
|
2186
|
+
if (!isIdent(k + 4) || !isDot(k + 5) || !isIdent(k + 6)) continue;
|
|
2187
|
+
if (isDot(k + 7) || at(k + 7)?.kind === "punct" && at(k + 7)?.value === "(") continue;
|
|
2188
|
+
const qualifier = at(k).value;
|
|
2189
|
+
const column = at(k + 2).value;
|
|
2190
|
+
if (at(k + 4).value !== qualifier || at(k + 6).value !== column) continue;
|
|
2191
|
+
if (!hasColumn(outer, column)) continue;
|
|
2192
|
+
const item = from.find((f) => (f.alias ?? f.name) === qualifier);
|
|
2193
|
+
if (!item) continue;
|
|
2194
|
+
const resolved = resolveRelation(snapshot, outer.schema, item);
|
|
2195
|
+
const innerRelation = resolved ? {
|
|
2196
|
+
schema: resolved.schema,
|
|
2197
|
+
name: resolved.name
|
|
2198
|
+
} : {
|
|
2199
|
+
schema: item.schema ?? outer.schema,
|
|
2200
|
+
name: item.name
|
|
2201
|
+
};
|
|
2202
|
+
out.push({
|
|
2203
|
+
kind: "self",
|
|
2204
|
+
column,
|
|
2205
|
+
inner: `${innerRelation.schema}.${innerRelation.name}`,
|
|
2206
|
+
innerRelation,
|
|
2207
|
+
comparedTo: `${qualifier}.${column}`,
|
|
2208
|
+
operator: op.value
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
return out;
|
|
2212
|
+
}
|
|
1944
2213
|
/** Relation references in the subquery's FROM list, with their aliases. */
|
|
1945
2214
|
function parseFrom(tokens, own) {
|
|
1946
2215
|
const start = own.findIndex((i) => tokens[i].kind === "ident" && tokens[i].value === "from");
|
|
@@ -2055,19 +2324,28 @@ function resolveRelation(snapshot, policySchema, item) {
|
|
|
2055
2324
|
}
|
|
2056
2325
|
function buildFinding(snapshot, policy, outer, clause, hit) {
|
|
2057
2326
|
const innerSql = qrel(hit.innerRelation.schema, hit.innerRelation.name);
|
|
2327
|
+
const self = hit.kind === "self";
|
|
2328
|
+
const admits = !self || [
|
|
2329
|
+
"=",
|
|
2330
|
+
"<=",
|
|
2331
|
+
">=",
|
|
2332
|
+
"~~",
|
|
2333
|
+
"like",
|
|
2334
|
+
"ilike"
|
|
2335
|
+
].includes(hit.operator ?? "=");
|
|
2058
2336
|
return finding({
|
|
2059
2337
|
id: ID,
|
|
2060
2338
|
severity: "high",
|
|
2061
2339
|
confidence: "heuristic",
|
|
2062
|
-
title: `Policy "${policy.name}" on ${policy.schema}.${policy.table}: does \`${hit.column}\` in the subquery mean ${outer.name}.${hit.column} or ${hit.inner}.${hit.column}?`,
|
|
2340
|
+
title: self ? `Policy "${policy.name}" on ${policy.schema}.${policy.table}: the subquery compares \`${hit.comparedTo}\` with itself — was one side meant to be ${outer.name}.${hit.column}?` : `Policy "${policy.name}" on ${policy.schema}.${policy.table}: does \`${hit.column}\` in the subquery mean ${outer.name}.${hit.column} or ${hit.inner}.${hit.column}?`,
|
|
2063
2341
|
target: {
|
|
2064
2342
|
schema: policy.schema,
|
|
2065
2343
|
table: policy.table,
|
|
2066
2344
|
policy: policy.name,
|
|
2067
2345
|
column: hit.column
|
|
2068
2346
|
},
|
|
2069
|
-
detail: `In the ${clause} expression, \`${hit.column}\` is written unqualified inside a subquery over ${hit.inner}, compared against \`${hit.comparedTo}\`. Both ${hit.inner} and ${policy.schema}.${policy.table} have a column named \`${hit.column}\`, and Postgres resolves the bare name against the innermost scope that has it — so it binds to ${hit.inner}.${hit.column}, not to the outer row. If the intent was to correlate the subquery with the row being checked, that correlation is not happening.\n\nNote that \`pg_policies\`
|
|
2070
|
-
impact: "The predicate does not mean what it reads like. Depending on the data it either matches far more rows than intended — exposing other users' or tenants' rows to anyone the policy applies to — or, if the inner comparison is never satisfiable, matches none, and the table silently returns empty results.",
|
|
2347
|
+
detail: self ? `In the ${clause} expression, the subquery over ${hit.inner} compares \`${hit.comparedTo}\` with itself, so that comparison does not depend on the row being checked. It is what Postgres stores for a comparison written with a bare \`${hit.column}\` meant for the outer row: ${hit.inner} has a \`${hit.column}\` too, Postgres resolves a bare name against the innermost scope that has it, and \`pg_policies\` shows the result with both sides qualified. If the intent was to correlate the subquery with ${policy.schema}.${policy.table}.${hit.column}, that correlation is not happening.` : `In the ${clause} expression, \`${hit.column}\` is written unqualified inside a subquery over ${hit.inner}, compared against \`${hit.comparedTo}\`. Both ${hit.inner} and ${policy.schema}.${policy.table} have a column named \`${hit.column}\`, and Postgres resolves the bare name against the innermost scope that has it — so it binds to ${hit.inner}.${hit.column}, not to the outer row. If the intent was to correlate the subquery with the row being checked, that correlation is not happening.\n\nNote that \`pg_policies\` qualifies every column inside a subquery, so a bare name never appears in text read from a live database: there the same mistake reads as a column compared with itself, which this check also reports. A comparison of two different columns looks deliberate either way, so an absence of this finding is not proof.`,
|
|
2348
|
+
impact: self ? admits ? `The subquery is true for every row of ${policy.schema}.${policy.table} as soon as it is true for one: anyone it admits to one row is admitted to all of them, other users' and other tenants' rows included.` : "The comparison is never true, so the subquery matches nothing and the policy admits no row — the table silently returns empty results to everyone it covers." : "The predicate does not mean what it reads like. Depending on the data it either matches far more rows than intended — exposing other users' or tenants' rows to anyone the policy applies to — or, if the inner comparison is never satisfiable, matches none, and the table silently returns empty results.",
|
|
2071
2349
|
fix: isRebaseManagedPolicy(snapshot, policy) ? managedPolicyFix(policy, `qualify every reference in the rule's condition so the binding is explicit — \`${hit.inner}.${hit.column}\` and \`${policy.table}.${hit.column}\` are different columns, and the bare name binds to the inner one`) : `-- Qualify every reference so the binding is explicit:\nALTER POLICY ${qi(policy.name)} ON ${qrel(policy.schema, policy.table)}\n ${clause === "USING" ? "USING" : "WITH CHECK"} (EXISTS (\n SELECT 1 FROM ${innerSql}\n WHERE ${innerSql}.${qi(hit.comparedTo.split(".").pop() ?? hit.comparedTo)}\n = ${qi(policy.table)}.${qi(hit.column)}\n ));\n-- Verify the intended direction first — this rewrite assumes the outer row was meant.`
|
|
2072
2350
|
});
|
|
2073
2351
|
}
|
|
@@ -2702,6 +2980,7 @@ async function readSnapshot(client, opts, diagnostics) {
|
|
|
2702
2980
|
const relations = await readRelations(db, schemas);
|
|
2703
2981
|
const policies = await readPolicies(db, schemas);
|
|
2704
2982
|
const grants = await readGrants(db, schemas);
|
|
2983
|
+
const schemaUsage = await readSchemaUsage(db, diagnostics);
|
|
2705
2984
|
const views = await readViews(db, schemas, serverVersionNum);
|
|
2706
2985
|
const foreignKeys = await readForeignKeys(db, schemas);
|
|
2707
2986
|
const routines = await readRoutines(db, schemas);
|
|
@@ -2723,6 +3002,7 @@ async function readSnapshot(client, opts, diagnostics) {
|
|
|
2723
3002
|
policies,
|
|
2724
3003
|
roles,
|
|
2725
3004
|
grants,
|
|
3005
|
+
schemaUsage,
|
|
2726
3006
|
views,
|
|
2727
3007
|
foreignKeys,
|
|
2728
3008
|
routines
|
|
@@ -2972,6 +3252,27 @@ async function readGrants(db, schemas) {
|
|
|
2972
3252
|
}
|
|
2973
3253
|
return [...map.values()];
|
|
2974
3254
|
}
|
|
3255
|
+
/**
|
|
3256
|
+
* Who holds USAGE on each schema. Every schema, not only the scanned ones: a
|
|
3257
|
+
* view or a foreign key can point outside the scan.
|
|
3258
|
+
*
|
|
3259
|
+
* `undefined`, not `[]`, when the read failed — an empty list would say no role
|
|
3260
|
+
* can use any schema and silence every check that gates on a grant, which is
|
|
3261
|
+
* the wrong direction for a scanner to fail in.
|
|
3262
|
+
*/
|
|
3263
|
+
async function readSchemaUsage(db, diagnostics) {
|
|
3264
|
+
const what = "schema privileges";
|
|
3265
|
+
const rows = await db.query(what, `SELECT n.nspname AS schema,
|
|
3266
|
+
CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee
|
|
3267
|
+
FROM pg_namespace n
|
|
3268
|
+
CROSS JOIN LATERAL aclexplode(COALESCE(n.nspacl, acldefault('n'::"char", n.nspowner))) a
|
|
3269
|
+
WHERE a.privilege_type = 'USAGE'`);
|
|
3270
|
+
if (diagnostics.degraded.some((entry) => entry.what === what)) return void 0;
|
|
3271
|
+
return rows.map((r) => ({
|
|
3272
|
+
schema: r.schema,
|
|
3273
|
+
grantee: r.grantee
|
|
3274
|
+
}));
|
|
3275
|
+
}
|
|
2975
3276
|
async function readViews(db, schemas, serverVersionNum) {
|
|
2976
3277
|
const rows = await db.query("views", `SELECT n.nspname AS schema, c.relname AS name, pg_get_userbyid(c.relowner) AS owner,
|
|
2977
3278
|
c.relkind::text AS kind, c.reloptions
|
|
@@ -2984,11 +3285,52 @@ async function readViews(db, schemas, serverVersionNum) {
|
|
|
2984
3285
|
schema: r.schema,
|
|
2985
3286
|
name: r.name,
|
|
2986
3287
|
owner: r.owner,
|
|
2987
|
-
securityInvoker: serverVersionNum < 15e4 || r.kind === "m" ? null : (r.reloptions
|
|
3288
|
+
securityInvoker: serverVersionNum < 15e4 || r.kind === "m" ? null : reloptionIsOn(r.reloptions, "security_invoker"),
|
|
2988
3289
|
dependsOn: dependencies.get(JSON.stringify([r.schema, r.name])) ?? []
|
|
2989
3290
|
}));
|
|
2990
3291
|
}
|
|
2991
3292
|
/**
|
|
3293
|
+
* Whether a boolean reloption is set on, read the way Postgres reads it.
|
|
3294
|
+
*
|
|
3295
|
+
* Postgres stores a reloption as the text it was given, after checking it with
|
|
3296
|
+
* `parse_bool`, so `WITH (security_invoker = 1)` is stored as
|
|
3297
|
+
* `security_invoker=1` and means on. Matching only `true` and `on` reported such
|
|
3298
|
+
* a view as a critical bypass. A value `parse_bool` would refuse cannot be
|
|
3299
|
+
* stored, so anything unrecognised here is read as off — the direction that
|
|
3300
|
+
* reports rather than hides.
|
|
3301
|
+
*/
|
|
3302
|
+
function reloptionIsOn(reloptions, name) {
|
|
3303
|
+
for (const option of reloptions ?? []) {
|
|
3304
|
+
const eq = option.indexOf("=");
|
|
3305
|
+
if (eq === -1 || option.slice(0, eq).toLowerCase() !== name) continue;
|
|
3306
|
+
return parsePgBool(option.slice(eq + 1)) === true;
|
|
3307
|
+
}
|
|
3308
|
+
return false;
|
|
3309
|
+
}
|
|
3310
|
+
/**
|
|
3311
|
+
* Postgres's `parse_bool`: `true`/`false`, `yes`/`no`, `on`/`off`, `1`/`0`,
|
|
3312
|
+
* case-insensitively, and any unambiguous prefix of the words — `t`, `ye`,
|
|
3313
|
+
* `of` — but not `o`, which could be either. `null` for anything else.
|
|
3314
|
+
*/
|
|
3315
|
+
function parsePgBool(raw) {
|
|
3316
|
+
const value = raw.trim().toLowerCase();
|
|
3317
|
+
if (value.length === 0) return null;
|
|
3318
|
+
const prefixOf = (word) => word.startsWith(value);
|
|
3319
|
+
switch (value[0]) {
|
|
3320
|
+
case "t": return prefixOf("true") ? true : null;
|
|
3321
|
+
case "f": return prefixOf("false") ? false : null;
|
|
3322
|
+
case "y": return prefixOf("yes") ? true : null;
|
|
3323
|
+
case "n": return prefixOf("no") ? false : null;
|
|
3324
|
+
case "o":
|
|
3325
|
+
if (value.length < 2) return null;
|
|
3326
|
+
if (prefixOf("on")) return true;
|
|
3327
|
+
return prefixOf("off") ? false : null;
|
|
3328
|
+
case "1": return value.length === 1 ? true : null;
|
|
3329
|
+
case "0": return value.length === 1 ? false : null;
|
|
3330
|
+
default: return null;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
/**
|
|
2992
3334
|
* Base relations of each view, resolved through `pg_depend` → `pg_rewrite`.
|
|
2993
3335
|
*
|
|
2994
3336
|
* Deliberately transitive: a view over a view over a protected table is still a
|