kryptheon-night 0.1.9 → 0.1.14
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/attack.js +79 -1
- package/engine.sql +90 -2
- package/finding.js +348 -38
- package/package.json +4 -2
- package/recheck.js +38 -4
- package/scan.js +38 -2
- package/schema.js +75 -1
- package/tamper.js +20 -1
package/attack.js
CHANGED
|
@@ -31,6 +31,39 @@ const USER_D = '66666666-6666-4666-8666-666666666666';
|
|
|
31
31
|
// where the row id IS the person.
|
|
32
32
|
const OWNER_NAMES = ['user_id', 'owner_id', 'owner', 'profile_id', 'account_id', 'created_by', 'author_id'];
|
|
33
33
|
|
|
34
|
+
// A key to Supabase's auth.users as the app wrote it. In the copy it points
|
|
35
|
+
// at a stand-in named kn_ext__auth__users, which the table-of-people rule
|
|
36
|
+
// below already recognises; this one is for a table itself called users.
|
|
37
|
+
const TO_AUTH_USERS = /REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(/i;
|
|
38
|
+
// A table that looks like it holds people, keyed by the person.
|
|
39
|
+
const PERSON_TABLE = /(profile|user|account|member)/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The single-column keys from a uuid column, with what they point at. Read
|
|
43
|
+
* from the constraint wording itself, because a key to auth.users and a key
|
|
44
|
+
* to public.users are different things and the schema is part of the words.
|
|
45
|
+
*/
|
|
46
|
+
function personKeys(table, uuids) {
|
|
47
|
+
const byName = new Set(uuids.map((c) => c.name));
|
|
48
|
+
const keys = [];
|
|
49
|
+
for (const constraint of table.constraints || []) {
|
|
50
|
+
if (constraint.kind !== 'f') continue;
|
|
51
|
+
const definition = String(constraint.definition || '');
|
|
52
|
+
const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(definition);
|
|
53
|
+
if (!match) continue;
|
|
54
|
+
const columns = match[1].split(',').map((t) => t.trim().split('"').join(''));
|
|
55
|
+
const refColumns = match[3].split(',').map((t) => t.trim().split('"').join(''));
|
|
56
|
+
if (columns.length !== 1 || !byName.has(columns[0])) continue;
|
|
57
|
+
keys.push({
|
|
58
|
+
column: columns[0],
|
|
59
|
+
toAuth: TO_AUTH_USERS.test(definition),
|
|
60
|
+
refTable: match[2].split('.').pop().trim().split('"').join(''),
|
|
61
|
+
refColumn: refColumns[0],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return keys;
|
|
65
|
+
}
|
|
66
|
+
|
|
34
67
|
/** The column that says who a row belongs to, or null if nothing does. */
|
|
35
68
|
function ownerColumn(table) {
|
|
36
69
|
const uuids = table.columns.filter((c) => c.type === 'uuid');
|
|
@@ -38,6 +71,19 @@ function ownerColumn(table) {
|
|
|
38
71
|
const found = uuids.find((c) => c.name === name);
|
|
39
72
|
if (found) return found.name;
|
|
40
73
|
}
|
|
74
|
+
// Not named like an owner, but pointing at a person. Found on a blind test:
|
|
75
|
+
// appointments.provider_id -> profiles.id -> auth.users. Judged by name
|
|
76
|
+
// alone it had no owner, so "one provider reads another's patients" - a
|
|
77
|
+
// rule written USING (true) - was never tried, and the worst hole in the
|
|
78
|
+
// app went unreported. `id` first: a table keyed by the person is theirs.
|
|
79
|
+
// Two people on one row - sender_id and recipient_id - and neither is
|
|
80
|
+
// named: whose row it is cannot be told, and guessing would report one
|
|
81
|
+
// person reading a message sent to them as a break. So only one, or none.
|
|
82
|
+
const people = personKeys(table, uuids).filter((k) => k.toAuth ||
|
|
83
|
+
(k.refColumn === 'id' && k.refTable !== table.name && PERSON_TABLE.test(k.refTable)));
|
|
84
|
+
const columns = Array.from(new Set(people.map((k) => k.column)));
|
|
85
|
+
if (columns.includes('id')) return 'id';
|
|
86
|
+
if (columns.length === 1) return columns[0];
|
|
41
87
|
// A profiles table keyed by the person themselves.
|
|
42
88
|
if (/profile|user|account|member/i.test(table.name)) {
|
|
43
89
|
const id = uuids.find((c) => c.name === 'id');
|
|
@@ -456,6 +502,22 @@ function refusalMeans(message) {
|
|
|
456
502
|
return denied.test(String(message)) ? 'unreachable' : 'untested';
|
|
457
503
|
}
|
|
458
504
|
|
|
505
|
+
/**
|
|
506
|
+
* The table whose rule looks itself up, or null.
|
|
507
|
+
*
|
|
508
|
+
* A rule on organization_members that asks organization_members who is a
|
|
509
|
+
* member has to be checked to be checked, and Postgres stops the request
|
|
510
|
+
* rather than loop. Found on a blind test: every signed-in read touching that
|
|
511
|
+
* table failed, the app was broken for everyone with an account, and the
|
|
512
|
+
* report listed it only as a reason something was not tested. It is a finding
|
|
513
|
+
* in its own right - and it names the table at fault, which is often not the
|
|
514
|
+
* one that was being read.
|
|
515
|
+
*/
|
|
516
|
+
function recursionIn(message) {
|
|
517
|
+
const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
|
|
518
|
+
return found ? found[1] : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
459
521
|
/**
|
|
460
522
|
* What each table gives away, and to whom.
|
|
461
523
|
*
|
|
@@ -471,6 +533,8 @@ async function impersonate(client, schema, tables) {
|
|
|
471
533
|
const findings = [];
|
|
472
534
|
const completed = [];
|
|
473
535
|
const blocked = [];
|
|
536
|
+
// Rules that looked themselves up, and which read ran into each one.
|
|
537
|
+
const looped = [];
|
|
474
538
|
|
|
475
539
|
/** Did this read produce a verdict, and if not, why not? */
|
|
476
540
|
const settle = (key, table, answer, as) => {
|
|
@@ -493,6 +557,19 @@ async function impersonate(client, schema, tables) {
|
|
|
493
557
|
const anon = await readAs(client, schema, table.name, 'anon', null);
|
|
494
558
|
const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
|
|
495
559
|
|
|
560
|
+
// Looked for on every table, owner or not: a signed-in read of a table
|
|
561
|
+
// with nobody's name on it fails just the same, and it is the same
|
|
562
|
+
// broken page.
|
|
563
|
+
for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
|
|
564
|
+
const relation = !Array.isArray(answer) && recursionIn(answer.blocked);
|
|
565
|
+
if (relation) looped.push({ relation: relation, table: table.name, who: who });
|
|
566
|
+
}
|
|
567
|
+
// Only a read of the table itself that came back with a verdict, for both
|
|
568
|
+
// callers, shows its rules no longer loop. A read that failed for some
|
|
569
|
+
// other reason shows nothing either way.
|
|
570
|
+
const answered = (answer) => Array.isArray(answer) || refusalMeans(answer.blocked) === 'unreachable';
|
|
571
|
+
if (answered(anon) && answered(asA)) completed.push('recursive:' + table.name);
|
|
572
|
+
|
|
496
573
|
if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
|
|
497
574
|
findings.push({
|
|
498
575
|
kind: 'exposed',
|
|
@@ -521,7 +598,7 @@ async function impersonate(client, schema, tables) {
|
|
|
521
598
|
}
|
|
522
599
|
}
|
|
523
600
|
|
|
524
|
-
return { findings: findings, completed: completed, blocked: blocked };
|
|
601
|
+
return { findings: findings, completed: completed, blocked: blocked, looped: looped };
|
|
525
602
|
}
|
|
526
603
|
|
|
527
604
|
/** A stable shape for comparing one run against another. */
|
|
@@ -539,6 +616,7 @@ module.exports = {
|
|
|
539
616
|
USER_C: USER_C,
|
|
540
617
|
USER_D: USER_D,
|
|
541
618
|
refusalMeans: refusalMeans,
|
|
619
|
+
recursionIn: recursionIn,
|
|
542
620
|
ownerColumn: ownerColumn,
|
|
543
621
|
fitTo: fitTo,
|
|
544
622
|
foreignKeys: foreignKeys,
|
package/engine.sql
CHANGED
|
@@ -1006,6 +1006,7 @@ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
|
1006
1006
|
DECLARE
|
|
1007
1007
|
wanted text;
|
|
1008
1008
|
found text;
|
|
1009
|
+
people text[];
|
|
1009
1010
|
BEGIN
|
|
1010
1011
|
FOREACH wanted IN ARRAY ARRAY['user_id', 'owner_id', 'owner', 'profile_id',
|
|
1011
1012
|
'account_id', 'created_by', 'author_id'] LOOP
|
|
@@ -1016,6 +1017,28 @@ BEGIN
|
|
|
1016
1017
|
IF found IS NOT NULL THEN RETURN found; END IF;
|
|
1017
1018
|
END LOOP;
|
|
1018
1019
|
|
|
1020
|
+
-- Not named like an owner, but a single-column key from a uuid column to a
|
|
1021
|
+
-- person: auth.users (or the copy's stand-in for it), or the id of a table
|
|
1022
|
+
-- that holds people. Found on a blind test: appointments.provider_id ->
|
|
1023
|
+
-- profiles.id was never tried. `id` first; two such columns (sender_id and
|
|
1024
|
+
-- recipient_id) cannot say whose row it is, so they name no owner.
|
|
1025
|
+
SELECT array_agg(DISTINCT k.col) INTO people
|
|
1026
|
+
FROM (
|
|
1027
|
+
SELECT __KN__.unquoted(p[1]) AS col,
|
|
1028
|
+
(con->>'definition') ~* 'REFERENCES\s+"?auth"?\s*\.\s*"?users"?\s*\(' AS to_auth,
|
|
1029
|
+
__KN__.unquoted(split_part(p[2], '.', greatest(array_length(string_to_array(p[2], '.'), 1), 1))) AS ref_table,
|
|
1030
|
+
__KN__.unquoted(p[3]) AS ref_column
|
|
1031
|
+
FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) con,
|
|
1032
|
+
regexp_match(con->>'definition', 'FOREIGN KEY \(([^),]+)\) REFERENCES ([^(]+)\(([^),]+)\)', 'i') p
|
|
1033
|
+
WHERE con->>'kind' = 'f' AND p IS NOT NULL
|
|
1034
|
+
) k
|
|
1035
|
+
WHERE EXISTS (SELECT 1 FROM jsonb_array_elements(tab->'columns') c
|
|
1036
|
+
WHERE c->>'type' = 'uuid' AND c->>'name' = k.col)
|
|
1037
|
+
AND (k.to_auth OR (k.ref_column = 'id' AND k.ref_table <> tab->>'name'
|
|
1038
|
+
AND k.ref_table ~* '(profile|user|account|member)'));
|
|
1039
|
+
IF 'id' = ANY (coalesce(people, '{}')) THEN RETURN 'id'; END IF;
|
|
1040
|
+
IF array_length(people, 1) = 1 THEN RETURN people[1]; END IF;
|
|
1041
|
+
|
|
1019
1042
|
-- A profiles table keyed by the person themselves.
|
|
1020
1043
|
IF (tab->>'name') ~* '(profile|user|account|member)' THEN
|
|
1021
1044
|
SELECT c->>'name' INTO found
|
|
@@ -1590,6 +1613,17 @@ RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
|
1590
1613
|
THEN 'unreachable' ELSE 'untested' END;
|
|
1591
1614
|
$$;
|
|
1592
1615
|
|
|
1616
|
+
/*
|
|
1617
|
+
* The table whose rule looks itself up, or null. The twin of recursionIn in
|
|
1618
|
+
* attack.js: Postgres stops such a request rather than loop, and names the
|
|
1619
|
+
* table at fault - often not the one that was being read.
|
|
1620
|
+
*/
|
|
1621
|
+
CREATE OR REPLACE FUNCTION __KN__.recursion_in(message text)
|
|
1622
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1623
|
+
SELECT (regexp_match(coalesce(message, ''),
|
|
1624
|
+
'infinite recursion detected in policy for relation "([^"]+)"', 'i'))[1];
|
|
1625
|
+
$$;
|
|
1626
|
+
|
|
1593
1627
|
/*
|
|
1594
1628
|
* Reads a table the way a request would, as whoever is asking.
|
|
1595
1629
|
*
|
|
@@ -1674,6 +1708,8 @@ DECLARE
|
|
|
1674
1708
|
findings jsonb := '[]'::jsonb;
|
|
1675
1709
|
completed jsonb := '[]'::jsonb;
|
|
1676
1710
|
blocked jsonb := '[]'::jsonb;
|
|
1711
|
+
-- Rules that looked themselves up, and which read ran into each one.
|
|
1712
|
+
looped jsonb := '[]'::jsonb;
|
|
1677
1713
|
tab jsonb;
|
|
1678
1714
|
owner text;
|
|
1679
1715
|
anon jsonb;
|
|
@@ -1690,6 +1726,22 @@ BEGIN
|
|
|
1690
1726
|
anon := __KN__.read_as(source, tab->>'name', 'anon', NULL, NULL);
|
|
1691
1727
|
as_a := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_a(), owner);
|
|
1692
1728
|
|
|
1729
|
+
-- Looked for on every table, owner or not. Only a read of the table itself
|
|
1730
|
+
-- that came back with a verdict, for both callers, shows its rules no
|
|
1731
|
+
-- longer loop; a read that failed for some other reason shows nothing.
|
|
1732
|
+
IF NOT (anon->>'ok')::boolean AND __KN__.recursion_in(anon->>'why') IS NOT NULL THEN
|
|
1733
|
+
looped := looped || jsonb_build_array(jsonb_build_object(
|
|
1734
|
+
'relation', __KN__.recursion_in(anon->>'why'), 'table', tab->>'name', 'who', 'anyone'));
|
|
1735
|
+
END IF;
|
|
1736
|
+
IF NOT (as_a->>'ok')::boolean AND __KN__.recursion_in(as_a->>'why') IS NOT NULL THEN
|
|
1737
|
+
looped := looped || jsonb_build_array(jsonb_build_object(
|
|
1738
|
+
'relation', __KN__.recursion_in(as_a->>'why'), 'table', tab->>'name', 'who', 'signed-in'));
|
|
1739
|
+
END IF;
|
|
1740
|
+
IF ((anon->>'ok')::boolean OR __KN__.refusal_means(anon->>'why') = 'unreachable')
|
|
1741
|
+
AND ((as_a->>'ok')::boolean OR __KN__.refusal_means(as_a->>'why') = 'unreachable') THEN
|
|
1742
|
+
completed := completed || to_jsonb('recursive:' || (tab->>'name'));
|
|
1743
|
+
END IF;
|
|
1744
|
+
|
|
1693
1745
|
-- Did this read produce a verdict, and if not, why not?
|
|
1694
1746
|
key := 'exposed:' || (tab->>'name');
|
|
1695
1747
|
IF (anon->>'ok')::boolean THEN
|
|
@@ -1742,7 +1794,8 @@ BEGIN
|
|
|
1742
1794
|
END IF;
|
|
1743
1795
|
END LOOP;
|
|
1744
1796
|
|
|
1745
|
-
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked
|
|
1797
|
+
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
|
|
1798
|
+
'looped', looped);
|
|
1746
1799
|
END $$;
|
|
1747
1800
|
|
|
1748
1801
|
-- --------------------------------------------------------------------------
|
|
@@ -1862,6 +1915,25 @@ END $$;
|
|
|
1862
1915
|
* `seeded` carries the shape of row that worked when the table was seeded, so
|
|
1863
1916
|
* the insert here is not rejected by some CHECK the seeder already solved.
|
|
1864
1917
|
*/
|
|
1918
|
+
/*
|
|
1919
|
+
* Whose name an insert is written under: nobody's yet (user C), unless the
|
|
1920
|
+
* owner column is a key into one of the app's own tables, where only the two
|
|
1921
|
+
* seeded people exist - then the one who is not the signed-in caller.
|
|
1922
|
+
*/
|
|
1923
|
+
CREATE OR REPLACE FUNCTION __KN__.write_as_for(tab jsonb, owner text, tables jsonb)
|
|
1924
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1925
|
+
BEGIN
|
|
1926
|
+
IF owner IS NOT NULL AND EXISTS (
|
|
1927
|
+
SELECT 1 FROM jsonb_array_elements(__KN__.foreign_keys(tab)) k
|
|
1928
|
+
WHERE jsonb_array_length(k->'columns') = 1 AND k->'columns'->>0 = owner
|
|
1929
|
+
AND EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(tables, '[]'::jsonb)) t
|
|
1930
|
+
WHERE t->>'name' = k->>'refTable'))
|
|
1931
|
+
THEN
|
|
1932
|
+
RETURN __KN__.user_a();
|
|
1933
|
+
END IF;
|
|
1934
|
+
RETURN __KN__.user_c();
|
|
1935
|
+
END $$;
|
|
1936
|
+
|
|
1865
1937
|
CREATE OR REPLACE FUNCTION __KN__.tamper(source text, tables jsonb, seeded jsonb,
|
|
1866
1938
|
policies jsonb DEFAULT '[]'::jsonb)
|
|
1867
1939
|
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
@@ -1925,7 +1997,10 @@ BEGIN
|
|
|
1925
1997
|
-- Written under a name nobody has used, which is both what makes the row
|
|
1926
1998
|
-- land at all and what makes it the right test: adding a row of your own
|
|
1927
1999
|
-- is the feature, adding one under somebody else's name is not.
|
|
1928
|
-
|
|
2000
|
+
-- Unless the owner is a key into one of the app's own tables, where only
|
|
2001
|
+
-- the two seeded people exist: then the one who is not the signed-in
|
|
2002
|
+
-- caller. Found on a blind test, where the nameless insert hit the key.
|
|
2003
|
+
row_ := __KN__.row_for(source, tab, __KN__.write_as_for(tab, owner, tables), '7', NULL, shape);
|
|
1929
2004
|
|
|
1930
2005
|
-- Every statement written out before anybody changes role.
|
|
1931
2006
|
moves := jsonb_build_array(
|
|
@@ -2504,6 +2579,19 @@ BEGIN
|
|
|
2504
2579
|
|
|
2505
2580
|
findings := (seen->'findings') || (wrote->'findings') || (stranded->'findings');
|
|
2506
2581
|
|
|
2582
|
+
-- A rule that looks itself up breaks the app for whoever it fails for.
|
|
2583
|
+
-- One finding per table at fault, however many reads ran into it; the
|
|
2584
|
+
-- attacks it stopped stay in not_checked, because they did not run.
|
|
2585
|
+
findings := findings || coalesce((
|
|
2586
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
2587
|
+
'kind', 'recursive', 'table', g.relation, 'reads', g.reads,
|
|
2588
|
+
'callers', g.callers, 'columns', '[]'::jsonb))
|
|
2589
|
+
FROM (SELECT l->>'relation' AS relation,
|
|
2590
|
+
jsonb_agg(DISTINCT l->>'table') AS reads,
|
|
2591
|
+
jsonb_agg(DISTINCT l->>'who') AS callers
|
|
2592
|
+
FROM jsonb_array_elements(coalesce(seen->'looped', '[]'::jsonb)) l
|
|
2593
|
+
GROUP BY l->>'relation') g), '[]'::jsonb);
|
|
2594
|
+
|
|
2507
2595
|
-- Everything genuinely attacked, so a re-check can tell a finding that was
|
|
2508
2596
|
-- fixed from one whose attack simply did not run this time.
|
|
2509
2597
|
attempted := coalesce(seen->'completed', '[]'::jsonb)
|
package/finding.js
CHANGED
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
const IDENTITY = [
|
|
21
21
|
[/\b(email|e_mail|mail)\b/i, 'email addresses'],
|
|
22
22
|
[/\b(phone|mobile|contact_number|telephone)\b/i, 'phone numbers'],
|
|
23
|
-
|
|
23
|
+
// `\b` treats "_" as part of a word, so display_name never matched `name` -
|
|
24
|
+
// found on a blind test, where a public profiles table was reported as
|
|
25
|
+
// holding nothing in particular. The compound names are listed instead.
|
|
26
|
+
[/\b(full_name|first_name|last_name|given_name|surname|name|display_name|username|user_name|nickname|screen_name)\b/i, 'names'],
|
|
24
27
|
[/\b(address|street|city|postcode|zip|pincode)\b/i, 'addresses'],
|
|
25
28
|
[/\b(dob|date_of_birth|birth_date|birthday)\b/i, 'dates of birth'],
|
|
26
29
|
];
|
|
@@ -28,18 +31,37 @@ const IDENTITY = [
|
|
|
28
31
|
// Things that are worse than identifying - they let somebody act as the person,
|
|
29
32
|
// or take money.
|
|
30
33
|
const SECRETS = [
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
// `\b` counts "_" as a letter, so these missed every compound name - found
|
|
35
|
+
// on a blind test: integration_token, access_token and signing_secret, all
|
|
36
|
+
// readable by anyone, and the report named none of them. A name that ENDS
|
|
37
|
+
// in the secret word is the secret; token_expires_at is only about one.
|
|
38
|
+
// Singular only: `tokens` and `input_tokens` count what an AI call used -
|
|
39
|
+
// found on the next blind test, where api_usage.tokens (an integer) was
|
|
40
|
+
// named as a secret.
|
|
41
|
+
[/\b(password|passwd|pass_hash|password_hash)\b|(^|_)(password|passwd|pwd|password_hash)$/i, 'passwords'],
|
|
42
|
+
[/\b(token|api_key|apikey|secret|private_key|access_key)\b|(^|_)(token|secret|(api|private|access|secret|signing|service|encryption)_?key)$/i, 'access tokens and secrets'],
|
|
33
43
|
[/\b(card|card_number|cvv|iban|account_number|upi)\b/i, 'payment details'],
|
|
44
|
+
// A code that grants something: whoever reads a moderator_code can become a
|
|
45
|
+
// moderator. Found on a blind test, added straight in production and readable
|
|
46
|
+
// by anyone - reported only as "2 rows".
|
|
47
|
+
[/^(?:\w+_)?(moderator|invite|invitation|access|reset|verification|recovery|admin|join|auth|login|magic|security|otp)_code$|^(otp|pin)$/i, 'secret codes'],
|
|
34
48
|
];
|
|
35
49
|
|
|
50
|
+
// Who holds power in the app. Not a secret in itself, but a list of exactly
|
|
51
|
+
// which accounts are worth attacking.
|
|
52
|
+
const PRIVILEGE = [/^(is_admin|is_superuser|is_super_admin|is_staff|is_moderator|is_owner|role|roles|permissions)$/i, 'who is an admin'];
|
|
53
|
+
|
|
36
54
|
const MONEY = [/\b(amount|total|price|balance|salary|revenue|invoice)\b/i, 'amounts of money'];
|
|
37
55
|
|
|
56
|
+
// Each kind named with the columns that showed it: "secret codes
|
|
57
|
+
// (moderator_code)" can be found in the table; "secret codes" has to be
|
|
58
|
+
// looked for.
|
|
38
59
|
function matched(columns, table) {
|
|
39
|
-
const names = columns || [];
|
|
60
|
+
const names = (columns || []).map(String);
|
|
40
61
|
const found = [];
|
|
41
62
|
for (const [pattern, label] of table) {
|
|
42
|
-
|
|
63
|
+
const hits = names.filter((column) => pattern.test(column));
|
|
64
|
+
if (hits.length) found.push(label + ' (' + hits.join(', ') + ')');
|
|
43
65
|
}
|
|
44
66
|
return found;
|
|
45
67
|
}
|
|
@@ -49,7 +71,13 @@ function readContents(columns) {
|
|
|
49
71
|
const identity = matched(columns, IDENTITY);
|
|
50
72
|
const secrets = matched(columns, SECRETS);
|
|
51
73
|
const money = matched(columns, [MONEY]);
|
|
52
|
-
|
|
74
|
+
const privilege = matched(columns, [PRIVILEGE]);
|
|
75
|
+
return { identity: identity, secrets: secrets, money: money, privilege: privilege };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Everything worth naming, worst first. */
|
|
79
|
+
function heldIn(contents) {
|
|
80
|
+
return contents.secrets.concat(contents.identity, contents.privilege || [], contents.money);
|
|
53
81
|
}
|
|
54
82
|
|
|
55
83
|
/** A list, written the way a person writes one. */
|
|
@@ -72,6 +100,11 @@ function listOf(items) {
|
|
|
72
100
|
* should be fixed - there is no such thing as a low finding here.
|
|
73
101
|
*/
|
|
74
102
|
function severityOf(finding, contents) {
|
|
103
|
+
if (finding.kind === 'recursive') {
|
|
104
|
+
// Nothing leaks and nothing is written: the request fails. Serious,
|
|
105
|
+
// because the app is broken for whoever it fails for, but not a break-in.
|
|
106
|
+
return 'HIGH';
|
|
107
|
+
}
|
|
75
108
|
if (finding.kind === 'privileged') {
|
|
76
109
|
// Not proven, so never CRITICAL: this is a reach that exists, not a break
|
|
77
110
|
// that was demonstrated. A function anon can call that also writes is worth
|
|
@@ -125,6 +158,18 @@ function severityOf(finding, contents) {
|
|
|
125
158
|
* one it is saves them looking in the wrong place.
|
|
126
159
|
*/
|
|
127
160
|
function causeOf(finding) {
|
|
161
|
+
if (finding.kind === 'recursive') {
|
|
162
|
+
return {
|
|
163
|
+
short: 'a rule on it looks the same table up again',
|
|
164
|
+
long:
|
|
165
|
+
'A row level security rule on this table checks something by reading ' +
|
|
166
|
+
'this same table - usually "is this person a member?" asked of the ' +
|
|
167
|
+
'members table itself. Reading the table means applying the rule, and ' +
|
|
168
|
+
'applying the rule means reading the table, so Postgres stops the ' +
|
|
169
|
+
'request with an error instead of going round forever. Any other ' +
|
|
170
|
+
'table whose rule asks this one fails the same way.',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
128
173
|
if (finding.kind === 'orphaned') {
|
|
129
174
|
return {
|
|
130
175
|
short: 'nothing in the database ties the two tables together',
|
|
@@ -246,7 +291,19 @@ function causeOf(finding) {
|
|
|
246
291
|
/** What a caller could do, written the way a person would say it. */
|
|
247
292
|
const WRITE_WORDS = { add: 'add rows to', change: 'change rows in', delete: 'delete rows from' };
|
|
248
293
|
|
|
294
|
+
/** Who a looping rule fails for, said the way the headline and body need it. */
|
|
295
|
+
function loopVictims(finding) {
|
|
296
|
+
const callers = finding.callers || [];
|
|
297
|
+
if (callers.includes('anyone') && callers.includes('signed-in')) return 'everyone, signed in or not,';
|
|
298
|
+
if (callers.includes('anyone')) return 'visitors who are not logged in';
|
|
299
|
+
return 'signed-in users';
|
|
300
|
+
}
|
|
301
|
+
|
|
249
302
|
function headlineFor(finding) {
|
|
303
|
+
if (finding.kind === 'recursive') {
|
|
304
|
+
return 'A rule on your ' + finding.table + ' table refers to itself, so ' + loopVictims(finding) +
|
|
305
|
+
' get an error instead of data.';
|
|
306
|
+
}
|
|
250
307
|
if (finding.kind === 'orphaned') {
|
|
251
308
|
return 'Your ' + finding.table + ' table can point at a ' +
|
|
252
309
|
finding.parent.replace(/s$/, '') + ' that does not exist.';
|
|
@@ -292,9 +349,27 @@ function costOf(finding) {
|
|
|
292
349
|
}
|
|
293
350
|
|
|
294
351
|
function bodyFor(finding, contents) {
|
|
295
|
-
const holds = listOf(
|
|
352
|
+
const holds = listOf(heldIn(contents));
|
|
296
353
|
const rowWord = finding.readable === 1 ? 'row' : 'rows';
|
|
297
354
|
|
|
355
|
+
if (finding.kind === 'recursive') {
|
|
356
|
+
// Said as what happened to the requests, and then what it cost this
|
|
357
|
+
// report: the tables behind the error could not be tested for leaks.
|
|
358
|
+
const reads = finding.reads || [];
|
|
359
|
+
const callers = finding.callers || [];
|
|
360
|
+
const as = [];
|
|
361
|
+
if (callers.includes('anyone')) as.push('a visitor who is not logged in');
|
|
362
|
+
if (callers.includes('signed-in')) as.push('a signed-in user');
|
|
363
|
+
const one = reads.length === 1;
|
|
364
|
+
return (
|
|
365
|
+
'Using ' + listOf(reads) + ' on a copy of your app as ' + as.join(' and as ') +
|
|
366
|
+
', every request was refused with "infinite recursion detected in policy for relation ' +
|
|
367
|
+
finding.table + '". Every page in your app that uses ' + (one ? 'that table' : 'those tables') +
|
|
368
|
+
' fails the same way for ' + loopVictims(finding).replace(/,$/, '') + '. It also means I could ' +
|
|
369
|
+
'not test ' + (one ? 'it' : 'them') + ' for leaks - those attacks are listed at the end.'
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
298
373
|
if (finding.kind === 'orphaned') {
|
|
299
374
|
// Said as the thing a person will actually meet: a customer asks to be
|
|
300
375
|
// deleted, and their rows are still here afterwards.
|
|
@@ -308,7 +383,7 @@ function bodyFor(finding, contents) {
|
|
|
308
383
|
}
|
|
309
384
|
|
|
310
385
|
if (finding.kind === 'writable') {
|
|
311
|
-
const holds = listOf(
|
|
386
|
+
const holds = listOf(heldIn(contents));
|
|
312
387
|
const who = finding.who === 'anyone'
|
|
313
388
|
? 'Without logging in and without an account, I '
|
|
314
389
|
: 'Signed in as one of your customers, I ';
|
|
@@ -410,11 +485,166 @@ function wrapTo(text, width) {
|
|
|
410
485
|
return out;
|
|
411
486
|
}
|
|
412
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Whether the app signs people in through Supabase: any foreign key to
|
|
490
|
+
* auth.users, or any rule that asks auth.uid() or auth.jwt(). Decided for the
|
|
491
|
+
* whole app, not table by table. Found on a blind test: appointments points
|
|
492
|
+
* at profiles, which points at auth.users - no key of its own and no rule
|
|
493
|
+
* asking auth.uid(), so it was told the app "signs people in its own way"
|
|
494
|
+
* when it plainly used Supabase's. Read from the original schema, not the
|
|
495
|
+
* copy - the copy points those keys at a stand-in. A table missing from the
|
|
496
|
+
* map is unknown, which the prompts treat as tied.
|
|
497
|
+
*/
|
|
498
|
+
function authTiesOf(plan) {
|
|
499
|
+
const tables = (plan && plan.tables) || [];
|
|
500
|
+
const keyed = tables.some((table) => (table.constraints || []).some((c) =>
|
|
501
|
+
c.kind === 'f' && /references\s+"?auth"?\s*\.\s*"?users"?\s*\(/i.test(String(c.definition || ''))));
|
|
502
|
+
const asked = ((plan && plan.policies) || []).some((policy) =>
|
|
503
|
+
/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(String(policy.qual || '') + ' ' + String(policy.with_check || '')));
|
|
504
|
+
return new Map(tables.map((table) => [table.name, keyed || asked]));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The fix for a table no rule can tie to the signed-in user, or null when one
|
|
509
|
+
* can. Two ways that happens. The app has its own login: found on a real app,
|
|
510
|
+
* "compare the owner to the id of the signed-in user" cannot be done there -
|
|
511
|
+
* auth.uid() is empty for everybody - and an assistant that writes it anyway
|
|
512
|
+
* locks the app out of its own table. Or the table has no column saying whose
|
|
513
|
+
* row it is - a waitlist, a contact form - so there is nothing to compare.
|
|
514
|
+
*/
|
|
515
|
+
// A column that says which organization a row belongs to. Such a table is
|
|
516
|
+
// not "nobody's" - its rows are a team's.
|
|
517
|
+
const TENANT_COLUMN = /^(org_id|organization_id|workspace_id|team_id|tenant_id|company_id)$/i;
|
|
518
|
+
|
|
519
|
+
function serverOnlyLines(finding) {
|
|
520
|
+
const table = finding.table;
|
|
521
|
+
let why;
|
|
522
|
+
// Found on a fix test: api_usage has org_id, and the prompt said it had no
|
|
523
|
+
// column saying whose row it is. The assistant applying it had to correct
|
|
524
|
+
// the prompt; a less careful one would have locked the table away from the
|
|
525
|
+
// members who use it.
|
|
526
|
+
const tenant = finding.authTied !== false && finding.owned === false &&
|
|
527
|
+
(finding.columns || []).find((c) => TENANT_COLUMN.test(String(c)));
|
|
528
|
+
if (tenant) {
|
|
529
|
+
return [
|
|
530
|
+
'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
|
|
531
|
+
'rule should let a signed-in user in only when they are a member of that row\'s organization - ' +
|
|
532
|
+
'checked through your membership table, or a helper function that asks it - and give logged-out ' +
|
|
533
|
+
'visitors nothing.',
|
|
534
|
+
'',
|
|
535
|
+
'Write separate rules for reading, inserting, updating and deleting, each with that membership ' +
|
|
536
|
+
'check (USING for reading, updating and deleting, WITH CHECK for inserting and updating), and ' +
|
|
537
|
+
'decide whether every member should be able to write or only admins. If only your server ' +
|
|
538
|
+
'writes this table, add no write rules at all.',
|
|
539
|
+
'',
|
|
540
|
+
];
|
|
541
|
+
}
|
|
542
|
+
if (finding.authTied === false) {
|
|
543
|
+
why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
|
|
544
|
+
'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
|
|
545
|
+
'work here - auth.uid() is empty for everyone - and adding one would lock your own app out.';
|
|
546
|
+
} else if (finding.owned === false) {
|
|
547
|
+
why = '"' + table + '" has no column that says whose row it is, so a rule tying rows to the ' +
|
|
548
|
+
'signed-in user has nothing to compare against.';
|
|
549
|
+
} else {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
return [
|
|
553
|
+
why,
|
|
554
|
+
'',
|
|
555
|
+
'If only your server reads and writes this table: keep row level security on, drop the rules ' +
|
|
556
|
+
'that let everyone in, and add none for anon or authenticated. Have your server use the ' +
|
|
557
|
+
'service_role key, which skips these rules - and keep that key on the server only, never ' +
|
|
558
|
+
'in the browser or in the app\'s public code.',
|
|
559
|
+
'',
|
|
560
|
+
'If the browser needs to read or write part of it directly, tell me which part first, so we ' +
|
|
561
|
+
'write the narrowest rule for exactly that.',
|
|
562
|
+
'',
|
|
563
|
+
];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* One prompt for everything wrong with one table's rules - read and write,
|
|
568
|
+
* strangers and customers - because it is one rule to replace, not four.
|
|
569
|
+
*/
|
|
570
|
+
function groupPromptFor(members) {
|
|
571
|
+
const lead = members.find((m) => m.kind === 'writable') || members[0];
|
|
572
|
+
const cause = causeOf(lead);
|
|
573
|
+
const table = lead.table;
|
|
574
|
+
const owner = (members.find((m) => m.owner) || {}).owner;
|
|
575
|
+
const ownerWords = owner ? '"' + owner + '"' : 'the column that says who each row belongs to';
|
|
576
|
+
const read = members.find((m) => m.kind === 'exposed');
|
|
577
|
+
const writers = members.filter((m) => m.kind === 'writable');
|
|
578
|
+
const anyoneWrites = writers.find((w) => w.who === 'anyone');
|
|
579
|
+
const customersWrite = writers.find((w) => w.who !== 'anyone');
|
|
580
|
+
const what = [];
|
|
581
|
+
if (read) what.push('read');
|
|
582
|
+
if (writers.length) what.push('written');
|
|
583
|
+
const who = read || anyoneWrites ? 'anyone who is not logged in' : 'any signed-in user, including other users\' rows';
|
|
584
|
+
const did = [];
|
|
585
|
+
if (read) did.push('read ' + read.readable + ' ' + (read.readable === 1 ? 'row' : 'rows'));
|
|
586
|
+
const moves = [];
|
|
587
|
+
for (const w of writers) for (const m of w.can || []) if (!moves.includes(m)) moves.push(m);
|
|
588
|
+
for (const m of ['add', 'change', 'delete']) {
|
|
589
|
+
if (moves.includes(m)) did.push({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' }[m]);
|
|
590
|
+
}
|
|
591
|
+
const lines = [
|
|
592
|
+
'My app has a security problem.',
|
|
593
|
+
'',
|
|
594
|
+
'The "' + table + '" table can be ' + what.join(' and ') + ' by ' + who +
|
|
595
|
+
', because ' + cause.short + '. I proved it on a copy: I ' + listOf(did) + '.' +
|
|
596
|
+
(anyoneWrites && customersWrite ? ' Signed-in users can do the same.' : ''),
|
|
597
|
+
'',
|
|
598
|
+
].concat(
|
|
599
|
+
serverOnlyLines(lead) || [
|
|
600
|
+
'Switch row level security on for this table, remove the rule that lets everyone in, and ' +
|
|
601
|
+
'write separate rules for reading, inserting, updating and deleting. A rule written FOR ALL ' +
|
|
602
|
+
'covers far more than reading.',
|
|
603
|
+
'',
|
|
604
|
+
'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
|
|
605
|
+
'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
|
|
606
|
+
'reads, changes or writes a row under somebody else\'s name. Give logged-out visitors no rule ' +
|
|
607
|
+
'at all unless they truly need one.',
|
|
608
|
+
'',
|
|
609
|
+
],
|
|
610
|
+
['Then check every other table for the same thing.'],
|
|
611
|
+
);
|
|
612
|
+
return lines
|
|
613
|
+
.map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
|
|
614
|
+
.reduce((all, part) => all.concat(part), [])
|
|
615
|
+
.join('\n');
|
|
616
|
+
}
|
|
617
|
+
|
|
413
618
|
function fixPromptFor(finding) {
|
|
414
619
|
const cause = causeOf(finding);
|
|
415
620
|
const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
|
|
416
621
|
|
|
417
|
-
const lines = finding.kind === '
|
|
622
|
+
const lines = finding.kind === 'recursive'
|
|
623
|
+
? [
|
|
624
|
+
'My app has a broken database rule.',
|
|
625
|
+
'',
|
|
626
|
+
'A row level security policy on the "' + finding.table + '" table reads "' + finding.table +
|
|
627
|
+
'" itself, so Postgres stops every request that touches it with "infinite recursion ' +
|
|
628
|
+
'detected in policy for relation ' + finding.table + '". I saw it when using ' +
|
|
629
|
+
listOf((finding.reads || []).map((t) => '"' + t + '"')) + '.',
|
|
630
|
+
'',
|
|
631
|
+
'Show me the policies on "' + finding.table + '" and on every table whose policy reads "' +
|
|
632
|
+
finding.table + '", and find the lookup that goes back into "' + finding.table + '".',
|
|
633
|
+
'',
|
|
634
|
+
'Move that lookup into a SECURITY DEFINER function that answers only about the signed-in ' +
|
|
635
|
+
'user - for example is_member_of(org uuid) returning whether auth.uid() is in that ' +
|
|
636
|
+
'organization - with SET search_path fixed, owned by the table\'s owner so it reads the ' +
|
|
637
|
+
'table without the rule applying again. Grant EXECUTE on it to authenticated only. Then ' +
|
|
638
|
+
'make the policies call that function instead of reading "' + finding.table + '" directly.',
|
|
639
|
+
'',
|
|
640
|
+
'Keep each policy exactly as strict as it was meant to be: members see only their own ' +
|
|
641
|
+
'organization\'s rows, and logged-out visitors get nothing. Do not replace it with USING ' +
|
|
642
|
+
'(true) to make the error go away.',
|
|
643
|
+
'',
|
|
644
|
+
'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
|
|
645
|
+
'check again - the tables it could not test will be tested this time.',
|
|
646
|
+
]
|
|
647
|
+
: finding.kind === 'privileged'
|
|
418
648
|
? [
|
|
419
649
|
'My app may have a security problem - please check it rather than assume it.',
|
|
420
650
|
'',
|
|
@@ -474,17 +704,24 @@ function fixPromptFor(finding) {
|
|
|
474
704
|
(finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
|
|
475
705
|
.join(', ') + '.',
|
|
476
706
|
'',
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
707
|
+
].concat(
|
|
708
|
+
serverOnlyLines(finding) || [
|
|
709
|
+
'Switch row level security on for this table, then write separate rules for ' +
|
|
710
|
+
'reading, inserting, updating and deleting. A rule written FOR SELECT does ' +
|
|
711
|
+
'not cover writes, and a rule written FOR ALL covers far more than reading.',
|
|
712
|
+
'',
|
|
713
|
+
'For insert and update, use WITH CHECK comparing ' + owner +
|
|
714
|
+
' to the id of the signed-in user, so nobody can write a row under somebody ' +
|
|
715
|
+
"else's name. For update and delete, add USING with the same comparison: " +
|
|
716
|
+
'with USING (true) left on the update, anyone can take a row that is not ' +
|
|
717
|
+
'theirs and rewrite it as their own.',
|
|
718
|
+
'',
|
|
719
|
+
],
|
|
720
|
+
[
|
|
721
|
+
'Then check every other table for the same thing - a table with no row level ' +
|
|
722
|
+
'security on it is writable by default.',
|
|
723
|
+
],
|
|
724
|
+
)
|
|
488
725
|
: finding.kind === 'duplicated'
|
|
489
726
|
? [
|
|
490
727
|
'My app has a security problem.',
|
|
@@ -559,11 +796,14 @@ function fixPromptFor(finding) {
|
|
|
559
796
|
? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
|
|
560
797
|
: 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
|
|
561
798
|
'',
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
799
|
+
].concat(
|
|
800
|
+
serverOnlyLines(finding) || [
|
|
801
|
+
'Fix it so a person can only read their own rows: compare ' + owner +
|
|
802
|
+
' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
|
|
803
|
+
'',
|
|
804
|
+
],
|
|
805
|
+
['Then look for the same mistake on every other table and fix those too.'],
|
|
806
|
+
);
|
|
567
807
|
// Wrapped here rather than by whoever prints it: this text is pasted into a
|
|
568
808
|
// chat box as often as it is read in a terminal, and an unwrapped paragraph
|
|
569
809
|
// is a wall in both.
|
|
@@ -605,7 +845,10 @@ function describe(finding) {
|
|
|
605
845
|
cause: finding.kind === 'privileged' ? '' : cause.long,
|
|
606
846
|
who: finding.who,
|
|
607
847
|
can: finding.can,
|
|
608
|
-
proof: finding.kind === '
|
|
848
|
+
proof: finding.kind === 'recursive'
|
|
849
|
+
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
850
|
+
'"infinite recursion detected in policy for relation ' + finding.table + '".'
|
|
851
|
+
: finding.kind === 'privileged'
|
|
609
852
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
610
853
|
: finding.kind === 'duplicated'
|
|
611
854
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
@@ -620,6 +863,50 @@ function describe(finding) {
|
|
|
620
863
|
};
|
|
621
864
|
}
|
|
622
865
|
|
|
866
|
+
/** Everything wrong with one table's rules, said once. */
|
|
867
|
+
function mergeTable(group, one) {
|
|
868
|
+
const members = group.map(one);
|
|
869
|
+
const read = group.find((m) => m.kind === 'exposed');
|
|
870
|
+
const anyoneWrites = group.find((m) => m.kind === 'writable' && m.who === 'anyone');
|
|
871
|
+
const customersWrite = group.find((m) => m.kind === 'writable' && m.who !== 'anyone');
|
|
872
|
+
const lead = anyoneWrites || customersWrite || read;
|
|
873
|
+
const leadItem = members[group.indexOf(lead)];
|
|
874
|
+
const table = lead.table;
|
|
875
|
+
const order = ['delete', 'change', 'add'];
|
|
876
|
+
const does = (w) => order.filter((m) => (w.can || []).includes(m)).map((m) => WRITE_WORDS[m]);
|
|
877
|
+
let headline;
|
|
878
|
+
if (read && anyoneWrites) {
|
|
879
|
+
const verbs = ['read'].concat(order.filter((m) => (anyoneWrites.can || []).includes(m)));
|
|
880
|
+
headline = 'Anyone can ' + listOf(verbs) + ' rows in your ' + table + ' table.';
|
|
881
|
+
} else if (read && customersWrite) {
|
|
882
|
+
headline = 'Anyone can read your ' + table + ' table, and any signed-in customer can ' + listOf(does(customersWrite)) + ' it.';
|
|
883
|
+
} else {
|
|
884
|
+
headline = leadItem.headline;
|
|
885
|
+
}
|
|
886
|
+
// The customers' part is said in one line when strangers can already do it.
|
|
887
|
+
const bodies = [];
|
|
888
|
+
for (let i = 0; i < group.length; i++) {
|
|
889
|
+
if (group[i] === customersWrite && anyoneWrites) continue;
|
|
890
|
+
bodies.push(members[i].body);
|
|
891
|
+
}
|
|
892
|
+
if (customersWrite && anyoneWrites) bodies.push('Signed-in customers can do the same.');
|
|
893
|
+
const severity = members.some((m) => m.severity === 'CRITICAL') ? 'CRITICAL' : leadItem.severity;
|
|
894
|
+
const can = [];
|
|
895
|
+
for (const m of group) for (const c of m.can || []) if (!can.includes(c)) can.push(c);
|
|
896
|
+
return Object.assign({}, leadItem, {
|
|
897
|
+
severity: severity,
|
|
898
|
+
status: 'confirmed',
|
|
899
|
+
kind: lead.kind,
|
|
900
|
+
who: read || anyoneWrites ? 'anyone' : leadItem.who,
|
|
901
|
+
can: can,
|
|
902
|
+
headline: headline,
|
|
903
|
+
body: bodies.join(' '),
|
|
904
|
+
proof: members.map((m) => m.proof).join(' '),
|
|
905
|
+
fixPrompt: groupPromptFor(group),
|
|
906
|
+
members: members,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
623
910
|
/** The whole report, in the order a person should read it. */
|
|
624
911
|
function describeAll(findings) {
|
|
625
912
|
// A table anyone can read is also, necessarily, a table one customer can read
|
|
@@ -628,24 +915,46 @@ function describeAll(findings) {
|
|
|
628
915
|
// is kept, and it carries the rest.
|
|
629
916
|
const raw = findings || [];
|
|
630
917
|
const publiclyOpen = new Set(raw.filter((f) => f.kind === 'exposed').map((f) => f.table));
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
918
|
+
const kept = raw.filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)));
|
|
919
|
+
const one = (f) => {
|
|
920
|
+
const item = Object.assign(describe(f), {
|
|
921
|
+
alsoCrossed: f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
|
|
922
|
+
});
|
|
923
|
+
if (item.alsoCrossed) item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
|
|
924
|
+
return item;
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
// One table, one finding. A single rule written FOR ALL USING (true) opens a
|
|
928
|
+
// table to reading and writing, for strangers and customers alike - four
|
|
929
|
+
// proofs of one mistake. Listed as four, a real app's report showed six
|
|
930
|
+
// CRITICAL findings for two rules, and the person reads six problems where
|
|
931
|
+
// there are two to fix. The proofs are kept as members, so a re-check can
|
|
932
|
+
// still tell "strangers can no longer write" from "strangers can still read".
|
|
933
|
+
const grouped = new Map();
|
|
934
|
+
for (const f of kept) {
|
|
935
|
+
if ((f.kind !== 'exposed' && f.kind !== 'writable') || f.isView || f.countOnly) continue;
|
|
936
|
+
if (!grouped.has(f.table)) grouped.set(f.table, []);
|
|
937
|
+
grouped.get(f.table).push(f);
|
|
938
|
+
}
|
|
939
|
+
const described = [];
|
|
940
|
+
const done = new Set();
|
|
941
|
+
for (const f of kept) {
|
|
942
|
+
const group = grouped.get(f.table);
|
|
943
|
+
if (!group || group.length < 2 || group.indexOf(f) === -1) {
|
|
944
|
+
described.push(one(f));
|
|
945
|
+
continue;
|
|
642
946
|
}
|
|
947
|
+
if (done.has(f.table)) continue;
|
|
948
|
+
done.add(f.table);
|
|
949
|
+
described.push(mergeTable(group, one));
|
|
643
950
|
}
|
|
644
951
|
// Worst first, and within that the ones open to the whole internet before the
|
|
645
952
|
// ones that need an account, and those before the ones that need two requests
|
|
646
953
|
// to arrive together.
|
|
647
954
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
648
|
-
|
|
955
|
+
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
956
|
+
// fails for, which matters more than a duplicate or a stray row.
|
|
957
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, privileged: 5 };
|
|
649
958
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
650
959
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
651
960
|
}
|
|
@@ -678,6 +987,7 @@ function allClearLines(attacksRun) {
|
|
|
678
987
|
}
|
|
679
988
|
|
|
680
989
|
module.exports = {
|
|
990
|
+
authTiesOf: authTiesOf,
|
|
681
991
|
readContents: readContents,
|
|
682
992
|
severityOf: severityOf,
|
|
683
993
|
causeOf: causeOf,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18.0.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node cli.check.js && node loop.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node cli.check.js && node loop.check.js",
|
|
35
35
|
"check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
|
|
36
36
|
"check:package": "node package.check.js",
|
|
37
37
|
"check:store": "node store.check.js",
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"check:verdicts": "node verdicts.check.js",
|
|
54
54
|
"check:shop": "node shop.check.js",
|
|
55
55
|
"check:privileged": "node privileged.check.js",
|
|
56
|
+
"check:recursion": "node recursion.check.js",
|
|
57
|
+
"check:guarded": "node guarded.check.js",
|
|
56
58
|
"check:cli": "node cli.check.js",
|
|
57
59
|
"check:writes": "node tamper.check.js",
|
|
58
60
|
"check:orphans": "node orphans.check.js",
|
package/recheck.js
CHANGED
|
@@ -42,9 +42,20 @@ function keyOf(item) {
|
|
|
42
42
|
* untestable table would land in `fixed`, and a green badge would be handed to
|
|
43
43
|
* an app nobody checked.
|
|
44
44
|
*/
|
|
45
|
+
/**
|
|
46
|
+
* A report shows one finding per table, but each one keeps the proofs it was
|
|
47
|
+
* made of. Those are what get compared: closing a table to strangers' writes
|
|
48
|
+
* while it stays readable is one proof fixed and one still open, not "the
|
|
49
|
+
* table's finding" either fixed or not. Reports saved before findings were
|
|
50
|
+
* grouped have no members, and are read as they are.
|
|
51
|
+
*/
|
|
52
|
+
function proofsOf(list) {
|
|
53
|
+
return (list || []).reduce((all, item) => all.concat(item.members && item.members.length ? item.members : [item]), []);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
function compare(before, after) {
|
|
46
|
-
const was = (before && before.findings)
|
|
47
|
-
const now = (after && after.findings)
|
|
57
|
+
const was = proofsOf(before && before.findings);
|
|
58
|
+
const now = proofsOf(after && after.findings);
|
|
48
59
|
|
|
49
60
|
// The second run failed outright. Nothing can be claimed about anything.
|
|
50
61
|
if (after && after.stopped) {
|
|
@@ -149,6 +160,22 @@ function badgeLines(result, attacksRun) {
|
|
|
149
160
|
];
|
|
150
161
|
}
|
|
151
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Which proof a line is about. One table can have several - strangers reading
|
|
165
|
+
* it, strangers writing to it - and "waitlist, waitlist" under "fixed" would
|
|
166
|
+
* not say which one was closed.
|
|
167
|
+
*/
|
|
168
|
+
function whatOf(item) {
|
|
169
|
+
if (item.kind === 'exposed') return 'read by anyone';
|
|
170
|
+
if (item.kind === 'crossed') return "customers reading each other's rows";
|
|
171
|
+
if (item.kind === 'writable') return item.who === 'anyone' ? 'written by anyone' : 'written by any signed-in customer';
|
|
172
|
+
if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
|
|
173
|
+
if (item.kind === 'orphaned') return 'rows left pointing at nothing';
|
|
174
|
+
if (item.kind === 'privileged') return 'a function anyone can call';
|
|
175
|
+
if (item.kind === 'recursive') return 'a rule that refers to itself';
|
|
176
|
+
return item.kind;
|
|
177
|
+
}
|
|
178
|
+
|
|
152
179
|
/** What the re-check says, in the order it matters. */
|
|
153
180
|
function describe(result) {
|
|
154
181
|
const lines = [''];
|
|
@@ -165,7 +192,14 @@ function describe(result) {
|
|
|
165
192
|
lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
|
|
166
193
|
lines.push('');
|
|
167
194
|
for (const item of result.fixed) {
|
|
168
|
-
|
|
195
|
+
// A function is never called, only read - so its fix is said as what
|
|
196
|
+
// was seen, not as an attack that was refused. Found on a fix test.
|
|
197
|
+
const how = item.kind === 'privileged'
|
|
198
|
+
? 'I looked again, and a visitor with no account can no longer call it.'
|
|
199
|
+
: item.kind === 'recursive'
|
|
200
|
+
? 'I read it again, and its rules answered instead of stopping with an error.'
|
|
201
|
+
: 'I ran the same attack again and it was refused.';
|
|
202
|
+
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + how);
|
|
169
203
|
}
|
|
170
204
|
lines.push('');
|
|
171
205
|
}
|
|
@@ -185,7 +219,7 @@ function describe(result) {
|
|
|
185
219
|
lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
|
|
186
220
|
lines.push('');
|
|
187
221
|
for (const item of result.unverifiable) {
|
|
188
|
-
lines.push(' ' + item.table + ' - ' + item.why);
|
|
222
|
+
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
|
|
189
223
|
}
|
|
190
224
|
lines.push('');
|
|
191
225
|
lines.push(' These are not fixed and not broken - they are unknown. The problem');
|
package/scan.js
CHANGED
|
@@ -269,6 +269,10 @@ async function scan(client, sourceSchema, options) {
|
|
|
269
269
|
const viewNames = new Set(views.map((view) => view.name));
|
|
270
270
|
const attempted = impersonation.completed.filter((key) => {
|
|
271
271
|
const name = key.split(':')[1];
|
|
272
|
+
// Whether a rule loops needs no rows: Postgres finds the loop while
|
|
273
|
+
// planning the read, before it looks at a single row. A table nothing
|
|
274
|
+
// could be seeded into has still been checked for that.
|
|
275
|
+
if (key.startsWith('recursive:')) return true;
|
|
272
276
|
return sownTables.has(name) || (viewNames.has(name) && sown.seeded.length > 0);
|
|
273
277
|
});
|
|
274
278
|
|
|
@@ -310,6 +314,25 @@ async function scan(client, sourceSchema, options) {
|
|
|
310
314
|
});
|
|
311
315
|
}
|
|
312
316
|
|
|
317
|
+
// A rule that looks itself up. The attacks it stopped stay in the list of
|
|
318
|
+
// what was not tested - they did not run - but the loop is also a finding
|
|
319
|
+
// of its own: every signed-in request that touches the table fails, so the
|
|
320
|
+
// app is broken for the people using it. One finding per table at fault,
|
|
321
|
+
// named from Postgres's own message, however many reads ran into it.
|
|
322
|
+
const loops = new Map();
|
|
323
|
+
const loopedInto = (relation, table, who) => {
|
|
324
|
+
if (!loops.has(relation)) loops.set(relation, { kind: 'recursive', table: relation, reads: [], callers: [], columns: [] });
|
|
325
|
+
const entry = loops.get(relation);
|
|
326
|
+
if (!entry.reads.includes(table)) entry.reads.push(table);
|
|
327
|
+
if (!entry.callers.includes(who)) entry.callers.push(who);
|
|
328
|
+
};
|
|
329
|
+
for (const hit of impersonation.looped || []) loopedInto(hit.relation, hit.table, hit.who);
|
|
330
|
+
for (const stuck of writes.blocked) {
|
|
331
|
+
const relation = attack.recursionIn(stuck.why);
|
|
332
|
+
if (relation) loopedInto(relation, stuck.table, /^as anyone:/.test(stuck.why) ? 'anyone' : 'signed-in');
|
|
333
|
+
}
|
|
334
|
+
const recursive = Array.from(loops.values());
|
|
335
|
+
|
|
313
336
|
let collisions = { findings: [], notTried: [], raced: [] };
|
|
314
337
|
if (opts.openSession) {
|
|
315
338
|
say(' Racing two requests against each other ...');
|
|
@@ -362,7 +385,9 @@ async function scan(client, sourceSchema, options) {
|
|
|
362
385
|
// version recorded only the flagged ones, so a revoke made the function
|
|
363
386
|
// vanish from both lists and the fix came back as "could not confirm".
|
|
364
387
|
for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
|
|
365
|
-
|
|
388
|
+
// Quiet: a yes/no about the caller, and a function that turns the caller
|
|
389
|
+
// away before it does anything (schema.guardedAtTheDoor).
|
|
390
|
+
const privileged = (plan.anonFunctions || []).filter((fn) => fn.callable && !fn.aboutCaller && !fn.guarded).map((fn) => ({
|
|
366
391
|
kind: 'privileged',
|
|
367
392
|
fn: fn.name,
|
|
368
393
|
table: fn.name,
|
|
@@ -372,13 +397,24 @@ async function scan(client, sourceSchema, options) {
|
|
|
372
397
|
columns: [],
|
|
373
398
|
}));
|
|
374
399
|
|
|
400
|
+
// Whether each table is tied to Supabase's sign-in decides what its fix
|
|
401
|
+
// can be. Read from the original schema: the copy points auth.users keys
|
|
402
|
+
// at a stand-in, and would call every table untied.
|
|
403
|
+
const ties = finding.authTiesOf(plan);
|
|
404
|
+
// And whether a row says whose it is: with no such column, no rule can
|
|
405
|
+
// tie it to the person asking. Read from the copy, the way seeding did.
|
|
406
|
+
const owned = new Map(theirs.map((table) => [table.name, Boolean(attack.ownerColumn(table))]));
|
|
407
|
+
const tagged = (f) => Object.assign({}, f,
|
|
408
|
+
ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
|
|
409
|
+
owned.has(f.table) ? { owned: owned.get(f.table) } : {});
|
|
410
|
+
|
|
375
411
|
return {
|
|
376
412
|
stopped: null,
|
|
377
413
|
attacksRun: attempted.length,
|
|
378
414
|
notChecked: notChecked,
|
|
379
415
|
attempted: attempted,
|
|
380
416
|
findings: finding.describeAll(
|
|
381
|
-
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged),
|
|
417
|
+
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
|
|
382
418
|
),
|
|
383
419
|
};
|
|
384
420
|
} finally {
|
package/schema.js
CHANGED
|
@@ -206,6 +206,49 @@ async function readIndexes(client, schema) {
|
|
|
206
206
|
* re-check needs to know the function was looked at again and the grant was
|
|
207
207
|
* gone, which is different from the function no longer being there at all.
|
|
208
208
|
*/
|
|
209
|
+
/**
|
|
210
|
+
* Whether a function turns the caller away before it does anything.
|
|
211
|
+
*
|
|
212
|
+
* Found on a blind test: create_admin_export opens with
|
|
213
|
+
* if not public.is_org_admin(p_org_id) then raise exception ...
|
|
214
|
+
* and was reported as "anyone can call it, with full rights". A visitor with
|
|
215
|
+
* no account is not an admin of anything, so the first line refuses them and
|
|
216
|
+
* nothing else runs. That is a guard, and the function is examined, not
|
|
217
|
+
* reported.
|
|
218
|
+
*
|
|
219
|
+
* Only a refusal counts - IF NOT <check> THEN RAISE - and only one that comes
|
|
220
|
+
* before the first write and the first RETURN, because a check made after the
|
|
221
|
+
* work is done guards nothing. The check has to ask about the caller: a call
|
|
222
|
+
* to one of the schema's yes/no helpers that ask auth.uid(), or a condition
|
|
223
|
+
* of its own that asks auth.uid() whether they are an admin, owner or member.
|
|
224
|
+
* "Is anybody signed in at all" is not enough, and neither is a check on an
|
|
225
|
+
* argument the caller chose.
|
|
226
|
+
*/
|
|
227
|
+
function guardedAtTheDoor(body, helpers) {
|
|
228
|
+
const text = String(body || '');
|
|
229
|
+
const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
|
|
230
|
+
const returns = /\breturn\b/i.exec(text);
|
|
231
|
+
const firstAct = Math.min(writes ? writes.index : Infinity, returns ? returns.index : Infinity);
|
|
232
|
+
// The condition may not run past a THEN, or two IFs would be read as one;
|
|
233
|
+
// and END IF is not the start of one.
|
|
234
|
+
const guard = /(?<!\bend\s+)\bif\b((?:(?!\bthen\b)[\s\S])*?)\bthen\s+raise\b(?!\s+(?:notice|warning|info|log|debug)\b)/gi;
|
|
235
|
+
// The name as a call, bare or schema-qualified (public.is_org_admin), and
|
|
236
|
+
// never as the tail of a longer name.
|
|
237
|
+
const asks = (cond, name) => new RegExp('(^|[^\\w$"])"?' +
|
|
238
|
+
name.replace(/[$]/g, '\\$') + '"?\\s*\\(', 'i').test(cond);
|
|
239
|
+
let found;
|
|
240
|
+
while ((found = guard.exec(text)) !== null) {
|
|
241
|
+
if (found.index > firstAct) break;
|
|
242
|
+
const cond = found[1].trim();
|
|
243
|
+
// Refusing when the check fails, not when it passes.
|
|
244
|
+
const refusesOnNo = /^not\b/i.test(cond) || /\bis\s+(not\s+true|false)\s*$/i.test(cond) || /=\s*false\s*$/i.test(cond);
|
|
245
|
+
if (!refusesOnNo) continue;
|
|
246
|
+
if ((helpers || []).some((name) => asks(cond, name))) return true;
|
|
247
|
+
if (/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) return true;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
209
252
|
async function readAnonDefinerFunctions(client, schema, role) {
|
|
210
253
|
const caller = role || 'anon';
|
|
211
254
|
// A plain Postgres with no anon role has no anonymous caller at all, so
|
|
@@ -218,6 +261,7 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
218
261
|
pg_get_function_identity_arguments(p.oid) AS args,
|
|
219
262
|
p.proconfig::text AS config,
|
|
220
263
|
pg_get_functiondef(p.oid) AS def,
|
|
264
|
+
p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
|
|
221
265
|
${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable
|
|
222
266
|
FROM pg_proc p
|
|
223
267
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
@@ -227,6 +271,21 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
227
271
|
ORDER BY p.proname`,
|
|
228
272
|
exists.rows.length ? [schema, caller] : [schema],
|
|
229
273
|
);
|
|
274
|
+
// The schema's yes/no helpers about the caller - is_org_admin(org) asking
|
|
275
|
+
// whether auth.uid() is an admin of it - definer or not, since a guard can
|
|
276
|
+
// call either. Read once, for guardedAtTheDoor.
|
|
277
|
+
const { rows: yesNo } = await client.query(
|
|
278
|
+
`SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def
|
|
279
|
+
FROM pg_proc p
|
|
280
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
281
|
+
WHERE n.nspname = $1 AND p.prokind = 'f' AND p.prorettype = 'pg_catalog.bool'::regtype`,
|
|
282
|
+
[schema],
|
|
283
|
+
);
|
|
284
|
+
const helpers = yesNo.filter((row) => {
|
|
285
|
+
const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
|
|
286
|
+
return /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body) &&
|
|
287
|
+
!/\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
|
|
288
|
+
}).map((row) => row.name);
|
|
230
289
|
return rows.map((row) => {
|
|
231
290
|
// A definer function with no search_path pinned is a second, separate
|
|
232
291
|
// hazard: the caller can set their own search_path and make the function
|
|
@@ -238,11 +297,25 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
238
297
|
// could fool this, and that is acceptable: it never turns a non-finding
|
|
239
298
|
// into a finding, only "can read" into "can change".
|
|
240
299
|
const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
|
|
241
|
-
|
|
300
|
+
// `update\s+\w` followed by \b matched only a one-letter table name, so
|
|
301
|
+
// UPDATE was never seen - archive_notebook was worded as reading. The
|
|
302
|
+
// whole name, quoted or not, is matched now.
|
|
303
|
+
const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
|
|
304
|
+
// A yes/no about the caller alone - is_org_member(org) asking whether
|
|
305
|
+
// auth.uid() belongs to it. Found on a blind test, where two such helpers
|
|
306
|
+
// were reported as "anyone can call it, with full rights": they are how
|
|
307
|
+
// row level security avoids checking a table against itself, and to a
|
|
308
|
+
// visitor with no account they answer no. Reading nothing about anybody
|
|
309
|
+
// else and writing nothing, they are not a reach. A yes/no that does not
|
|
310
|
+
// ask auth.uid() - is_admin(user_id) - answers about other people, and
|
|
311
|
+
// stays a finding.
|
|
312
|
+
const aboutCaller = row.returns_bool === true && !writes && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
|
|
242
313
|
return {
|
|
243
314
|
name: row.name,
|
|
244
315
|
args: row.args || '',
|
|
245
316
|
writes: writes,
|
|
317
|
+
aboutCaller: aboutCaller,
|
|
318
|
+
guarded: guardedAtTheDoor(body, helpers),
|
|
246
319
|
hasFixedSearchPath: hasFixedSearchPath,
|
|
247
320
|
callable: row.callable === true,
|
|
248
321
|
};
|
|
@@ -1192,6 +1265,7 @@ function diffSchemas(source, copy) {
|
|
|
1192
1265
|
module.exports = {
|
|
1193
1266
|
readSchema: readSchema,
|
|
1194
1267
|
readAnonDefinerFunctions: readAnonDefinerFunctions,
|
|
1268
|
+
guardedAtTheDoor: guardedAtTheDoor,
|
|
1195
1269
|
writeSchema: writeSchema,
|
|
1196
1270
|
diffSchemas: diffSchemas,
|
|
1197
1271
|
readPolicies: readPolicies,
|
package/tamper.js
CHANGED
|
@@ -131,7 +131,13 @@ async function tamper(client, schema, tables, seeded, policies) {
|
|
|
131
131
|
// Written under a name nobody has used, which is both what makes the
|
|
132
132
|
// row land at all and what makes it the right test: adding a row of
|
|
133
133
|
// your own is the feature, adding one under somebody else name is not.
|
|
134
|
-
|
|
134
|
+
//
|
|
135
|
+
// Unless the owner is a key into one of the app's own tables - provider_id
|
|
136
|
+
// -> profiles.id - where only the two seeded people exist. Found on a
|
|
137
|
+
// blind test: the nameless insert hit the foreign key, and "any provider
|
|
138
|
+
// can add appointments for any other" came back as not tested. The other
|
|
139
|
+
// seeded person is still somebody else's name to the signed-in caller.
|
|
140
|
+
const row = await attack.rowFor(client, schema, table, writeAsFor(table, owner, tables), 7, null, shapeFor.get(table.name));
|
|
135
141
|
const placeholders = row.values.map((_, i) => '$' + (i + 1));
|
|
136
142
|
const attempts = [
|
|
137
143
|
row.columns.length
|
|
@@ -186,6 +192,18 @@ async function tamper(client, schema, tables, seeded, policies) {
|
|
|
186
192
|
return { findings: findings, completed: completed, blocked: blocked };
|
|
187
193
|
}
|
|
188
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Whose name an insert is written under: nobody's yet (USER_C), unless the
|
|
197
|
+
* owner column is a key into one of the app's own tables, where only the two
|
|
198
|
+
* seeded people exist - then the one who is not the signed-in caller.
|
|
199
|
+
*/
|
|
200
|
+
function writeAsFor(table, owner, tables) {
|
|
201
|
+
if (!owner) return attack.USER_C;
|
|
202
|
+
const key = attack.foreignKeys(table).find((k) => k.columns.length === 1 && k.columns[0] === owner);
|
|
203
|
+
const inApp = key && (tables || []).some((t) => t.name === key.refTable);
|
|
204
|
+
return inApp ? attack.USER_A : attack.USER_C;
|
|
205
|
+
}
|
|
206
|
+
|
|
189
207
|
/**
|
|
190
208
|
* A column an UPDATE can harmlessly set to itself.
|
|
191
209
|
*
|
|
@@ -204,5 +222,6 @@ module.exports = {
|
|
|
204
222
|
tryWrite: tryWrite,
|
|
205
223
|
whatHappened: whatHappened,
|
|
206
224
|
firstWritable: firstWritable,
|
|
225
|
+
writeAsFor: writeAsFor,
|
|
207
226
|
tamper: tamper,
|
|
208
227
|
};
|