kryptheon-night 0.1.8 → 0.1.10
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 +46 -0
- package/bin/kryptheon-night.js +4 -1
- package/engine.sql +46 -1
- package/finding.js +344 -42
- package/package.json +4 -2
- package/recheck.js +45 -4
- package/scan.js +69 -6
- package/schema.js +1210 -1137
- package/store.js +15 -0
- 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');
|
package/bin/kryptheon-night.js
CHANGED
|
@@ -561,12 +561,15 @@ async function main() {
|
|
|
561
561
|
if (!before) {
|
|
562
562
|
scanner.report(result);
|
|
563
563
|
scanner.saveRun(file, result);
|
|
564
|
-
process.exitCode =
|
|
564
|
+
process.exitCode = scanner.exitCodeFor(result);
|
|
565
565
|
} else {
|
|
566
566
|
const verdict = recheck.compare(before, result);
|
|
567
567
|
recheck.describe(verdict).forEach(line);
|
|
568
568
|
recheck.badgeLines(verdict, result.attacksRun || 0).forEach(line);
|
|
569
|
+
// The report ends with what was not tested; with nothing left to report
|
|
570
|
+
// that list still has to be printed, or the re-check goes quiet about it.
|
|
569
571
|
if (result.findings.length) scanner.report(result);
|
|
572
|
+
else scanner.notTestedLines(result).forEach(line);
|
|
570
573
|
scanner.saveRun(file, result);
|
|
571
574
|
process.exitCode = result.stopped ? 2 : verdict.allClear ? 0 : 1;
|
|
572
575
|
}
|
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
|
|
@@ -1862,6 +1885,25 @@ END $$;
|
|
|
1862
1885
|
* `seeded` carries the shape of row that worked when the table was seeded, so
|
|
1863
1886
|
* the insert here is not rejected by some CHECK the seeder already solved.
|
|
1864
1887
|
*/
|
|
1888
|
+
/*
|
|
1889
|
+
* Whose name an insert is written under: nobody's yet (user C), unless the
|
|
1890
|
+
* owner column is a key into one of the app's own tables, where only the two
|
|
1891
|
+
* seeded people exist - then the one who is not the signed-in caller.
|
|
1892
|
+
*/
|
|
1893
|
+
CREATE OR REPLACE FUNCTION __KN__.write_as_for(tab jsonb, owner text, tables jsonb)
|
|
1894
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1895
|
+
BEGIN
|
|
1896
|
+
IF owner IS NOT NULL AND EXISTS (
|
|
1897
|
+
SELECT 1 FROM jsonb_array_elements(__KN__.foreign_keys(tab)) k
|
|
1898
|
+
WHERE jsonb_array_length(k->'columns') = 1 AND k->'columns'->>0 = owner
|
|
1899
|
+
AND EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(tables, '[]'::jsonb)) t
|
|
1900
|
+
WHERE t->>'name' = k->>'refTable'))
|
|
1901
|
+
THEN
|
|
1902
|
+
RETURN __KN__.user_a();
|
|
1903
|
+
END IF;
|
|
1904
|
+
RETURN __KN__.user_c();
|
|
1905
|
+
END $$;
|
|
1906
|
+
|
|
1865
1907
|
CREATE OR REPLACE FUNCTION __KN__.tamper(source text, tables jsonb, seeded jsonb,
|
|
1866
1908
|
policies jsonb DEFAULT '[]'::jsonb)
|
|
1867
1909
|
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
@@ -1925,7 +1967,10 @@ BEGIN
|
|
|
1925
1967
|
-- Written under a name nobody has used, which is both what makes the row
|
|
1926
1968
|
-- land at all and what makes it the right test: adding a row of your own
|
|
1927
1969
|
-- is the feature, adding one under somebody else's name is not.
|
|
1928
|
-
|
|
1970
|
+
-- Unless the owner is a key into one of the app's own tables, where only
|
|
1971
|
+
-- the two seeded people exist: then the one who is not the signed-in
|
|
1972
|
+
-- caller. Found on a blind test, where the nameless insert hit the key.
|
|
1973
|
+
row_ := __KN__.row_for(source, tab, __KN__.write_as_for(tab, owner, tables), '7', NULL, shape);
|
|
1929
1974
|
|
|
1930
1975
|
-- Every statement written out before anybody changes role.
|
|
1931
1976
|
moves := jsonb_build_array(
|
package/finding.js
CHANGED
|
@@ -72,6 +72,14 @@ function listOf(items) {
|
|
|
72
72
|
* should be fixed - there is no such thing as a low finding here.
|
|
73
73
|
*/
|
|
74
74
|
function severityOf(finding, contents) {
|
|
75
|
+
if (finding.kind === 'privileged') {
|
|
76
|
+
// Not proven, so never CRITICAL: this is a reach that exists, not a break
|
|
77
|
+
// that was demonstrated. A function anon can call that also writes is worth
|
|
78
|
+
// more attention than one that only reads, but both stay HIGH and both
|
|
79
|
+
// carry "verification required" - the person has to say whether the anon
|
|
80
|
+
// call was intended.
|
|
81
|
+
return 'HIGH';
|
|
82
|
+
}
|
|
75
83
|
if (finding.kind === 'orphaned') {
|
|
76
84
|
// Nothing is exposed and nothing is destroyed, so this is not the same
|
|
77
85
|
// order of thing as a table anyone can empty. It stays serious because
|
|
@@ -90,7 +98,13 @@ function severityOf(finding, contents) {
|
|
|
90
98
|
// the table. A second row holding the same session token is critical even
|
|
91
99
|
// if the table has nothing personal in it at all.
|
|
92
100
|
if (finding.expectation === 'credential') return 'CRITICAL';
|
|
93
|
-
|
|
101
|
+
// Critical only while this column could be the login itself. When the
|
|
102
|
+
// database has Supabase's auth.users, sign-in goes through that table,
|
|
103
|
+
// which keeps every email unique - so a duplicate here is a copy that
|
|
104
|
+
// disagrees with itself, not two accounts sharing one login. Found on a
|
|
105
|
+
// real app: the report said "two accounts can answer to the same login"
|
|
106
|
+
// about a profiles table the login never read.
|
|
107
|
+
if (finding.expectation === 'identity') return finding.loginElsewhere ? 'HIGH' : 'CRITICAL';
|
|
94
108
|
return 'HIGH';
|
|
95
109
|
}
|
|
96
110
|
if (contents.secrets.length) return 'CRITICAL';
|
|
@@ -247,6 +261,9 @@ function headlineFor(finding) {
|
|
|
247
261
|
if (finding.kind === 'duplicated') {
|
|
248
262
|
return 'Your ' + finding.table + ' table lets the same ' + finding.column + ' exist twice.';
|
|
249
263
|
}
|
|
264
|
+
if (finding.kind === 'privileged') {
|
|
265
|
+
return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
|
|
266
|
+
}
|
|
250
267
|
if (finding.kind === 'exposed') {
|
|
251
268
|
return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
|
|
252
269
|
}
|
|
@@ -263,6 +280,17 @@ const COST_OF_A_DUPLICATE = {
|
|
|
263
280
|
code: 'A one-time code that can exist twice can be redeemed twice.',
|
|
264
281
|
};
|
|
265
282
|
|
|
283
|
+
// Said instead of the login sentence when sign-in happens in auth.users.
|
|
284
|
+
const COST_OF_A_COPIED_IDENTITY =
|
|
285
|
+
'Signing in is not affected: your app signs people in through auth.users, ' +
|
|
286
|
+
'which keeps each email unique. But anything that looks a person up by this ' +
|
|
287
|
+
'column can get two rows back and pick the wrong one.';
|
|
288
|
+
|
|
289
|
+
function costOf(finding) {
|
|
290
|
+
if (finding.expectation === 'identity' && finding.loginElsewhere) return COST_OF_A_COPIED_IDENTITY;
|
|
291
|
+
return COST_OF_A_DUPLICATE[finding.expectation] || '';
|
|
292
|
+
}
|
|
293
|
+
|
|
266
294
|
function bodyFor(finding, contents) {
|
|
267
295
|
const holds = listOf(contents.secrets.concat(contents.identity, contents.money));
|
|
268
296
|
const rowWord = finding.readable === 1 ? 'row' : 'rows';
|
|
@@ -300,14 +328,32 @@ function bodyFor(finding, contents) {
|
|
|
300
328
|
'kept, and your live app was never touched.';
|
|
301
329
|
}
|
|
302
330
|
|
|
331
|
+
if (finding.kind === 'privileged') {
|
|
332
|
+
// Said as what is true, not as what was done - nothing was executed. The
|
|
333
|
+
// reach is real; whether it is a mistake is the person's to say, so the
|
|
334
|
+
// wording asks rather than accuses.
|
|
335
|
+
return (
|
|
336
|
+
'This function is SECURITY DEFINER, so it runs as whoever created it, not ' +
|
|
337
|
+
'as the visitor calling it - which means the row level security on the tables ' +
|
|
338
|
+
'it touches is checked against the owner and skipped for the caller. It is ' +
|
|
339
|
+
'granted to anon, the role a visitor with no account uses, so anyone on the ' +
|
|
340
|
+
'internet can call it' +
|
|
341
|
+
(finding.writes ? ' - and it writes to your data, so a caller can change rows the rules would otherwise protect.'
|
|
342
|
+
: ', reaching whatever it reads past the rules on those tables.') +
|
|
343
|
+
' I did not call it: whether an open function like this is intended is ' +
|
|
344
|
+
'something only you can confirm.' +
|
|
345
|
+
(finding.hasFixedSearchPath ? ''
|
|
346
|
+
: ' It also does not pin its search_path, so a caller can point the names ' +
|
|
347
|
+
'inside it at their own objects.')
|
|
348
|
+
);
|
|
349
|
+
}
|
|
303
350
|
if (finding.kind === 'duplicated') {
|
|
304
351
|
// Said as what was done, not as what it implies. Two connections, one
|
|
305
352
|
// moment, both accepted, and here is the count afterwards.
|
|
306
353
|
return (
|
|
307
354
|
'I opened two connections to a copy of your app and inserted the same ' +
|
|
308
355
|
finding.column + ' from both at the same moment. Both were accepted, so there are ' +
|
|
309
|
-
'now ' + finding.copies + ' rows holding the identical value. ' +
|
|
310
|
-
(COST_OF_A_DUPLICATE[finding.expectation] || '')
|
|
356
|
+
'now ' + finding.copies + ' rows holding the identical value. ' + costOf(finding)
|
|
311
357
|
).trim();
|
|
312
358
|
}
|
|
313
359
|
|
|
@@ -364,11 +410,146 @@ function wrapTo(text, width) {
|
|
|
364
410
|
return out;
|
|
365
411
|
}
|
|
366
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Whether the app signs people in through Supabase: any foreign key to
|
|
415
|
+
* auth.users, or any rule that asks auth.uid() or auth.jwt(). Decided for the
|
|
416
|
+
* whole app, not table by table. Found on a blind test: appointments points
|
|
417
|
+
* at profiles, which points at auth.users - no key of its own and no rule
|
|
418
|
+
* asking auth.uid(), so it was told the app "signs people in its own way"
|
|
419
|
+
* when it plainly used Supabase's. Read from the original schema, not the
|
|
420
|
+
* copy - the copy points those keys at a stand-in. A table missing from the
|
|
421
|
+
* map is unknown, which the prompts treat as tied.
|
|
422
|
+
*/
|
|
423
|
+
function authTiesOf(plan) {
|
|
424
|
+
const tables = (plan && plan.tables) || [];
|
|
425
|
+
const keyed = tables.some((table) => (table.constraints || []).some((c) =>
|
|
426
|
+
c.kind === 'f' && /references\s+"?auth"?\s*\.\s*"?users"?\s*\(/i.test(String(c.definition || ''))));
|
|
427
|
+
const asked = ((plan && plan.policies) || []).some((policy) =>
|
|
428
|
+
/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(String(policy.qual || '') + ' ' + String(policy.with_check || '')));
|
|
429
|
+
return new Map(tables.map((table) => [table.name, keyed || asked]));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The fix for a table no rule can tie to the signed-in user, or null when one
|
|
434
|
+
* can. Two ways that happens. The app has its own login: found on a real app,
|
|
435
|
+
* "compare the owner to the id of the signed-in user" cannot be done there -
|
|
436
|
+
* auth.uid() is empty for everybody - and an assistant that writes it anyway
|
|
437
|
+
* locks the app out of its own table. Or the table has no column saying whose
|
|
438
|
+
* row it is - a waitlist, a contact form - so there is nothing to compare.
|
|
439
|
+
*/
|
|
440
|
+
function serverOnlyLines(finding) {
|
|
441
|
+
const table = finding.table;
|
|
442
|
+
let why;
|
|
443
|
+
if (finding.authTied === false) {
|
|
444
|
+
why = 'Nothing in "' + table + '" points at auth.users and no rule on it uses auth.uid(), so your ' +
|
|
445
|
+
'app signs people in its own way. A rule comparing a column to the signed-in user cannot ' +
|
|
446
|
+
'work here - auth.uid() is empty for everyone - and adding one would lock your own app out.';
|
|
447
|
+
} else if (finding.owned === false) {
|
|
448
|
+
why = '"' + table + '" has no column that says whose row it is, so a rule tying rows to the ' +
|
|
449
|
+
'signed-in user has nothing to compare against.';
|
|
450
|
+
} else {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
return [
|
|
454
|
+
why,
|
|
455
|
+
'',
|
|
456
|
+
'If only your server reads and writes this table: keep row level security on, drop the rules ' +
|
|
457
|
+
'that let everyone in, and add none for anon or authenticated. Have your server use the ' +
|
|
458
|
+
'service_role key, which skips these rules - and keep that key on the server only, never ' +
|
|
459
|
+
'in the browser or in the app\'s public code.',
|
|
460
|
+
'',
|
|
461
|
+
'If the browser needs to read or write part of it directly, tell me which part first, so we ' +
|
|
462
|
+
'write the narrowest rule for exactly that.',
|
|
463
|
+
'',
|
|
464
|
+
];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* One prompt for everything wrong with one table's rules - read and write,
|
|
469
|
+
* strangers and customers - because it is one rule to replace, not four.
|
|
470
|
+
*/
|
|
471
|
+
function groupPromptFor(members) {
|
|
472
|
+
const lead = members.find((m) => m.kind === 'writable') || members[0];
|
|
473
|
+
const cause = causeOf(lead);
|
|
474
|
+
const table = lead.table;
|
|
475
|
+
const owner = (members.find((m) => m.owner) || {}).owner;
|
|
476
|
+
const ownerWords = owner ? '"' + owner + '"' : 'the column that says who each row belongs to';
|
|
477
|
+
const read = members.find((m) => m.kind === 'exposed');
|
|
478
|
+
const writers = members.filter((m) => m.kind === 'writable');
|
|
479
|
+
const anyoneWrites = writers.find((w) => w.who === 'anyone');
|
|
480
|
+
const customersWrite = writers.find((w) => w.who !== 'anyone');
|
|
481
|
+
const what = [];
|
|
482
|
+
if (read) what.push('read');
|
|
483
|
+
if (writers.length) what.push('written');
|
|
484
|
+
const who = read || anyoneWrites ? 'anyone who is not logged in' : 'any signed-in user, including other users\' rows';
|
|
485
|
+
const did = [];
|
|
486
|
+
if (read) did.push('read ' + read.readable + ' ' + (read.readable === 1 ? 'row' : 'rows'));
|
|
487
|
+
const moves = [];
|
|
488
|
+
for (const w of writers) for (const m of w.can || []) if (!moves.includes(m)) moves.push(m);
|
|
489
|
+
for (const m of ['add', 'change', 'delete']) {
|
|
490
|
+
if (moves.includes(m)) did.push({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' }[m]);
|
|
491
|
+
}
|
|
492
|
+
const lines = [
|
|
493
|
+
'My app has a security problem.',
|
|
494
|
+
'',
|
|
495
|
+
'The "' + table + '" table can be ' + what.join(' and ') + ' by ' + who +
|
|
496
|
+
', because ' + cause.short + '. I proved it on a copy: I ' + listOf(did) + '.' +
|
|
497
|
+
(anyoneWrites && customersWrite ? ' Signed-in users can do the same.' : ''),
|
|
498
|
+
'',
|
|
499
|
+
].concat(
|
|
500
|
+
serverOnlyLines(lead) || [
|
|
501
|
+
'Switch row level security on for this table, remove the rule that lets everyone in, and ' +
|
|
502
|
+
'write separate rules for reading, inserting, updating and deleting. A rule written FOR ALL ' +
|
|
503
|
+
'covers far more than reading.',
|
|
504
|
+
'',
|
|
505
|
+
'Each of them should compare ' + ownerWords + ' to the id of the signed-in user (auth.uid()): ' +
|
|
506
|
+
'USING for reading, updating and deleting, WITH CHECK for inserting and updating, so nobody ' +
|
|
507
|
+
'reads, changes or writes a row under somebody else\'s name. Give logged-out visitors no rule ' +
|
|
508
|
+
'at all unless they truly need one.',
|
|
509
|
+
'',
|
|
510
|
+
],
|
|
511
|
+
['Then check every other table for the same thing.'],
|
|
512
|
+
);
|
|
513
|
+
return lines
|
|
514
|
+
.map((paragraph) => (paragraph ? wrapTo(paragraph, 72) : ['']))
|
|
515
|
+
.reduce((all, part) => all.concat(part), [])
|
|
516
|
+
.join('\n');
|
|
517
|
+
}
|
|
518
|
+
|
|
367
519
|
function fixPromptFor(finding) {
|
|
368
520
|
const cause = causeOf(finding);
|
|
369
521
|
const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
|
|
370
522
|
|
|
371
|
-
const lines = finding.kind === '
|
|
523
|
+
const lines = finding.kind === 'privileged'
|
|
524
|
+
? [
|
|
525
|
+
'My app may have a security problem - please check it rather than assume it.',
|
|
526
|
+
'',
|
|
527
|
+
'The database function "' + finding.fn + '(' + (finding.args || '') + ')" is SECURITY ' +
|
|
528
|
+
'DEFINER, so it runs with its owner\'s rights and ignores row level security. It is ' +
|
|
529
|
+
'granted EXECUTE to "anon", so anyone who is not logged in can call it' +
|
|
530
|
+
(finding.writes ? ', and it writes to tables.' : '.'),
|
|
531
|
+
'',
|
|
532
|
+
'First tell me: is it meant to be callable by logged-out visitors? If it is not, ' +
|
|
533
|
+
'REVOKE EXECUTE ON FUNCTION "' + finding.fn + '"(' + (finding.args || '') + ') FROM anon, PUBLIC, ' +
|
|
534
|
+
'and GRANT EXECUTE only to the role that should run it - usually service_role, the ' +
|
|
535
|
+
'backend key, the way an admin-only function like this should be reached.',
|
|
536
|
+
'',
|
|
537
|
+
].concat(
|
|
538
|
+
finding.hasFixedSearchPath
|
|
539
|
+
? []
|
|
540
|
+
: [
|
|
541
|
+
'It also does not set a fixed search_path. Add SET search_path = pg_catalog, public (or ' +
|
|
542
|
+
'the schemas it truly needs) so a caller cannot make it resolve names to their own objects.',
|
|
543
|
+
'',
|
|
544
|
+
],
|
|
545
|
+
[
|
|
546
|
+
'If it is meant to be public, keep it, but make sure everything inside it checks what the ' +
|
|
547
|
+
'caller is allowed to see or change itself - the table rules will not do it here.',
|
|
548
|
+
'',
|
|
549
|
+
'Then look at every other SECURITY DEFINER function for the same grant.',
|
|
550
|
+
],
|
|
551
|
+
)
|
|
552
|
+
: finding.kind === 'orphaned'
|
|
372
553
|
? [
|
|
373
554
|
'My app has a data problem.',
|
|
374
555
|
'',
|
|
@@ -399,17 +580,24 @@ function fixPromptFor(finding) {
|
|
|
399
580
|
(finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
|
|
400
581
|
.join(', ') + '.',
|
|
401
582
|
'',
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
583
|
+
].concat(
|
|
584
|
+
serverOnlyLines(finding) || [
|
|
585
|
+
'Switch row level security on for this table, then write separate rules for ' +
|
|
586
|
+
'reading, inserting, updating and deleting. A rule written FOR SELECT does ' +
|
|
587
|
+
'not cover writes, and a rule written FOR ALL covers far more than reading.',
|
|
588
|
+
'',
|
|
589
|
+
'For insert and update, use WITH CHECK comparing ' + owner +
|
|
590
|
+
' to the id of the signed-in user, so nobody can write a row under somebody ' +
|
|
591
|
+
"else's name. For update and delete, add USING with the same comparison: " +
|
|
592
|
+
'with USING (true) left on the update, anyone can take a row that is not ' +
|
|
593
|
+
'theirs and rewrite it as their own.',
|
|
594
|
+
'',
|
|
595
|
+
],
|
|
596
|
+
[
|
|
597
|
+
'Then check every other table for the same thing - a table with no row level ' +
|
|
598
|
+
'security on it is writable by default.',
|
|
599
|
+
],
|
|
600
|
+
)
|
|
413
601
|
: finding.kind === 'duplicated'
|
|
414
602
|
? [
|
|
415
603
|
'My app has a security problem.',
|
|
@@ -418,17 +606,50 @@ function fixPromptFor(finding) {
|
|
|
418
606
|
'". I proved it by inserting the same value from two connections at the same ' +
|
|
419
607
|
'moment, and both were accepted.',
|
|
420
608
|
'',
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
609
|
+
// First, because it has to happen before anything is changed. Learned
|
|
610
|
+
// by applying the short version of this prompt literally to a real app:
|
|
611
|
+
// the constraint cannot be created while duplicates exist, and an
|
|
612
|
+
// assistant told only "add a constraint" may delete rows to get there.
|
|
613
|
+
'First, find the rows that already share a value and show them to me. Do not ' +
|
|
614
|
+
'delete or merge any of them without asking me: the rule below cannot be ' +
|
|
615
|
+
'created while they exist, and deleting rows to make it pass loses real data.',
|
|
616
|
+
'',
|
|
617
|
+
(finding.expectation === 'identity'
|
|
618
|
+
? 'Then add a unique index on lower("' + finding.column + '"), so that "' + finding.table +
|
|
619
|
+
'"."' + finding.column + '" is unique whatever the case - Ann@example.com and ' +
|
|
620
|
+
'ann@example.com are the same person.'
|
|
621
|
+
: 'Then add a unique constraint on "' + finding.table + '"."' + finding.column + '".') +
|
|
622
|
+
' It has to be in the database itself. Checking in application code before inserting ' +
|
|
623
|
+
'is not enough - between the check and the insert, the other request has already gone in.',
|
|
424
624
|
'',
|
|
425
625
|
// Said because the obvious fix is wrong for multi-tenant apps, and being
|
|
426
626
|
// told to drop a legitimate design would cost them more than the bug.
|
|
427
|
-
'If the same value is allowed to repeat for different owners, make
|
|
428
|
-
'
|
|
627
|
+
'If the same value is allowed to repeat for different owners, make it cover both ' +
|
|
628
|
+
'columns together rather than leaving it off.',
|
|
429
629
|
'',
|
|
430
|
-
|
|
431
|
-
|
|
630
|
+
].concat(
|
|
631
|
+
[
|
|
632
|
+
'Find everything that writes this column - app code, and any database trigger or ' +
|
|
633
|
+
'function, such as one that copies each new sign-up into this table - and make ' +
|
|
634
|
+
'sure each one handles "this value already exists" instead of failing. If people ' +
|
|
635
|
+
'can edit this column themselves, decide whether they should: once it is unique, ' +
|
|
636
|
+
'someone who puts another person\'s value in first will block that person.',
|
|
637
|
+
'',
|
|
638
|
+
],
|
|
639
|
+
finding.expectation === 'identity' && finding.loginElsewhere
|
|
640
|
+
? [
|
|
641
|
+
'Sign-in uses auth.users, so this column is only a copy of the email there. ' +
|
|
642
|
+
'Consider not letting people edit it at all and keeping it in step with auth.users.',
|
|
643
|
+
'',
|
|
644
|
+
]
|
|
645
|
+
: [],
|
|
646
|
+
[
|
|
647
|
+
'Afterwards, sign up a brand-new account and edit an existing profile to make sure ' +
|
|
648
|
+
'both still work, and run this check again.',
|
|
649
|
+
'',
|
|
650
|
+
'Then look for the same missing constraint on every other table and fix those too.',
|
|
651
|
+
],
|
|
652
|
+
)
|
|
432
653
|
: finding.isView
|
|
433
654
|
? [
|
|
434
655
|
'My app has a security problem.',
|
|
@@ -451,11 +672,14 @@ function fixPromptFor(finding) {
|
|
|
451
672
|
? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
|
|
452
673
|
: 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
|
|
453
674
|
'',
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
675
|
+
].concat(
|
|
676
|
+
serverOnlyLines(finding) || [
|
|
677
|
+
'Fix it so a person can only read their own rows: compare ' + owner +
|
|
678
|
+
' against the id of the signed-in user, and make sure logged-out visitors get nothing.',
|
|
679
|
+
'',
|
|
680
|
+
],
|
|
681
|
+
['Then look for the same mistake on every other table and fix those too.'],
|
|
682
|
+
);
|
|
459
683
|
// Wrapped here rather than by whoever prints it: this text is pasted into a
|
|
460
684
|
// chat box as often as it is read in a terminal, and an unwrapped paragraph
|
|
461
685
|
// is a wall in both.
|
|
@@ -471,7 +695,15 @@ function describe(finding) {
|
|
|
471
695
|
const cause = causeOf(finding);
|
|
472
696
|
return {
|
|
473
697
|
severity: severityOf(finding, contents),
|
|
474
|
-
|
|
698
|
+
// Every other finding here was proven by an attack that ran; this is the
|
|
699
|
+
// one the tool reasons about without executing, so it says so. The report
|
|
700
|
+
// and the re-check both read this: a "verification required" finding is
|
|
701
|
+
// never counted towards a clean re-check on its own.
|
|
702
|
+
status: finding.kind === 'privileged' ? 'verification required' : 'confirmed',
|
|
703
|
+
table: finding.kind === 'privileged' ? finding.fn : finding.table,
|
|
704
|
+
fn: finding.fn,
|
|
705
|
+
args: finding.args,
|
|
706
|
+
writes: finding.writes,
|
|
475
707
|
kind: finding.kind,
|
|
476
708
|
// Carried through because a table can have two different columns that each
|
|
477
709
|
// accept a duplicate, and the re-check tells one finding from another by
|
|
@@ -483,10 +715,15 @@ function describe(finding) {
|
|
|
483
715
|
expectation: finding.expectation,
|
|
484
716
|
headline: headlineFor(finding, contents),
|
|
485
717
|
body: bodyFor(finding, contents),
|
|
486
|
-
|
|
718
|
+
// For a privileged function there is no "why the door is open" the way a
|
|
719
|
+
// table has one; the body already carries the whole explanation, so the
|
|
720
|
+
// cause line would only repeat it. Left empty and skipped by the report.
|
|
721
|
+
cause: finding.kind === 'privileged' ? '' : cause.long,
|
|
487
722
|
who: finding.who,
|
|
488
723
|
can: finding.can,
|
|
489
|
-
proof: finding.kind === '
|
|
724
|
+
proof: finding.kind === 'privileged'
|
|
725
|
+
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
726
|
+
: finding.kind === 'duplicated'
|
|
490
727
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
491
728
|
finding.column + '.'
|
|
492
729
|
: finding.countOnly
|
|
@@ -499,6 +736,50 @@ function describe(finding) {
|
|
|
499
736
|
};
|
|
500
737
|
}
|
|
501
738
|
|
|
739
|
+
/** Everything wrong with one table's rules, said once. */
|
|
740
|
+
function mergeTable(group, one) {
|
|
741
|
+
const members = group.map(one);
|
|
742
|
+
const read = group.find((m) => m.kind === 'exposed');
|
|
743
|
+
const anyoneWrites = group.find((m) => m.kind === 'writable' && m.who === 'anyone');
|
|
744
|
+
const customersWrite = group.find((m) => m.kind === 'writable' && m.who !== 'anyone');
|
|
745
|
+
const lead = anyoneWrites || customersWrite || read;
|
|
746
|
+
const leadItem = members[group.indexOf(lead)];
|
|
747
|
+
const table = lead.table;
|
|
748
|
+
const order = ['delete', 'change', 'add'];
|
|
749
|
+
const does = (w) => order.filter((m) => (w.can || []).includes(m)).map((m) => WRITE_WORDS[m]);
|
|
750
|
+
let headline;
|
|
751
|
+
if (read && anyoneWrites) {
|
|
752
|
+
const verbs = ['read'].concat(order.filter((m) => (anyoneWrites.can || []).includes(m)));
|
|
753
|
+
headline = 'Anyone can ' + listOf(verbs) + ' rows in your ' + table + ' table.';
|
|
754
|
+
} else if (read && customersWrite) {
|
|
755
|
+
headline = 'Anyone can read your ' + table + ' table, and any signed-in customer can ' + listOf(does(customersWrite)) + ' it.';
|
|
756
|
+
} else {
|
|
757
|
+
headline = leadItem.headline;
|
|
758
|
+
}
|
|
759
|
+
// The customers' part is said in one line when strangers can already do it.
|
|
760
|
+
const bodies = [];
|
|
761
|
+
for (let i = 0; i < group.length; i++) {
|
|
762
|
+
if (group[i] === customersWrite && anyoneWrites) continue;
|
|
763
|
+
bodies.push(members[i].body);
|
|
764
|
+
}
|
|
765
|
+
if (customersWrite && anyoneWrites) bodies.push('Signed-in customers can do the same.');
|
|
766
|
+
const severity = members.some((m) => m.severity === 'CRITICAL') ? 'CRITICAL' : leadItem.severity;
|
|
767
|
+
const can = [];
|
|
768
|
+
for (const m of group) for (const c of m.can || []) if (!can.includes(c)) can.push(c);
|
|
769
|
+
return Object.assign({}, leadItem, {
|
|
770
|
+
severity: severity,
|
|
771
|
+
status: 'confirmed',
|
|
772
|
+
kind: lead.kind,
|
|
773
|
+
who: read || anyoneWrites ? 'anyone' : leadItem.who,
|
|
774
|
+
can: can,
|
|
775
|
+
headline: headline,
|
|
776
|
+
body: bodies.join(' '),
|
|
777
|
+
proof: members.map((m) => m.proof).join(' '),
|
|
778
|
+
fixPrompt: groupPromptFor(group),
|
|
779
|
+
members: members,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
502
783
|
/** The whole report, in the order a person should read it. */
|
|
503
784
|
function describeAll(findings) {
|
|
504
785
|
// A table anyone can read is also, necessarily, a table one customer can read
|
|
@@ -507,24 +788,44 @@ function describeAll(findings) {
|
|
|
507
788
|
// is kept, and it carries the rest.
|
|
508
789
|
const raw = findings || [];
|
|
509
790
|
const publiclyOpen = new Set(raw.filter((f) => f.kind === 'exposed').map((f) => f.table));
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
791
|
+
const kept = raw.filter((f) => !(f.kind === 'crossed' && publiclyOpen.has(f.table)));
|
|
792
|
+
const one = (f) => {
|
|
793
|
+
const item = Object.assign(describe(f), {
|
|
794
|
+
alsoCrossed: f.kind === 'exposed' && raw.some((o) => o.kind === 'crossed' && o.table === f.table),
|
|
795
|
+
});
|
|
796
|
+
if (item.alsoCrossed) item.body += ' Your signed-in customers can read each other\'s rows for the same reason.';
|
|
797
|
+
return item;
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
// One table, one finding. A single rule written FOR ALL USING (true) opens a
|
|
801
|
+
// table to reading and writing, for strangers and customers alike - four
|
|
802
|
+
// proofs of one mistake. Listed as four, a real app's report showed six
|
|
803
|
+
// CRITICAL findings for two rules, and the person reads six problems where
|
|
804
|
+
// there are two to fix. The proofs are kept as members, so a re-check can
|
|
805
|
+
// still tell "strangers can no longer write" from "strangers can still read".
|
|
806
|
+
const grouped = new Map();
|
|
807
|
+
for (const f of kept) {
|
|
808
|
+
if ((f.kind !== 'exposed' && f.kind !== 'writable') || f.isView || f.countOnly) continue;
|
|
809
|
+
if (!grouped.has(f.table)) grouped.set(f.table, []);
|
|
810
|
+
grouped.get(f.table).push(f);
|
|
811
|
+
}
|
|
812
|
+
const described = [];
|
|
813
|
+
const done = new Set();
|
|
814
|
+
for (const f of kept) {
|
|
815
|
+
const group = grouped.get(f.table);
|
|
816
|
+
if (!group || group.length < 2 || group.indexOf(f) === -1) {
|
|
817
|
+
described.push(one(f));
|
|
818
|
+
continue;
|
|
521
819
|
}
|
|
820
|
+
if (done.has(f.table)) continue;
|
|
821
|
+
done.add(f.table);
|
|
822
|
+
described.push(mergeTable(group, one));
|
|
522
823
|
}
|
|
523
824
|
// Worst first, and within that the ones open to the whole internet before the
|
|
524
825
|
// ones that need an account, and those before the ones that need two requests
|
|
525
826
|
// to arrive together.
|
|
526
827
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
527
|
-
const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4 };
|
|
828
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, duplicated: 3, orphaned: 4, privileged: 5 };
|
|
528
829
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
529
830
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
530
831
|
}
|
|
@@ -557,6 +858,7 @@ function allClearLines(attacksRun) {
|
|
|
557
858
|
}
|
|
558
859
|
|
|
559
860
|
module.exports = {
|
|
861
|
+
authTiesOf: authTiesOf,
|
|
560
862
|
readContents: readContents,
|
|
561
863
|
severityOf: severityOf,
|
|
562
864
|
causeOf: causeOf,
|