kryptheon-night 0.1.31 → 0.1.32
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 +57 -1
- package/engine.sql +286 -9
- package/finding.js +105 -11
- package/package.json +5 -3
- package/scan.js +17 -3
- package/schema.js +54 -12
- package/tamper.js +30 -5
package/attack.js
CHANGED
|
@@ -531,6 +531,27 @@ function insertRow(client, schema, table, row, back) {
|
|
|
531
531
|
);
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
/**
|
|
535
|
+
* The column that says which organization's a row is, on a table with no
|
|
536
|
+
* person's name on it - or null. Its first key to a table that others point
|
|
537
|
+
* at (an organization, a workspace, a team), where the table itself is not one
|
|
538
|
+
* of those: seeding gives each person their own organization, and a table like
|
|
539
|
+
* this one row, in the first person's. Found on a blind test (HarborLine):
|
|
540
|
+
* integration_secrets(organization_id, access_token) was readable by every
|
|
541
|
+
* signed-in user, and with no person column it was never tried at all.
|
|
542
|
+
*/
|
|
543
|
+
function tenantKey(table, tables, parents) {
|
|
544
|
+
// A view is not seeded: it shows whatever its tables hold, the second
|
|
545
|
+
// person's own organization included, so a row of it read by them proves
|
|
546
|
+
// nothing. Found on the same test: workspace_health, filtered to the
|
|
547
|
+
// organizations the reader belongs to, came back as a leak.
|
|
548
|
+
if (table.isView || parents.has(table.name)) return null;
|
|
549
|
+
for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
|
|
550
|
+
if (key.refTable !== table.name && parents.has(key.refTable) && key.columns.length === 1) return key.columns[0];
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
534
555
|
/** Every table some other table points at, by a key it declared or one it implies. */
|
|
535
556
|
function parentTables(tables) {
|
|
536
557
|
const parents = new Set();
|
|
@@ -619,7 +640,19 @@ async function seed(client, schema, tables) {
|
|
|
619
640
|
// this table can use the same one instead of rediscovering it, and be
|
|
620
641
|
// sure a refusal is the app defending itself rather than a CHECK it
|
|
621
642
|
// never satisfied.
|
|
622
|
-
|
|
643
|
+
const entry = { table: table.name, owner: owner, attempt: worked };
|
|
644
|
+
// A table with nobody's name on it that got a row for each person - an
|
|
645
|
+
// organization's invoices, say - holds the second person's own row too.
|
|
646
|
+
// Which row is the first person's is kept, so a write meant for
|
|
647
|
+
// "somebody else's row" is aimed at it. Found on a blind test
|
|
648
|
+
// (HarborLine): an unscoped UPDATE changed the second person's own
|
|
649
|
+
// invoice, and was reported as any customer changing another's.
|
|
650
|
+
const firstRow = !owner && rows.get(table.name) && rows.get(table.name).get(USER_A);
|
|
651
|
+
const pk = primaryKeyOf(table);
|
|
652
|
+
if (firstRow && rows.get(table.name).size === 2 && pk && firstRow[pk] !== null && firstRow[pk] !== undefined) {
|
|
653
|
+
entry.rowOfA = { column: pk, value: String(firstRow[pk]) };
|
|
654
|
+
}
|
|
655
|
+
seeded.push(entry);
|
|
623
656
|
} else {
|
|
624
657
|
// Recorded, never swallowed. The report has to say this table was not
|
|
625
658
|
// checked rather than let an empty table pass for a safe one.
|
|
@@ -747,8 +780,10 @@ async function impersonate(client, schema, tables) {
|
|
|
747
780
|
return false;
|
|
748
781
|
};
|
|
749
782
|
|
|
783
|
+
const parents = parentTables(tables);
|
|
750
784
|
for (const table of tables) {
|
|
751
785
|
const owner = ownerColumn(table);
|
|
786
|
+
const tenant = owner ? null : tenantKey(table, tables, parents);
|
|
752
787
|
const anon = await readAs(client, schema, table.name, 'anon', null);
|
|
753
788
|
const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
|
|
754
789
|
|
|
@@ -794,6 +829,27 @@ async function impersonate(client, schema, tables) {
|
|
|
794
829
|
});
|
|
795
830
|
}
|
|
796
831
|
}
|
|
832
|
+
|
|
833
|
+
// A table whose rows are an organization's, not a person's: its one row is
|
|
834
|
+
// the first person's organization's, and the second person - signed in,
|
|
835
|
+
// in an organization of their own - asks for it. What a logged-out
|
|
836
|
+
// stranger can already read is reported as exposed, and not twice.
|
|
837
|
+
if (tenant) {
|
|
838
|
+
const asB = await readAs(client, schema, table.name, 'authenticated', USER_B);
|
|
839
|
+
const open = Array.isArray(anon) && anon.length > 0;
|
|
840
|
+
if (settle('crossed:' + table.name, table.name, asB, 'as a signed-in user in another organization') &&
|
|
841
|
+
asB.length > 0 && !open) {
|
|
842
|
+
findings.push({
|
|
843
|
+
kind: 'crossed',
|
|
844
|
+
table: table.name,
|
|
845
|
+
owner: tenant,
|
|
846
|
+
tenant: true,
|
|
847
|
+
readable: asB.length,
|
|
848
|
+
columns: Object.keys(asB[0] || {}),
|
|
849
|
+
rlsEnabled: table.rlsEnabled,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
797
853
|
}
|
|
798
854
|
|
|
799
855
|
return { findings: findings, completed: completed, blocked: blocked, looped: looped };
|
package/engine.sql
CHANGED
|
@@ -367,6 +367,169 @@ EXCEPTION WHEN OTHERS THEN
|
|
|
367
367
|
RETURN '[]'::jsonb;
|
|
368
368
|
END $$;
|
|
369
369
|
|
|
370
|
+
/*
|
|
371
|
+
* Whether a function turns the caller away before it does anything, and
|
|
372
|
+
* whether its only check is one somebody with no role slips past (schema.js
|
|
373
|
+
* doorOf, read the same way: IF <check> THEN RAISE, before the first write
|
|
374
|
+
* and the first RETURN). `helpers` are { name, nullable } - a yes/no about
|
|
375
|
+
* the caller, or a role helper that answers NULL for a stranger.
|
|
376
|
+
*/
|
|
377
|
+
CREATE OR REPLACE FUNCTION __KN__.door_of(body text, helpers jsonb)
|
|
378
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
379
|
+
DECLARE
|
|
380
|
+
text_ text := coalesce(body, '');
|
|
381
|
+
writes_re constant text := '\y(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\y';
|
|
382
|
+
guard_re constant text :=
|
|
383
|
+
'((?<!\yend\s)(?<!\yend\s\s)\yif\y((?:(?!\ythen\y).)*?)\ythen\s+raise\y(?!\s+(?:notice|warning|info|log|debug)\y))';
|
|
384
|
+
first_act integer := 2147483647;
|
|
385
|
+
m text[];
|
|
386
|
+
pos integer;
|
|
387
|
+
from_ integer := 1;
|
|
388
|
+
cond text;
|
|
389
|
+
refuses_on_no boolean;
|
|
390
|
+
refuses boolean;
|
|
391
|
+
null_safe boolean;
|
|
392
|
+
h jsonb;
|
|
393
|
+
asks boolean;
|
|
394
|
+
slips boolean := false;
|
|
395
|
+
BEGIN
|
|
396
|
+
IF text_ ~* writes_re THEN
|
|
397
|
+
first_act := least(first_act, length((regexp_split_to_array(text_, writes_re, 'i'))[1]) + 1);
|
|
398
|
+
END IF;
|
|
399
|
+
IF text_ ~* '\yreturn\y' THEN
|
|
400
|
+
first_act := least(first_act, length((regexp_split_to_array(text_, '\yreturn\y', 'i'))[1]) + 1);
|
|
401
|
+
END IF;
|
|
402
|
+
FOR m IN SELECT regexp_matches(text_, guard_re, 'gi') LOOP
|
|
403
|
+
pos := from_ + strpos(substr(text_, from_), m[1]) - 1;
|
|
404
|
+
from_ := pos + length(m[1]);
|
|
405
|
+
EXIT WHEN pos > first_act;
|
|
406
|
+
cond := btrim(m[2]);
|
|
407
|
+
-- Refusing when the check fails, not when it passes.
|
|
408
|
+
refuses_on_no := cond ~* '^not\y' OR cond ~* '\yis\s+(not\s+true|false)\s*$' OR cond ~* '=\s*false\s*$';
|
|
409
|
+
FOR h IN SELECT * FROM jsonb_array_elements(coalesce(helpers, '[]'::jsonb)) LOOP
|
|
410
|
+
asks := cond ~* ('(^|[^\w$"])"?' || replace(h->>'name', '$', '\$') || '"?\s*\(');
|
|
411
|
+
CONTINUE WHEN NOT asks;
|
|
412
|
+
IF NOT coalesce((h->>'nullable')::boolean, false) THEN
|
|
413
|
+
IF refuses_on_no THEN RETURN jsonb_build_object('guarded', true, 'nullSlips', false); END IF;
|
|
414
|
+
ELSE
|
|
415
|
+
refuses := refuses_on_no OR cond ~* '\ynot\s+in\s*\(' OR cond ~ '<>|!=';
|
|
416
|
+
null_safe := cond ~* '\ycoalesce\s*\(' OR cond ~* '\yis\s+null\y' OR cond ~* '\yis\s+not\s+true\s*$';
|
|
417
|
+
IF refuses AND null_safe THEN RETURN jsonb_build_object('guarded', true, 'nullSlips', false); END IF;
|
|
418
|
+
IF refuses THEN slips := true; END IF;
|
|
419
|
+
END IF;
|
|
420
|
+
END LOOP;
|
|
421
|
+
IF refuses_on_no AND cond ~* '\yauth\s*\.\s*(uid|jwt)\s*\(' AND cond ~* 'admin|owner|member|role' THEN
|
|
422
|
+
RETURN jsonb_build_object('guarded', true, 'nullSlips', false);
|
|
423
|
+
END IF;
|
|
424
|
+
END LOOP;
|
|
425
|
+
RETURN jsonb_build_object('guarded', false, 'nullSlips', slips);
|
|
426
|
+
END $$;
|
|
427
|
+
|
|
428
|
+
/*
|
|
429
|
+
* Every SECURITY DEFINER function in the schema, trigger functions aside, and
|
|
430
|
+
* what the report needs to know about each (schema.js
|
|
431
|
+
* readAnonDefinerFunctions). Found on a blind test (HarborLine): the nightly
|
|
432
|
+
* run never looked at functions at all, so mark_invoice_paid, set_team_role
|
|
433
|
+
* and every other open one showed on the command line and never on the
|
|
434
|
+
* dashboard.
|
|
435
|
+
*/
|
|
436
|
+
CREATE OR REPLACE FUNCTION __KN__.read_anon_definer_functions(source text, role_ text DEFAULT 'anon')
|
|
437
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
438
|
+
DECLARE
|
|
439
|
+
writes_re constant text := '\y(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\y';
|
|
440
|
+
auth_re constant text := '\yauth\s*\.\s*(uid|jwt)\s*\(';
|
|
441
|
+
has_role boolean := EXISTS (SELECT 1 FROM pg_roles WHERE rolname = role_);
|
|
442
|
+
helpers jsonb;
|
|
443
|
+
out_ jsonb := '[]'::jsonb;
|
|
444
|
+
r record;
|
|
445
|
+
body text;
|
|
446
|
+
conf text;
|
|
447
|
+
writes boolean;
|
|
448
|
+
door jsonb;
|
|
449
|
+
BEGIN
|
|
450
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object('name', h.name, 'nullable', h.nullable) ORDER BY h.name), '[]'::jsonb)
|
|
451
|
+
INTO helpers
|
|
452
|
+
FROM (SELECT p.proname::text AS name, p.prorettype <> 'pg_catalog.bool'::regtype AS nullable,
|
|
453
|
+
regexp_replace(coalesce(pg_get_functiondef(p.oid), ''), '--[^\n]*', ' ', 'g') AS b
|
|
454
|
+
FROM pg_proc p
|
|
455
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
456
|
+
JOIN pg_type t ON t.oid = p.prorettype
|
|
457
|
+
WHERE n.nspname = source AND p.prokind = 'f'
|
|
458
|
+
AND (p.prorettype = 'pg_catalog.bool'::regtype OR t.typtype = 'e')) h
|
|
459
|
+
WHERE h.b ~* auth_re AND NOT h.b ~* writes_re;
|
|
460
|
+
|
|
461
|
+
FOR r IN
|
|
462
|
+
SELECT p.proname::text AS name,
|
|
463
|
+
pg_get_function_identity_arguments(p.oid) AS args,
|
|
464
|
+
p.proconfig::text AS config,
|
|
465
|
+
pg_get_functiondef(p.oid) AS def,
|
|
466
|
+
p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
|
|
467
|
+
(SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
|
|
468
|
+
CASE WHEN has_role THEN has_function_privilege(role_, p.oid, 'EXECUTE') ELSE false END AS callable
|
|
469
|
+
FROM pg_proc p
|
|
470
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
471
|
+
WHERE n.nspname = source
|
|
472
|
+
AND p.prosecdef
|
|
473
|
+
AND p.prorettype <> 'pg_catalog.trigger'::regtype
|
|
474
|
+
ORDER BY p.proname
|
|
475
|
+
LOOP
|
|
476
|
+
body := regexp_replace(coalesce(r.def, ''), '--[^\n]*', ' ', 'g');
|
|
477
|
+
conf := replace(replace(replace(coalesce(r.config, ''), '{', ''), '}', ''), '"', '');
|
|
478
|
+
writes := body ~* writes_re;
|
|
479
|
+
door := __KN__.door_of(body, helpers);
|
|
480
|
+
out_ := out_ || jsonb_build_array(jsonb_build_object(
|
|
481
|
+
'name', r.name,
|
|
482
|
+
'args', coalesce(r.args, ''),
|
|
483
|
+
'writes', writes,
|
|
484
|
+
'aboutCaller', (coalesce(r.returns_bool, false) OR coalesce(r.returns_enum, false)) AND NOT writes AND body ~* auth_re,
|
|
485
|
+
'guarded', (door->>'guarded')::boolean,
|
|
486
|
+
'nullSlips', (door->>'nullSlips')::boolean,
|
|
487
|
+
'hasFixedSearchPath', conf ~* '(^|,)search_path=',
|
|
488
|
+
'callable', coalesce(r.callable, false)));
|
|
489
|
+
END LOOP;
|
|
490
|
+
RETURN out_;
|
|
491
|
+
END $$;
|
|
492
|
+
|
|
493
|
+
/*
|
|
494
|
+
* Public buckets worth a look (scan.js bucketsOf): a read rule says only some
|
|
495
|
+
* people may read it, or there is no read rule and its name says its files
|
|
496
|
+
* are somebody's own.
|
|
497
|
+
*/
|
|
498
|
+
CREATE OR REPLACE FUNCTION __KN__.bucket_findings(buckets jsonb)
|
|
499
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
500
|
+
DECLARE
|
|
501
|
+
b jsonb;
|
|
502
|
+
rule jsonb;
|
|
503
|
+
esc text;
|
|
504
|
+
rest text;
|
|
505
|
+
narrowing jsonb;
|
|
506
|
+
no_rule boolean;
|
|
507
|
+
found jsonb := '[]'::jsonb;
|
|
508
|
+
BEGIN
|
|
509
|
+
FOR b IN SELECT * FROM jsonb_array_elements(coalesce(buckets, '[]'::jsonb)) LOOP
|
|
510
|
+
CONTINUE WHEN NOT coalesce((b->>'public')::boolean, false);
|
|
511
|
+
esc := regexp_replace(b->>'id', '([.*+?^${}()|\[\]\\])', '\\\1', 'g');
|
|
512
|
+
narrowing := '[]'::jsonb;
|
|
513
|
+
FOR rule IN SELECT * FROM jsonb_array_elements(coalesce(b->'readRules', '[]'::jsonb)) LOOP
|
|
514
|
+
rest := regexp_replace(coalesce(rule->>'qual', ''), 'bucket_id\s*=\s*''' || esc || '''(::text)?', '', 'gi');
|
|
515
|
+
rest := regexp_replace(rest, '''' || esc || '''(::text)?\s*=\s*bucket_id', '', 'gi');
|
|
516
|
+
rest := regexp_replace(rest, '\ytrue\y|\yand\y|[()\s]', '', 'gi');
|
|
517
|
+
IF NOT (coalesce(rule->'roles', '[]'::jsonb) ? 'anon' OR coalesce(rule->'roles', '[]'::jsonb) ? 'public')
|
|
518
|
+
OR rest <> '' THEN
|
|
519
|
+
narrowing := narrowing || to_jsonb(rule->>'name');
|
|
520
|
+
END IF;
|
|
521
|
+
END LOOP;
|
|
522
|
+
no_rule := jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0
|
|
523
|
+
AND (b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)';
|
|
524
|
+
IF jsonb_array_length(narrowing) > 0 OR no_rule THEN
|
|
525
|
+
found := found || jsonb_build_array(jsonb_build_object(
|
|
526
|
+
'kind', 'bucket', 'table', b->>'name', 'bucket', b->>'id',
|
|
527
|
+
'rules', narrowing, 'noRule', no_rule, 'columns', '[]'::jsonb));
|
|
528
|
+
END IF;
|
|
529
|
+
END LOOP;
|
|
530
|
+
RETURN found;
|
|
531
|
+
END $$;
|
|
532
|
+
|
|
370
533
|
/* Everything needed to rebuild a schema, in one shape. */
|
|
371
534
|
CREATE OR REPLACE FUNCTION __KN__.read_schema(source text)
|
|
372
535
|
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
@@ -395,6 +558,7 @@ BEGIN
|
|
|
395
558
|
'viewGrants', __KN__.read_view_grants(source),
|
|
396
559
|
'functions', __KN__.read_functions(source),
|
|
397
560
|
'buckets', __KN__.read_buckets(),
|
|
561
|
+
'anonFunctions', __KN__.read_anon_definer_functions(source),
|
|
398
562
|
-- A foreign key pointing at something whose shape could not be read is
|
|
399
563
|
-- not something to guess at. The caller stops rather than attacking a
|
|
400
564
|
-- copy that is missing a piece.
|
|
@@ -1454,6 +1618,31 @@ BEGIN
|
|
|
1454
1618
|
RETURN keys;
|
|
1455
1619
|
END $$;
|
|
1456
1620
|
|
|
1621
|
+
/*
|
|
1622
|
+
* The column that says which organization's a row is, on a table with no
|
|
1623
|
+
* person's name on it - or NULL. Its first single-column key to a table that
|
|
1624
|
+
* others point at, where the table itself is not one of those (attack.js
|
|
1625
|
+
* tenantKey). Found on a blind test (HarborLine): integration_secrets was
|
|
1626
|
+
* readable by every signed-in user, and with no person column it was never
|
|
1627
|
+
* tried.
|
|
1628
|
+
*/
|
|
1629
|
+
CREATE OR REPLACE FUNCTION __KN__.tenant_key(tab jsonb, tables jsonb, parents jsonb)
|
|
1630
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1631
|
+
DECLARE
|
|
1632
|
+
key jsonb;
|
|
1633
|
+
BEGIN
|
|
1634
|
+
-- A view is not seeded: it shows whatever its tables hold, the second
|
|
1635
|
+
-- person's own organization included (workspace_health, on the same test).
|
|
1636
|
+
IF coalesce((tab->>'isView')::boolean, false) OR parents ? (tab->>'name') THEN RETURN NULL; END IF;
|
|
1637
|
+
FOR key IN SELECT * FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables)) LOOP
|
|
1638
|
+
IF key->>'refTable' <> tab->>'name' AND parents ? (key->>'refTable')
|
|
1639
|
+
AND jsonb_array_length(key->'columns') = 1 THEN
|
|
1640
|
+
RETURN key->'columns'->>0;
|
|
1641
|
+
END IF;
|
|
1642
|
+
END LOOP;
|
|
1643
|
+
RETURN NULL;
|
|
1644
|
+
END $$;
|
|
1645
|
+
|
|
1457
1646
|
/* Every table some other table points at, by a key it declared or one it implies. */
|
|
1458
1647
|
CREATE OR REPLACE FUNCTION __KN__.parent_tables(tables jsonb)
|
|
1459
1648
|
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
@@ -1942,8 +2131,19 @@ BEGIN
|
|
|
1942
2131
|
-- into this table can use the same one instead of rediscovering it, and
|
|
1943
2132
|
-- be sure a refusal is the app defending itself rather than a CHECK it
|
|
1944
2133
|
-- never satisfied.
|
|
2134
|
+
-- A table with nobody's name on it that got a row for each person keeps
|
|
2135
|
+
-- which row is the first person's, so a write meant for somebody else's
|
|
2136
|
+
-- row is aimed at it (attack.js seed; HarborLine's invoices).
|
|
1945
2137
|
seeded := seeded || jsonb_build_array(jsonb_build_object(
|
|
1946
|
-
'table', tab->>'name', 'owner', owner, 'attempt', worked)
|
|
2138
|
+
'table', tab->>'name', 'owner', owner, 'attempt', worked)
|
|
2139
|
+
|| CASE WHEN owner IS NULL
|
|
2140
|
+
AND (SELECT count(*) FROM jsonb_object_keys(rows_->(tab->>'name'))) = 2
|
|
2141
|
+
AND __KN__.primary_key_of(tab) IS NOT NULL
|
|
2142
|
+
AND rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab) IS NOT NULL
|
|
2143
|
+
THEN jsonb_build_object('rowOfA', jsonb_build_object(
|
|
2144
|
+
'column', __KN__.primary_key_of(tab),
|
|
2145
|
+
'value', rows_->(tab->>'name')->__KN__.user_a()->>__KN__.primary_key_of(tab)))
|
|
2146
|
+
ELSE '{}'::jsonb END);
|
|
1947
2147
|
ELSE
|
|
1948
2148
|
-- Recorded, never swallowed. The report has to say this table was not
|
|
1949
2149
|
-- checked rather than let an empty table pass for a safe one.
|
|
@@ -2093,9 +2293,13 @@ DECLARE
|
|
|
2093
2293
|
key text;
|
|
2094
2294
|
named jsonb;
|
|
2095
2295
|
reached boolean;
|
|
2296
|
+
parents jsonb := __KN__.parent_tables(tables);
|
|
2297
|
+
tenant text;
|
|
2298
|
+
as_b jsonb;
|
|
2096
2299
|
BEGIN
|
|
2097
2300
|
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2098
2301
|
owner := __KN__.owner_column(tab);
|
|
2302
|
+
tenant := CASE WHEN owner IS NULL THEN __KN__.tenant_key(tab, tables, parents) END;
|
|
2099
2303
|
named := coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c),
|
|
2100
2304
|
'[]'::jsonb);
|
|
2101
2305
|
|
|
@@ -2170,6 +2374,35 @@ BEGIN
|
|
|
2170
2374
|
'why', 'as a signed-in customer: ' || (as_a->>'why')));
|
|
2171
2375
|
END IF;
|
|
2172
2376
|
END IF;
|
|
2377
|
+
|
|
2378
|
+
-- A table whose rows are an organization's, not a person's: its one row
|
|
2379
|
+
-- is the first person's organization's, and the second person - signed
|
|
2380
|
+
-- in, in an organization of their own - asks for it. What a logged-out
|
|
2381
|
+
-- stranger can already read is reported as exposed, and not twice.
|
|
2382
|
+
IF tenant IS NOT NULL THEN
|
|
2383
|
+
key := 'crossed:' || (tab->>'name');
|
|
2384
|
+
as_b := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_b(), NULL);
|
|
2385
|
+
IF (as_b->>'ok')::boolean THEN
|
|
2386
|
+
completed := completed || to_jsonb(key);
|
|
2387
|
+
IF (as_b->>'count')::bigint > 0
|
|
2388
|
+
AND NOT ((anon->>'ok')::boolean AND (anon->>'count')::bigint > 0) THEN
|
|
2389
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2390
|
+
'kind', 'crossed',
|
|
2391
|
+
'table', tab->>'name',
|
|
2392
|
+
'owner', tenant,
|
|
2393
|
+
'tenant', true,
|
|
2394
|
+
'readable', (as_b->>'count')::bigint,
|
|
2395
|
+
'columns', named,
|
|
2396
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2397
|
+
END IF;
|
|
2398
|
+
ELSIF __KN__.refusal_means(as_b->>'why') = 'unreachable' THEN
|
|
2399
|
+
completed := completed || to_jsonb(key);
|
|
2400
|
+
ELSE
|
|
2401
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2402
|
+
'table', tab->>'name', 'key', key,
|
|
2403
|
+
'why', 'as a signed-in user in another organization: ' || (as_b->>'why')));
|
|
2404
|
+
END IF;
|
|
2405
|
+
END IF;
|
|
2173
2406
|
END LOOP;
|
|
2174
2407
|
|
|
2175
2408
|
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked,
|
|
@@ -2340,10 +2573,11 @@ DECLARE
|
|
|
2340
2573
|
-- narrow what is allowed and never open it, so it is never the reason.
|
|
2341
2574
|
rules_for jsonb;
|
|
2342
2575
|
named jsonb;
|
|
2576
|
+
first_row jsonb;
|
|
2343
2577
|
BEGIN
|
|
2344
2578
|
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2345
2579
|
-- Never seeded, so there is nothing in it to protect.
|
|
2346
|
-
SELECT (s->>'attempt')::integer INTO shape
|
|
2580
|
+
SELECT (s->>'attempt')::integer, s->'rowOfA' INTO shape, first_row
|
|
2347
2581
|
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2348
2582
|
WHERE s->>'table' = tab->>'name';
|
|
2349
2583
|
CONTINUE WHEN NOT FOUND;
|
|
@@ -2369,8 +2603,14 @@ BEGIN
|
|
|
2369
2603
|
-- Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
2370
2604
|
-- customer deleting their OWN rows is not a finding, it is the feature,
|
|
2371
2605
|
-- and an unscoped DELETE would report every correctly built app.
|
|
2372
|
-
|
|
2373
|
-
|
|
2606
|
+
-- With nobody's name on the table, the first person's own row where each
|
|
2607
|
+
-- person got one (seed's rowOfA).
|
|
2608
|
+
their_rows := CASE
|
|
2609
|
+
WHEN owner IS NOT NULL
|
|
2610
|
+
THEN ' WHERE ' || __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a())
|
|
2611
|
+
WHEN jsonb_typeof(first_row) = 'object'
|
|
2612
|
+
THEN ' WHERE ' || __KN__.always_quote(first_row->>'column') || '::text = ' || quote_literal(first_row->>'value')
|
|
2613
|
+
ELSE '' END;
|
|
2374
2614
|
|
|
2375
2615
|
-- Written under a name nobody has used, which is both what makes the row
|
|
2376
2616
|
-- land at all and what makes it the right test: adding a row of your own
|
|
@@ -2641,7 +2881,8 @@ DECLARE
|
|
|
2641
2881
|
in_rule_literal text := '''\{([^}'']*,)?"?' || regexp_replace(role_, esc, '\\\1', 'g') ||
|
|
2642
2882
|
'"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
|
|
2643
2883
|
in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
|
|
2644
|
-
|
|
2884
|
+
-- A takeover is an UPDATE too (tamper.js COMMAND).
|
|
2885
|
+
command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' WHEN 'takeover' THEN 'UPDATE' END;
|
|
2645
2886
|
policy jsonb;
|
|
2646
2887
|
body text;
|
|
2647
2888
|
BEGIN
|
|
@@ -2825,6 +3066,18 @@ BEGIN
|
|
|
2825
3066
|
jsonb_build_object('what', 'change',
|
|
2826
3067
|
'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
|
|
2827
3068
|
jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
|
|
3069
|
+
-- On the members table itself: putting their own id on the first
|
|
3070
|
+
-- person's membership row - taking that member's place, and their role
|
|
3071
|
+
-- (tamper.js; HarborLine). Asked of every role, listed or not.
|
|
3072
|
+
IF tab->>'name' = member->'table'->>'name' THEN
|
|
3073
|
+
where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_a())];
|
|
3074
|
+
FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
|
|
3075
|
+
where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
|
|
3076
|
+
END LOOP;
|
|
3077
|
+
moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
|
|
3078
|
+
'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
|
|
3079
|
+
|| quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
|
|
3080
|
+
END IF;
|
|
2828
3081
|
|
|
2829
3082
|
-- Adding a row to the team, said only of a role whose own name says it
|
|
2830
3083
|
-- may only look, and written under the other person's name: a rule that
|
|
@@ -2868,7 +3121,8 @@ BEGIN
|
|
|
2868
3121
|
CONTINUE WHEN coalesce((move->>'lookOnly')::boolean, false) AND NOT __KN__.look_only(role_);
|
|
2869
3122
|
-- A write the rules list this role for is not asked: whatever it did
|
|
2870
3123
|
-- would not be reported, and a key refusing it left the table untested.
|
|
2871
|
-
IF
|
|
3124
|
+
IF NOT coalesce((move->>'anyRole')::boolean, false)
|
|
3125
|
+
AND __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
|
|
2872
3126
|
listed := true;
|
|
2873
3127
|
CONTINUE;
|
|
2874
3128
|
END IF;
|
|
@@ -2903,7 +3157,10 @@ BEGIN
|
|
|
2903
3157
|
'changed', changed,
|
|
2904
3158
|
'below', below,
|
|
2905
3159
|
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2906
|
-
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
|
|
3160
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
|
|
3161
|
+
|| CASE WHEN can ? 'takeover'
|
|
3162
|
+
THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
|
|
3163
|
+
ELSE '{}'::jsonb END);
|
|
2907
3164
|
EXIT;
|
|
2908
3165
|
END IF;
|
|
2909
3166
|
IF NOT listed THEN below := below || to_jsonb(role_); END IF;
|
|
@@ -3499,12 +3756,32 @@ BEGIN
|
|
|
3499
3756
|
FROM jsonb_array_elements(coalesce(seen->'looped', '[]'::jsonb)) l
|
|
3500
3757
|
GROUP BY l->>'relation') g), '[]'::jsonb);
|
|
3501
3758
|
|
|
3759
|
+
-- Functions anybody can call that run past the rules, and public buckets
|
|
3760
|
+
-- worth a look - read from the app's own shape, never run or opened
|
|
3761
|
+
-- (scan.js privilegedOf and bucketsOf). Found on a blind test
|
|
3762
|
+
-- (HarborLine): neither was ever in the nightly run, so the dashboard
|
|
3763
|
+
-- showed less than the command line did.
|
|
3764
|
+
findings := findings || coalesce((
|
|
3765
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
3766
|
+
'kind', 'privileged', 'fn', f->>'name', 'table', f->>'name', 'args', f->>'args',
|
|
3767
|
+
'writes', (f->>'writes')::boolean, 'hasFixedSearchPath', (f->>'hasFixedSearchPath')::boolean,
|
|
3768
|
+
'nullSlips', coalesce((f->>'nullSlips')::boolean, false), 'columns', '[]'::jsonb))
|
|
3769
|
+
FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f
|
|
3770
|
+
WHERE (f->>'callable')::boolean AND NOT (f->>'aboutCaller')::boolean AND NOT (f->>'guarded')::boolean),
|
|
3771
|
+
'[]'::jsonb)
|
|
3772
|
+
|| __KN__.bucket_findings(plan->'buckets');
|
|
3773
|
+
|
|
3502
3774
|
-- Everything genuinely attacked, so a re-check can tell a finding that was
|
|
3503
|
-
-- fixed from one whose attack simply did not run this time.
|
|
3775
|
+
-- fixed from one whose attack simply did not run this time. Every function
|
|
3776
|
+
-- and bucket read counts as looked at, so a revoked grant comes back fixed.
|
|
3504
3777
|
attempted := coalesce(seen->'completed', '[]'::jsonb)
|
|
3505
3778
|
|| coalesce(wrote->'completed', '[]'::jsonb)
|
|
3506
3779
|
|| coalesce(ranked->'completed', '[]'::jsonb)
|
|
3507
|
-
|| coalesce(stranded->'completed', '[]'::jsonb)
|
|
3780
|
+
|| coalesce(stranded->'completed', '[]'::jsonb)
|
|
3781
|
+
|| coalesce((SELECT jsonb_agg('privileged:' || (f->>'name'))
|
|
3782
|
+
FROM jsonb_array_elements(coalesce(plan->'anonFunctions', '[]'::jsonb)) f), '[]'::jsonb)
|
|
3783
|
+
|| coalesce((SELECT jsonb_agg('bucket:' || (b->>'name'))
|
|
3784
|
+
FROM jsonb_array_elements(coalesce(plan->'buckets', '[]'::jsonb)) b), '[]'::jsonb);
|
|
3508
3785
|
|
|
3509
3786
|
-- And everything that was not. Said out loud, never left out: a table
|
|
3510
3787
|
-- nothing could be put into reads exactly like a table nothing got out of.
|
package/finding.js
CHANGED
|
@@ -358,6 +358,10 @@ function headlineFor(finding) {
|
|
|
358
358
|
if (finding.kind === 'privileged') {
|
|
359
359
|
return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
|
|
360
360
|
}
|
|
361
|
+
if (finding.kind === 'role' && finding.takeover) {
|
|
362
|
+
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
|
|
363
|
+
finding.table + ' table - and with it their role.';
|
|
364
|
+
}
|
|
361
365
|
if (finding.kind === 'role') {
|
|
362
366
|
const order = ['delete', 'change', 'add'];
|
|
363
367
|
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
@@ -368,6 +372,9 @@ function headlineFor(finding) {
|
|
|
368
372
|
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
|
|
369
373
|
' table, which holds ' + listOf(finding.secrets || []) + '.';
|
|
370
374
|
}
|
|
375
|
+
if (finding.kind === 'bucket' && finding.noRule) {
|
|
376
|
+
return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
|
|
377
|
+
}
|
|
371
378
|
if (finding.kind === 'bucket') {
|
|
372
379
|
return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
|
|
373
380
|
}
|
|
@@ -377,6 +384,7 @@ function headlineFor(finding) {
|
|
|
377
384
|
// The table name goes in front of the sentence rather than inside it. Put
|
|
378
385
|
// inside, a table called `customers` produced "One customer can read another
|
|
379
386
|
// customer's customers", which is the kind of line that loses a reader.
|
|
387
|
+
if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
|
|
380
388
|
return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
|
|
381
389
|
}
|
|
382
390
|
|
|
@@ -454,6 +462,20 @@ function bodyFor(finding, contents) {
|
|
|
454
462
|
'kept, and your live app was never touched.';
|
|
455
463
|
}
|
|
456
464
|
|
|
465
|
+
if (finding.kind === 'role' && finding.takeover) {
|
|
466
|
+
// Not a matter of taste like a viewer who may edit: taking somebody
|
|
467
|
+
// else's membership is never what a role is for.
|
|
468
|
+
const place = oneOf(finding.parent);
|
|
469
|
+
return (
|
|
470
|
+
'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
|
|
471
|
+
' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' +
|
|
472
|
+
finding.via + '.' + finding.personColumn + ' on the other person\'s own membership row to their own id - so ' +
|
|
473
|
+
'they held that member\'s place and role, on a copy of your app. Before joining, they could not. It was ' +
|
|
474
|
+
'undone straight away. A member taking over another member\'s row is never something a role should be able ' +
|
|
475
|
+
'to do, even where members may edit some of a membership.'
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
457
479
|
if (finding.kind === 'role') {
|
|
458
480
|
// Said as what was done, and then whose decision it is: a viewer who may
|
|
459
481
|
// edit is a hole in one app and the design of another.
|
|
@@ -494,6 +516,15 @@ function bodyFor(finding, contents) {
|
|
|
494
516
|
);
|
|
495
517
|
}
|
|
496
518
|
|
|
519
|
+
if (finding.kind === 'bucket' && finding.noRule) {
|
|
520
|
+
return (
|
|
521
|
+
'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
|
|
522
|
+
'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
|
|
523
|
+
'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
|
|
524
|
+
'something for everyone. I only read the bucket\'s setting and its rules; I did not open or list ' +
|
|
525
|
+
'any file. Whether it should be public is yours to say.'
|
|
526
|
+
);
|
|
527
|
+
}
|
|
497
528
|
if (finding.kind === 'bucket') {
|
|
498
529
|
// Said as what was read, never as a file taken: no file was opened.
|
|
499
530
|
return (
|
|
@@ -520,6 +551,11 @@ function bodyFor(finding, contents) {
|
|
|
520
551
|
: ', reaching whatever it reads past the rules on those tables.') +
|
|
521
552
|
' I did not call it: whether an open function like this is intended is ' +
|
|
522
553
|
'something only you can confirm.' +
|
|
554
|
+
(finding.nullSlips
|
|
555
|
+
? ' Its first lines do check the caller\'s role - but with NOT IN or <>, and somebody with no role ' +
|
|
556
|
+
'there gets NULL back, and NULL NOT IN (...) is not true: the IF never raises, so a signed-in ' +
|
|
557
|
+
'stranger gets through as if they had passed.'
|
|
558
|
+
: '') +
|
|
523
559
|
(finding.hasFixedSearchPath ? ''
|
|
524
560
|
: ' It also does not pin its search_path, so a caller can point the names ' +
|
|
525
561
|
'inside it at their own objects.')
|
|
@@ -558,6 +594,13 @@ function bodyFor(finding, contents) {
|
|
|
558
594
|
(holds ? ', including ' + holds : '') + '.'
|
|
559
595
|
);
|
|
560
596
|
}
|
|
597
|
+
if (finding.tenant) {
|
|
598
|
+
return (
|
|
599
|
+
'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
|
|
600
|
+
finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
|
|
601
|
+
'. Anyone with an account can read every organization\'s rows.'
|
|
602
|
+
);
|
|
603
|
+
}
|
|
561
604
|
return (
|
|
562
605
|
'Signed in as one customer, I asked for another customer\'s rows and got ' +
|
|
563
606
|
finding.readable + ' of them back' + (holds ? ', including ' + holds : '') +
|
|
@@ -629,14 +672,17 @@ function serverOnlyLines(finding) {
|
|
|
629
672
|
// column saying whose row it is. The assistant applying it had to correct
|
|
630
673
|
// the prompt; a less careful one would have locked the table away from the
|
|
631
674
|
// members who use it.
|
|
632
|
-
|
|
675
|
+
// A row an organization owns names its organization even when the column is
|
|
676
|
+
// not called organization_id: the attack found which key it is.
|
|
677
|
+
const team = finding.authTied !== false &&
|
|
678
|
+
(finding.tenant ? finding.owner : (finding.columns || []).find((c) => TENANT_COLUMN.test(String(c))));
|
|
633
679
|
const maker = finding.owner || finding.ownedBy;
|
|
634
680
|
// A team's row that also says who made it - created_by beside workspace_id.
|
|
635
681
|
// Found on a benchmark: the prompt said to compare created_by to auth.uid()
|
|
636
682
|
// for reading, which hides every teammate's rows and breaks the shared
|
|
637
683
|
// view the table exists for. Who made a row decides who may write it
|
|
638
684
|
// under their name, not who may see it.
|
|
639
|
-
if (team && finding.owned !== false && maker) {
|
|
685
|
+
if (team && !finding.tenant && finding.owned !== false && maker) {
|
|
640
686
|
return [
|
|
641
687
|
'Rows in "' + table + '" belong to a team ("' + team + '"), and "' + maker + '" only records who ' +
|
|
642
688
|
'made each one. So reading should be decided by team membership: let a signed-in user read a ' +
|
|
@@ -654,7 +700,7 @@ function serverOnlyLines(finding) {
|
|
|
654
700
|
'',
|
|
655
701
|
];
|
|
656
702
|
}
|
|
657
|
-
const tenant = finding.owned === false && team;
|
|
703
|
+
const tenant = (finding.tenant || finding.owned === false) && team;
|
|
658
704
|
if (tenant) {
|
|
659
705
|
return [
|
|
660
706
|
'Rows in "' + table + '" belong to an organization ("' + tenant + '"), not to one person. So the ' +
|
|
@@ -785,6 +831,23 @@ function fixPromptFor(finding) {
|
|
|
785
831
|
'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
|
|
786
832
|
'check again - the tables it could not test will be tested this time.',
|
|
787
833
|
]
|
|
834
|
+
: finding.kind === 'role' && finding.takeover
|
|
835
|
+
? [
|
|
836
|
+
'My app has a permissions problem - a member can take over another member\'s place.',
|
|
837
|
+
'',
|
|
838
|
+
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
839
|
+
finding.roleColumn + '" is "' + finding.who + '" can update another member\'s row in "' + finding.via +
|
|
840
|
+
'" and set "' + finding.personColumn + '" to their own id - taking that member\'s place and role, an ' +
|
|
841
|
+
'admin\'s or the owner\'s included. This was proved on a copy of the database.',
|
|
842
|
+
'',
|
|
843
|
+
'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change "' + finding.personColumn +
|
|
844
|
+
'" or the team a membership belongs to on an existing row - adding or removing members is how membership ' +
|
|
845
|
+
'changes. Let only the roles that manage the team (usually owner and admin) update membership rows at ' +
|
|
846
|
+
'all, check that in WITH CHECK as well as USING, and never use WITH CHECK (true). If members only need to ' +
|
|
847
|
+
'change something of their own, allow exactly that column on their own row and nothing else.',
|
|
848
|
+
'',
|
|
849
|
+
'Then check every function that updates "' + finding.via + '" for the same thing.',
|
|
850
|
+
]
|
|
788
851
|
: finding.kind === 'role'
|
|
789
852
|
? [
|
|
790
853
|
'My app may have a permissions problem - please check it rather than assume it.',
|
|
@@ -830,11 +893,14 @@ function fixPromptFor(finding) {
|
|
|
830
893
|
'My app may have a storage problem - please check it rather than assume it.',
|
|
831
894
|
'',
|
|
832
895
|
'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
|
|
833
|
-
'download it without logging in
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
896
|
+
'download it without logging in' + (finding.noRule
|
|
897
|
+
? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
|
|
898
|
+
'meant for everyone.'
|
|
899
|
+
: ', and the storage.objects ' +
|
|
900
|
+
((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
|
|
901
|
+
listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
|
|
902
|
+
((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
|
|
903
|
+
((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.'),
|
|
838
904
|
'',
|
|
839
905
|
'First tell me: is every file in this bucket meant to be downloadable by anyone? If yes, change nothing.',
|
|
840
906
|
'',
|
|
@@ -843,8 +909,11 @@ function fixPromptFor(finding) {
|
|
|
843
909
|
'change the app to fetch these files with signed URLs (createSignedUrl) instead of getPublicUrl, ' +
|
|
844
910
|
'or the links it shows will stop working.',
|
|
845
911
|
'',
|
|
846
|
-
|
|
847
|
-
'
|
|
912
|
+
finding.noRule
|
|
913
|
+
? 'Once it is private, add a read rule on storage.objects for this bucket that lets in only the ' +
|
|
914
|
+
'people a file belongs to - without one, nobody but your server can read them.'
|
|
915
|
+
: 'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
|
|
916
|
+
'only lets in the right people.',
|
|
848
917
|
'',
|
|
849
918
|
'Then look at every other bucket for the same thing.',
|
|
850
919
|
]
|
|
@@ -863,6 +932,16 @@ function fixPromptFor(finding) {
|
|
|
863
932
|
'backend key, the way an admin-only function like this should be reached.',
|
|
864
933
|
'',
|
|
865
934
|
].concat(
|
|
935
|
+
finding.nullSlips
|
|
936
|
+
? [
|
|
937
|
+
'Its role check lets through anyone with no role at all: the helper returns NULL for them, and ' +
|
|
938
|
+
'NULL NOT IN (...) - or NULL <> ... - is not true, so the IF never raises. Write the check so ' +
|
|
939
|
+
'NULL cannot pass, for example IF coalesce(<the helper>(...), \'none\') NOT IN (...) THEN RAISE, ' +
|
|
940
|
+
'or IF <the helper>(...) IS NULL OR <the helper>(...) NOT IN (...) THEN RAISE - and check every ' +
|
|
941
|
+
'other function that uses the same helper the same way.',
|
|
942
|
+
'',
|
|
943
|
+
]
|
|
944
|
+
: [],
|
|
866
945
|
finding.hasFixedSearchPath
|
|
867
946
|
? []
|
|
868
947
|
: [
|
|
@@ -998,7 +1077,9 @@ function fixPromptFor(finding) {
|
|
|
998
1077
|
'The "' + finding.table + '" table ' +
|
|
999
1078
|
(finding.kind === 'exposed'
|
|
1000
1079
|
? 'can be read by anyone who is not logged in, because ' + cause.short + '.'
|
|
1001
|
-
:
|
|
1080
|
+
: finding.tenant
|
|
1081
|
+
? 'lets a signed-in user read rows belonging to a different organization, because ' + cause.short + '.'
|
|
1082
|
+
: 'lets one signed-in user read rows belonging to a different user, because ' + cause.short + '.'),
|
|
1002
1083
|
'',
|
|
1003
1084
|
].concat(
|
|
1004
1085
|
serverOnlyLines(finding) || [
|
|
@@ -1058,11 +1139,22 @@ function describe(finding) {
|
|
|
1058
1139
|
readable: finding.readable,
|
|
1059
1140
|
secrets: finding.secrets,
|
|
1060
1141
|
bucket: finding.bucket,
|
|
1142
|
+
// An organization's rows read from outside it, and a member taking over
|
|
1143
|
+
// another member's row: kept so the re-check and the read-back word them
|
|
1144
|
+
// the same way the report did.
|
|
1145
|
+
tenant: finding.tenant,
|
|
1146
|
+
takeover: finding.takeover,
|
|
1147
|
+
personColumn: finding.personColumn,
|
|
1148
|
+
nullSlips: finding.nullSlips,
|
|
1149
|
+
noRule: finding.noRule,
|
|
1061
1150
|
proof: finding.kind === 'recursive'
|
|
1062
1151
|
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
1063
1152
|
loopError(finding) + '.'
|
|
1064
1153
|
: finding.kind === 'privileged'
|
|
1065
1154
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
1155
|
+
: finding.kind === 'role' && finding.takeover
|
|
1156
|
+
? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
|
|
1157
|
+
finding.via + '", which I could not touch before joining. It was undone.'
|
|
1066
1158
|
: finding.kind === 'role'
|
|
1067
1159
|
? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
|
|
1068
1160
|
listOf((finding.can || []).map((what) => {
|
|
@@ -1072,6 +1164,8 @@ function describe(finding) {
|
|
|
1072
1164
|
: finding.kind === 'teamread'
|
|
1073
1165
|
? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
|
|
1074
1166
|
((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
|
|
1167
|
+
: finding.kind === 'bucket' && finding.noRule
|
|
1168
|
+
? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it. I did not open any file.'
|
|
1075
1169
|
: finding.kind === 'bucket'
|
|
1076
1170
|
? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
|
1077
1171
|
' says who may read it. I did not open any file.'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
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 recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.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 nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.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",
|
|
@@ -75,7 +75,9 @@
|
|
|
75
75
|
"check:installer": "node installer.check.js",
|
|
76
76
|
"check:roles": "node roles.check.js",
|
|
77
77
|
"check:buckets": "node buckets.check.js",
|
|
78
|
-
"check:installscript": "node installscript.check.js"
|
|
78
|
+
"check:installscript": "node installscript.check.js",
|
|
79
|
+
"check:tenants": "node tenants.check.js",
|
|
80
|
+
"check:takeover": "node takeover.check.js"
|
|
79
81
|
},
|
|
80
82
|
"keywords": [
|
|
81
83
|
"supabase",
|
package/scan.js
CHANGED
|
@@ -505,6 +505,8 @@ function privilegedOf(plan) {
|
|
|
505
505
|
args: fn.args,
|
|
506
506
|
writes: fn.writes,
|
|
507
507
|
hasFixedSearchPath: fn.hasFixedSearchPath,
|
|
508
|
+
// Its only check at the door is one a caller with no role slips past.
|
|
509
|
+
nullSlips: fn.nullSlips === true,
|
|
508
510
|
columns: [],
|
|
509
511
|
}));
|
|
510
512
|
}
|
|
@@ -536,10 +538,19 @@ function bucketsOf(plan) {
|
|
|
536
538
|
table: b.name,
|
|
537
539
|
bucket: b.id,
|
|
538
540
|
rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
|
|
541
|
+
// No read rule at all, and a name that says what is in it is not for
|
|
542
|
+
// everyone. Found on a blind test (HarborLine): customer-exports was
|
|
543
|
+
// public, and its one read rule had failed to load (a text compared to a
|
|
544
|
+
// uuid) - with nothing to read, the bucket was never mentioned.
|
|
545
|
+
noRule: !(b.readRules || []).length && PRIVATE_BUCKET.test(String(b.name)),
|
|
539
546
|
columns: [],
|
|
540
|
-
})).filter((f) => f.rules.length);
|
|
547
|
+
})).filter((f) => f.rules.length || f.noRule);
|
|
541
548
|
}
|
|
542
549
|
|
|
550
|
+
// Names that say a bucket's files are somebody's own business. Whole words
|
|
551
|
+
// only: "plain" or "avatars" say nothing, and stay quiet.
|
|
552
|
+
const PRIVATE_BUCKET = /(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)/i;
|
|
553
|
+
|
|
543
554
|
/**
|
|
544
555
|
* What each finding needs to know about its table for its fix to be right:
|
|
545
556
|
* whether the app signs people in through Supabase, whether a row says whose
|
|
@@ -619,14 +630,17 @@ function nightlyRecord(latest, readAt, plan) {
|
|
|
619
630
|
if (!run) return record;
|
|
620
631
|
// The night's findings, told as the night's: tagged with what their table
|
|
621
632
|
// needs for a right fix when the schema could be read, and joined by the
|
|
622
|
-
// functions anyone can call - read now, when this is
|
|
623
|
-
//
|
|
633
|
+
// functions anyone can call and the public buckets - read now, when this is
|
|
634
|
+
// asked, which is newer than the night. The night records them too since
|
|
635
|
+
// 0.1.32 (for the dashboard), so where they can be read now the night's own
|
|
636
|
+
// are left out rather than said twice; where they cannot, the night's stand.
|
|
624
637
|
const tag = plan ? taggerFor(plan, plan.tables) : (f) => f;
|
|
625
638
|
// A loop the engine could not name ("stack depth limit exceeded") is put
|
|
626
639
|
// on the tables whose own rule reads back into them, read from the shape.
|
|
627
640
|
const loops = plan ? loopingTables(plan) : [];
|
|
628
641
|
const night = [];
|
|
629
642
|
for (const f of run.findings || []) {
|
|
643
|
+
if (plan && (f.kind === 'privileged' || f.kind === 'bucket')) continue;
|
|
630
644
|
const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
|
|
631
645
|
for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
|
|
632
646
|
}
|
package/schema.js
CHANGED
|
@@ -225,6 +225,25 @@ async function readIndexes(client, schema) {
|
|
|
225
225
|
* argument the caller chose.
|
|
226
226
|
*/
|
|
227
227
|
function guardedAtTheDoor(body, helpers) {
|
|
228
|
+
return doorOf(body, helpers).guarded;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The same reading, and one more answer: whether the only check at the door
|
|
233
|
+
* is one a caller with no role at all slips past.
|
|
234
|
+
*
|
|
235
|
+
* Found on a blind test (HarborLine): complete_automation_run opened with
|
|
236
|
+
* if public.org_role(v_org) not in ('owner', 'admin') then raise ...
|
|
237
|
+
* org_role answers with the caller's role there - and with NULL for somebody
|
|
238
|
+
* who has none. NULL NOT IN (...) is not true, so the IF never raises, and a
|
|
239
|
+
* stranger completed another organization's runs. A role helper's check is a
|
|
240
|
+
* guard only when it is written so NULL cannot pass - coalesce(...), or
|
|
241
|
+
* IS NULL OR ... - and otherwise it is said, by name, why it is not one.
|
|
242
|
+
*
|
|
243
|
+
* `helpers` are names (yes/no helpers about the caller) or { name, nullable }
|
|
244
|
+
* (a role helper, which answers NULL for a stranger).
|
|
245
|
+
*/
|
|
246
|
+
function doorOf(body, helpers) {
|
|
228
247
|
const text = String(body || '');
|
|
229
248
|
const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
|
|
230
249
|
const returns = /\breturn\b/i.exec(text);
|
|
@@ -236,17 +255,26 @@ function guardedAtTheDoor(body, helpers) {
|
|
|
236
255
|
// never as the tail of a longer name.
|
|
237
256
|
const asks = (cond, name) => new RegExp('(^|[^\\w$"])"?' +
|
|
238
257
|
name.replace(/[$]/g, '\\$') + '"?\\s*\\(', 'i').test(cond);
|
|
258
|
+
const list = (helpers || []).map((h) => (typeof h === 'string' ? { name: h, nullable: false } : h));
|
|
259
|
+
let slips = false;
|
|
239
260
|
let found;
|
|
240
261
|
while ((found = guard.exec(text)) !== null) {
|
|
241
262
|
if (found.index > firstAct) break;
|
|
242
263
|
const cond = found[1].trim();
|
|
243
264
|
// Refusing when the check fails, not when it passes.
|
|
244
265
|
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 (
|
|
246
|
-
if (
|
|
247
|
-
|
|
266
|
+
if (refusesOnNo && list.some((h) => !h.nullable && asks(cond, h.name))) return { guarded: true, nullSlips: false };
|
|
267
|
+
if (refusesOnNo && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) {
|
|
268
|
+
return { guarded: true, nullSlips: false };
|
|
269
|
+
}
|
|
270
|
+
if (list.some((h) => h.nullable && asks(cond, h.name))) {
|
|
271
|
+
const refuses = refusesOnNo || /\bnot\s+in\s*\(/i.test(cond) || /<>|!=/.test(cond);
|
|
272
|
+
const nullSafe = /\bcoalesce\s*\(/i.test(cond) || /\bis\s+null\b/i.test(cond) || /\bis\s+not\s+true\s*$/i.test(cond);
|
|
273
|
+
if (refuses && nullSafe) return { guarded: true, nullSlips: false };
|
|
274
|
+
if (refuses) slips = true;
|
|
275
|
+
}
|
|
248
276
|
}
|
|
249
|
-
return false;
|
|
277
|
+
return { guarded: false, nullSlips: slips };
|
|
250
278
|
}
|
|
251
279
|
|
|
252
280
|
async function readAnonDefinerFunctions(client, schema, role) {
|
|
@@ -262,6 +290,7 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
262
290
|
p.proconfig::text AS config,
|
|
263
291
|
pg_get_functiondef(p.oid) AS def,
|
|
264
292
|
p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
|
|
293
|
+
(SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
|
|
265
294
|
${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable
|
|
266
295
|
FROM pg_proc p
|
|
267
296
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
@@ -271,21 +300,25 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
271
300
|
ORDER BY p.proname`,
|
|
272
301
|
exists.rows.length ? [schema, caller] : [schema],
|
|
273
302
|
);
|
|
274
|
-
// The schema's
|
|
275
|
-
//
|
|
276
|
-
// call either. Read once, for
|
|
303
|
+
// The schema's helpers about the caller - is_org_admin(org) asking whether
|
|
304
|
+
// auth.uid() is an admin of it, org_role(org) answering with their role -
|
|
305
|
+
// definer or not, since a guard can call either. Read once, for doorOf. A
|
|
306
|
+
// role helper answers NULL for somebody with no role, so it is marked.
|
|
277
307
|
const { rows: yesNo } = await client.query(
|
|
278
|
-
`SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def
|
|
308
|
+
`SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def,
|
|
309
|
+
p.prorettype <> 'pg_catalog.bool'::regtype AS nullable
|
|
279
310
|
FROM pg_proc p
|
|
280
311
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
281
|
-
|
|
312
|
+
JOIN pg_type t ON t.oid = p.prorettype
|
|
313
|
+
WHERE n.nspname = $1 AND p.prokind = 'f' AND (p.prorettype = 'pg_catalog.bool'::regtype OR t.typtype = 'e')
|
|
314
|
+
ORDER BY p.proname`,
|
|
282
315
|
[schema],
|
|
283
316
|
);
|
|
284
317
|
const helpers = yesNo.filter((row) => {
|
|
285
318
|
const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
|
|
286
319
|
return /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body) &&
|
|
287
320
|
!/\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);
|
|
321
|
+
}).map((row) => ({ name: row.name, nullable: row.nullable === true }));
|
|
289
322
|
return rows.map((row) => {
|
|
290
323
|
// A definer function with no search_path pinned is a second, separate
|
|
291
324
|
// hazard: the caller can set their own search_path and make the function
|
|
@@ -309,13 +342,21 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
309
342
|
// else and writing nothing, they are not a reach. A yes/no that does not
|
|
310
343
|
// ask auth.uid() - is_admin(user_id) - answers about other people, and
|
|
311
344
|
// stays a finding.
|
|
312
|
-
|
|
345
|
+
//
|
|
346
|
+
// A role about the caller alone is the same: org_role(org), the caller's
|
|
347
|
+
// own role there, from a fixed list of labels (an enum) that can hold
|
|
348
|
+
// nothing about anybody else. Found on a blind test (HarborLine), where
|
|
349
|
+
// org_role was reported beside the functions it guards.
|
|
350
|
+
const aboutCaller = (row.returns_bool === true || row.returns_enum === true) && !writes &&
|
|
351
|
+
/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
|
|
352
|
+
const door = doorOf(body, helpers);
|
|
313
353
|
return {
|
|
314
354
|
name: row.name,
|
|
315
355
|
args: row.args || '',
|
|
316
356
|
writes: writes,
|
|
317
357
|
aboutCaller: aboutCaller,
|
|
318
|
-
guarded:
|
|
358
|
+
guarded: door.guarded,
|
|
359
|
+
nullSlips: door.nullSlips,
|
|
319
360
|
hasFixedSearchPath: hasFixedSearchPath,
|
|
320
361
|
callable: row.callable === true,
|
|
321
362
|
};
|
|
@@ -1438,6 +1479,7 @@ module.exports = {
|
|
|
1438
1479
|
readSchema: readSchema,
|
|
1439
1480
|
readAnonDefinerFunctions: readAnonDefinerFunctions,
|
|
1440
1481
|
guardedAtTheDoor: guardedAtTheDoor,
|
|
1482
|
+
doorOf: doorOf,
|
|
1441
1483
|
writeSchema: writeSchema,
|
|
1442
1484
|
diffSchemas: diffSchemas,
|
|
1443
1485
|
readPolicies: readPolicies,
|
package/tamper.js
CHANGED
|
@@ -114,6 +114,11 @@ async function tamper(client, schema, tables, seeded, policies) {
|
|
|
114
114
|
rulesFor.set(policy.table_name, list);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// The first person's row in tables that hold one for each person but name
|
|
118
|
+
// nobody (attack.seed), so a write is aimed at a row that is not the
|
|
119
|
+
// caller's own.
|
|
120
|
+
const firstRowOf = new Map((seeded || []).filter((entry) => entry.rowOfA).map((entry) => [entry.table, entry.rowOfA]));
|
|
121
|
+
|
|
117
122
|
for (const table of tables) {
|
|
118
123
|
if (!shapeFor.has(table.name)) continue; // never seeded, so nothing to protect
|
|
119
124
|
const owner = attack.ownerColumn(table);
|
|
@@ -127,9 +132,12 @@ async function tamper(client, schema, tables, seeded, policies) {
|
|
|
127
132
|
// Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
128
133
|
// customer deleting their OWN rows is not a finding, it is the feature,
|
|
129
134
|
// and an unscoped DELETE would report every correctly built app.
|
|
135
|
+
const firstRow = firstRowOf.get(table.name);
|
|
130
136
|
const theirRows = owner
|
|
131
137
|
? ' WHERE ' + quote(owner) + ' = ' + "'" + attack.USER_A + "'"
|
|
132
|
-
:
|
|
138
|
+
: firstRow
|
|
139
|
+
? ' WHERE ' + quote(firstRow.column) + "::text = '" + firstRow.value.split("'").join("''") + "'"
|
|
140
|
+
: '';
|
|
133
141
|
const at = quote(schema) + '.' + quote(table.name);
|
|
134
142
|
|
|
135
143
|
// Written under a name nobody has used, which is both what makes the
|
|
@@ -334,7 +342,8 @@ function memberships(tables) {
|
|
|
334
342
|
}
|
|
335
343
|
|
|
336
344
|
// What each kind of write is called in a rule.
|
|
337
|
-
|
|
345
|
+
// A takeover is an UPDATE too: a rule for updating is what it would hide behind.
|
|
346
|
+
const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE' };
|
|
338
347
|
|
|
339
348
|
/**
|
|
340
349
|
* Whether the app's own rules name this role among those allowed to make
|
|
@@ -478,6 +487,22 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
478
487
|
{ what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
|
|
479
488
|
{ what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
|
|
480
489
|
];
|
|
490
|
+
// On the members table itself: putting their own id on the first
|
|
491
|
+
// person's membership row - taking that member's place, and their role.
|
|
492
|
+
// Found on a blind test (HarborLine): a member could update any member
|
|
493
|
+
// row of the team, and repointing an admin's row made them admin. A rule
|
|
494
|
+
// that lets a role edit memberships does not make this the design, so
|
|
495
|
+
// it is asked of every role, listed or not.
|
|
496
|
+
if (table.name === member.table.name) {
|
|
497
|
+
const firstPersons = [quote(member.person) + " = '" + attack.USER_A + "'"]
|
|
498
|
+
.concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
|
|
499
|
+
moves.push({
|
|
500
|
+
what: 'takeover',
|
|
501
|
+
anyRole: true,
|
|
502
|
+
statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
|
|
503
|
+
values: [],
|
|
504
|
+
});
|
|
505
|
+
}
|
|
481
506
|
// Adding a row to the team, said only of a role whose own name says it
|
|
482
507
|
// may only look: a member adding rows is the feature, a viewer adding
|
|
483
508
|
// them is not. Found on a blind test (HelixOps): any member could add
|
|
@@ -534,7 +559,7 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
534
559
|
// A write the rules list this role for is not asked: whatever it
|
|
535
560
|
// did would not be reported, and a key refusing it on the way out
|
|
536
561
|
// left the table untested (HelixOps' assets, as a dispatcher).
|
|
537
|
-
if (allowedByRules(rules, table.name, move.what, role, member.roleType)) {
|
|
562
|
+
if (!move.anyRole && allowedByRules(rules, table.name, move.what, role, member.roleType)) {
|
|
538
563
|
listed = true;
|
|
539
564
|
continue;
|
|
540
565
|
}
|
|
@@ -554,7 +579,7 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
554
579
|
}
|
|
555
580
|
completed.push(key);
|
|
556
581
|
if (can.length) {
|
|
557
|
-
findings.push({
|
|
582
|
+
findings.push(Object.assign({
|
|
558
583
|
kind: 'role',
|
|
559
584
|
table: table.name,
|
|
560
585
|
who: role,
|
|
@@ -566,7 +591,7 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
566
591
|
below: below.slice(),
|
|
567
592
|
columns: (table.columns || []).map((c) => c.name),
|
|
568
593
|
rlsEnabled: table.rlsEnabled,
|
|
569
|
-
});
|
|
594
|
+
}, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {}));
|
|
570
595
|
break;
|
|
571
596
|
}
|
|
572
597
|
if (!listed) below.push(role);
|