kryptheon-night 0.1.9 → 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/engine.sql +46 -1
- package/finding.js +208 -27
- package/package.json +1 -1
- package/recheck.js +30 -4
- package/scan.js +12 -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');
|
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
|
@@ -410,6 +410,112 @@ function wrapTo(text, width) {
|
|
|
410
410
|
return out;
|
|
411
411
|
}
|
|
412
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
|
+
|
|
413
519
|
function fixPromptFor(finding) {
|
|
414
520
|
const cause = causeOf(finding);
|
|
415
521
|
const owner = finding.owner ? '"' + finding.owner + '"' : 'the column that says who each row belongs to';
|
|
@@ -474,17 +580,24 @@ function fixPromptFor(finding) {
|
|
|
474
580
|
(finding.can || []).map((what) => ({ add: 'added a row', change: 'changed rows', delete: 'deleted rows' })[what])
|
|
475
581
|
.join(', ') + '.',
|
|
476
582
|
'',
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
+
)
|
|
488
601
|
: finding.kind === 'duplicated'
|
|
489
602
|
? [
|
|
490
603
|
'My app has a security problem.',
|
|
@@ -559,11 +672,14 @@ function fixPromptFor(finding) {
|
|
|
559
672
|
? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
|
|
560
673
|
: 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
|
|
561
674
|
'',
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
+
);
|
|
567
683
|
// Wrapped here rather than by whoever prints it: this text is pasted into a
|
|
568
684
|
// chat box as often as it is read in a terminal, and an unwrapped paragraph
|
|
569
685
|
// is a wall in both.
|
|
@@ -620,6 +736,50 @@ function describe(finding) {
|
|
|
620
736
|
};
|
|
621
737
|
}
|
|
622
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
|
+
|
|
623
783
|
/** The whole report, in the order a person should read it. */
|
|
624
784
|
function describeAll(findings) {
|
|
625
785
|
// A table anyone can read is also, necessarily, a table one customer can read
|
|
@@ -628,18 +788,38 @@ function describeAll(findings) {
|
|
|
628
788
|
// is kept, and it carries the rest.
|
|
629
789
|
const raw = findings || [];
|
|
630
790
|
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
|
-
|
|
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;
|
|
642
819
|
}
|
|
820
|
+
if (done.has(f.table)) continue;
|
|
821
|
+
done.add(f.table);
|
|
822
|
+
described.push(mergeTable(group, one));
|
|
643
823
|
}
|
|
644
824
|
// Worst first, and within that the ones open to the whole internet before the
|
|
645
825
|
// ones that need an account, and those before the ones that need two requests
|
|
@@ -678,6 +858,7 @@ function allClearLines(attacksRun) {
|
|
|
678
858
|
}
|
|
679
859
|
|
|
680
860
|
module.exports = {
|
|
861
|
+
authTiesOf: authTiesOf,
|
|
681
862
|
readContents: readContents,
|
|
682
863
|
severityOf: severityOf,
|
|
683
864
|
causeOf: causeOf,
|
package/package.json
CHANGED
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,21 @@ 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
|
+
return item.kind;
|
|
176
|
+
}
|
|
177
|
+
|
|
152
178
|
/** What the re-check says, in the order it matters. */
|
|
153
179
|
function describe(result) {
|
|
154
180
|
const lines = [''];
|
|
@@ -165,7 +191,7 @@ function describe(result) {
|
|
|
165
191
|
lines.push(' ' + result.fixed.length + (result.fixed.length === 1 ? ' problem is' : ' problems are') + ' fixed:');
|
|
166
192
|
lines.push('');
|
|
167
193
|
for (const item of result.fixed) {
|
|
168
|
-
lines.push(' ' + item.table + ' - I ran the same attack again and it was refused.');
|
|
194
|
+
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - I ran the same attack again and it was refused.');
|
|
169
195
|
}
|
|
170
196
|
lines.push('');
|
|
171
197
|
}
|
|
@@ -185,7 +211,7 @@ function describe(result) {
|
|
|
185
211
|
lines.push(' ' + result.unverifiable.length + ' I could NOT confirm:');
|
|
186
212
|
lines.push('');
|
|
187
213
|
for (const item of result.unverifiable) {
|
|
188
|
-
lines.push(' ' + item.table + ' - ' + item.why);
|
|
214
|
+
lines.push(' ' + item.table + ' (' + whatOf(item) + ') - ' + item.why);
|
|
189
215
|
}
|
|
190
216
|
lines.push('');
|
|
191
217
|
lines.push(' These are not fixed and not broken - they are unknown. The problem');
|
package/scan.js
CHANGED
|
@@ -372,13 +372,24 @@ async function scan(client, sourceSchema, options) {
|
|
|
372
372
|
columns: [],
|
|
373
373
|
}));
|
|
374
374
|
|
|
375
|
+
// Whether each table is tied to Supabase's sign-in decides what its fix
|
|
376
|
+
// can be. Read from the original schema: the copy points auth.users keys
|
|
377
|
+
// at a stand-in, and would call every table untied.
|
|
378
|
+
const ties = finding.authTiesOf(plan);
|
|
379
|
+
// And whether a row says whose it is: with no such column, no rule can
|
|
380
|
+
// tie it to the person asking. Read from the copy, the way seeding did.
|
|
381
|
+
const owned = new Map(theirs.map((table) => [table.name, Boolean(attack.ownerColumn(table))]));
|
|
382
|
+
const tagged = (f) => Object.assign({}, f,
|
|
383
|
+
ties.has(f.table) ? { authTied: ties.get(f.table) } : {},
|
|
384
|
+
owned.has(f.table) ? { owned: owned.get(f.table) } : {});
|
|
385
|
+
|
|
375
386
|
return {
|
|
376
387
|
stopped: null,
|
|
377
388
|
attacksRun: attempted.length,
|
|
378
389
|
notChecked: notChecked,
|
|
379
390
|
attempted: attempted,
|
|
380
391
|
findings: finding.describeAll(
|
|
381
|
-
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged),
|
|
392
|
+
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged).map(tagged),
|
|
382
393
|
),
|
|
383
394
|
};
|
|
384
395
|
} finally {
|
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
|
};
|