kryptheon-night 0.1.25 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/engine.sql +353 -1
- package/finding.js +112 -3
- package/intro.js +5 -1
- package/package.json +5 -3
- package/recheck.js +4 -0
- package/scan.js +59 -5
- package/schema.js +59 -0
- package/sqlengine.js +12 -1
- package/tamper.js +218 -1
package/README.md
CHANGED
|
@@ -69,6 +69,17 @@ Four questions, asked against the copy:
|
|
|
69
69
|
| **Collision** | can the same thing exist twice? |
|
|
70
70
|
| **Interruption** | can a half-finished write survive? |
|
|
71
71
|
|
|
72
|
+
And three things it says are **yours to check**, because only you know
|
|
73
|
+
whether they are meant:
|
|
74
|
+
|
|
75
|
+
| check | the question |
|
|
76
|
+
|---|---|
|
|
77
|
+
| **Lowest role** | can a viewer in a team change what is in it? (tried on the copy, undone) |
|
|
78
|
+
| **Storage** | is a bucket public although a rule says who may read it? (settings only, never a file) |
|
|
79
|
+
| **Open functions** | can a visitor with no account call a function that skips the rules? |
|
|
80
|
+
|
|
81
|
+
These are listed apart from problems and do not change the exit code.
|
|
82
|
+
|
|
72
83
|
A problem reads like this:
|
|
73
84
|
|
|
74
85
|
CRITICAL customers
|
package/engine.sql
CHANGED
|
@@ -335,6 +335,38 @@ RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
|
335
335
|
) rows;
|
|
336
336
|
$$;
|
|
337
337
|
|
|
338
|
+
/*
|
|
339
|
+
* Supabase Storage's buckets: whether each is public, and the rules on
|
|
340
|
+
* storage.objects that say who may read it. The settings and the rules only,
|
|
341
|
+
* never a file. Run through EXECUTE because a database without Supabase
|
|
342
|
+
* Storage has no storage.buckets to name, and that failure is caught below
|
|
343
|
+
* and read as no buckets. Mirrors readBuckets in schema.js.
|
|
344
|
+
*/
|
|
345
|
+
CREATE OR REPLACE FUNCTION __KN__.read_buckets()
|
|
346
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
347
|
+
DECLARE
|
|
348
|
+
found jsonb;
|
|
349
|
+
BEGIN
|
|
350
|
+
EXECUTE $q$
|
|
351
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
352
|
+
'id', b.id::text,
|
|
353
|
+
'name', b.name::text,
|
|
354
|
+
'public', coalesce(b.public, false),
|
|
355
|
+
'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
356
|
+
'name', p.policyname::text,
|
|
357
|
+
'roles', to_jsonb(p.roles::text[]),
|
|
358
|
+
'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
|
|
359
|
+
FROM pg_policies p
|
|
360
|
+
WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
|
|
361
|
+
AND p.cmd IN ('SELECT', 'ALL')
|
|
362
|
+
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
|
|
363
|
+
) ORDER BY b.id), '[]'::jsonb)
|
|
364
|
+
FROM storage.buckets b $q$ INTO found;
|
|
365
|
+
RETURN found;
|
|
366
|
+
EXCEPTION WHEN OTHERS THEN
|
|
367
|
+
RETURN '[]'::jsonb;
|
|
368
|
+
END $$;
|
|
369
|
+
|
|
338
370
|
/* Everything needed to rebuild a schema, in one shape. */
|
|
339
371
|
CREATE OR REPLACE FUNCTION __KN__.read_schema(source text)
|
|
340
372
|
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
@@ -362,6 +394,7 @@ BEGIN
|
|
|
362
394
|
'views', __KN__.read_views(source),
|
|
363
395
|
'viewGrants', __KN__.read_view_grants(source),
|
|
364
396
|
'functions', __KN__.read_functions(source),
|
|
397
|
+
'buckets', __KN__.read_buckets(),
|
|
365
398
|
-- A foreign key pointing at something whose shape could not be read is
|
|
366
399
|
-- not something to guess at. The caller stops rather than attacking a
|
|
367
400
|
-- copy that is missing a piece.
|
|
@@ -642,6 +675,19 @@ BEGIN
|
|
|
642
675
|
parts := parts || CASE WHEN bare = src THEN '''' || target || ''', ' || part ELSE part END;
|
|
643
676
|
END LOOP;
|
|
644
677
|
def := replace(def, m[1] || m[2], m[1] || array_to_string(parts, ', '));
|
|
678
|
+
ELSE
|
|
679
|
+
-- None pinned, so a bare name in the body is looked up on the caller's
|
|
680
|
+
-- path when it runs, and that path has never heard of the copy. Found on
|
|
681
|
+
-- a blind test (OrbitDesk): the copy's has_role read the original's
|
|
682
|
+
-- members with the original's enum, and every rule calling it failed with
|
|
683
|
+
-- "operator does not exist". Pinned the way the copy was built: the copy
|
|
684
|
+
-- first, then where the original's bare names would have gone. A BEGIN
|
|
685
|
+
-- ATOMIC body has no AS line and needs none.
|
|
686
|
+
SELECT string_agg('''' || s || '''', ', ' ORDER BY first) INTO head
|
|
687
|
+
FROM (SELECT s, min(at) AS first
|
|
688
|
+
FROM unnest(ARRAY[target, src, 'public', 'extensions']) WITH ORDINALITY AS u(s, at)
|
|
689
|
+
GROUP BY s) p;
|
|
690
|
+
def := regexp_replace(def, E'\nAS ', E'\n SET search_path TO ' || head || E'\nAS ');
|
|
645
691
|
END IF;
|
|
646
692
|
RETURN __KN__.rewrite_own_table_refs(def, plan, target, copied);
|
|
647
693
|
END $$;
|
|
@@ -2316,6 +2362,308 @@ BEGIN
|
|
|
2316
2362
|
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
|
|
2317
2363
|
END $$;
|
|
2318
2364
|
|
|
2365
|
+
-- --------------------------------------------------------------------------
|
|
2366
|
+
-- What the least trusted member of a team can change.
|
|
2367
|
+
--
|
|
2368
|
+
-- Mirrors teammate in tamper.js. One fake person joins the other's team with
|
|
2369
|
+
-- the lowest role there is, for one attempt at a time, and every write is
|
|
2370
|
+
-- tried before joining and after, so only what joining added is counted.
|
|
2371
|
+
-- Reported as something to check: whether a viewer may edit is the owner's
|
|
2372
|
+
-- decision. Found on two blind tests, where five holes of this shape - "any
|
|
2373
|
+
-- member may change it", or an UPDATE rule that checks the role beside one
|
|
2374
|
+
-- that does not - went unreported.
|
|
2375
|
+
-- --------------------------------------------------------------------------
|
|
2376
|
+
|
|
2377
|
+
/* The role a viewer has, out of a team's list of roles, or null. */
|
|
2378
|
+
CREATE OR REPLACE FUNCTION __KN__.lowest_role(labels jsonb)
|
|
2379
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2380
|
+
DECLARE
|
|
2381
|
+
pattern text;
|
|
2382
|
+
found text;
|
|
2383
|
+
BEGIN
|
|
2384
|
+
-- Labels that say "may only look", tried in this order before falling back
|
|
2385
|
+
-- to the last label that is not plainly in charge.
|
|
2386
|
+
FOREACH pattern IN ARRAY ARRAY['view', 'read', 'guest', 'observ', 'audit'] LOOP
|
|
2387
|
+
SELECT l INTO found FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, at)
|
|
2388
|
+
WHERE l ~* pattern ORDER BY at LIMIT 1;
|
|
2389
|
+
IF found IS NOT NULL THEN RETURN found; END IF;
|
|
2390
|
+
END LOOP;
|
|
2391
|
+
SELECT l INTO found FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, at)
|
|
2392
|
+
WHERE l !~* 'owner|admin|super|root' ORDER BY at DESC LIMIT 1;
|
|
2393
|
+
RETURN found;
|
|
2394
|
+
END $$;
|
|
2395
|
+
|
|
2396
|
+
/*
|
|
2397
|
+
* The column holding a member's role, and its lowest value: named for a role,
|
|
2398
|
+
* with a fixed list of values.
|
|
2399
|
+
*/
|
|
2400
|
+
CREATE OR REPLACE FUNCTION __KN__.role_column_of(tab jsonb)
|
|
2401
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2402
|
+
DECLARE
|
|
2403
|
+
col jsonb;
|
|
2404
|
+
labels jsonb;
|
|
2405
|
+
lowest text;
|
|
2406
|
+
BEGIN
|
|
2407
|
+
FOR col IN SELECT * FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) LOOP
|
|
2408
|
+
CONTINUE WHEN (col->>'name') !~* 'role';
|
|
2409
|
+
IF jsonb_typeof(col->'enum_labels') = 'array' AND jsonb_array_length(col->'enum_labels') > 0 THEN
|
|
2410
|
+
labels := col->'enum_labels';
|
|
2411
|
+
ELSE
|
|
2412
|
+
labels := to_jsonb(__KN__.allowed_by_check(tab, col->>'name'));
|
|
2413
|
+
END IF;
|
|
2414
|
+
CONTINUE WHEN labels IS NULL OR jsonb_array_length(labels) < 2;
|
|
2415
|
+
lowest := __KN__.lowest_role(labels);
|
|
2416
|
+
IF lowest IS NOT NULL THEN
|
|
2417
|
+
RETURN jsonb_build_object('column', col->>'name', 'lowest', lowest);
|
|
2418
|
+
END IF;
|
|
2419
|
+
END LOOP;
|
|
2420
|
+
RETURN NULL;
|
|
2421
|
+
END $$;
|
|
2422
|
+
|
|
2423
|
+
/* Whether a column is a key on its own: one row per person, at most. */
|
|
2424
|
+
CREATE OR REPLACE FUNCTION __KN__.alone_unique(tab jsonb, column_name text)
|
|
2425
|
+
RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
|
|
2426
|
+
SELECT EXISTS (
|
|
2427
|
+
SELECT 1
|
|
2428
|
+
FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) c,
|
|
2429
|
+
LATERAL regexp_match(c->>'definition', '(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)', 'i') m
|
|
2430
|
+
WHERE c->>'kind' IN ('p', 'u')
|
|
2431
|
+
AND array_length(string_to_array(m[1], ','), 1) = 1
|
|
2432
|
+
AND replace(trim(m[1]), '"', '') = column_name);
|
|
2433
|
+
$$;
|
|
2434
|
+
|
|
2435
|
+
/*
|
|
2436
|
+
* The tables saying who is in which team, and as what: a person, a role, and
|
|
2437
|
+
* a key to a table of the app's own that is not the person. A table where the
|
|
2438
|
+
* person can appear only once is not one: nobody joins a second team there.
|
|
2439
|
+
*/
|
|
2440
|
+
CREATE OR REPLACE FUNCTION __KN__.memberships(tables jsonb)
|
|
2441
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2442
|
+
DECLARE
|
|
2443
|
+
found jsonb := '[]'::jsonb;
|
|
2444
|
+
tab jsonb;
|
|
2445
|
+
person text;
|
|
2446
|
+
role_ jsonb;
|
|
2447
|
+
keys jsonb;
|
|
2448
|
+
BEGIN
|
|
2449
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(coalesce(tables, '[]'::jsonb)) LOOP
|
|
2450
|
+
person := __KN__.owner_column(tab);
|
|
2451
|
+
-- Whoever made the row is not who is in the team: an invitation's
|
|
2452
|
+
-- created_by makes nobody a member of anything.
|
|
2453
|
+
CONTINUE WHEN person IS NULL OR person ~* '^(created_by|author_id|owner|owner_id)$';
|
|
2454
|
+
role_ := __KN__.role_column_of(tab);
|
|
2455
|
+
CONTINUE WHEN role_ IS NULL;
|
|
2456
|
+
SELECT coalesce(jsonb_agg(k ORDER BY at), '[]'::jsonb) INTO keys
|
|
2457
|
+
FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables))
|
|
2458
|
+
WITH ORDINALITY AS e(k, at)
|
|
2459
|
+
WHERE k->>'refTable' <> tab->>'name'
|
|
2460
|
+
AND EXISTS (SELECT 1 FROM jsonb_array_elements(tables) t WHERE t->>'name' = k->>'refTable')
|
|
2461
|
+
AND NOT (k->'columns' ? person);
|
|
2462
|
+
CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
|
|
2463
|
+
found := found || jsonb_build_array(jsonb_build_object(
|
|
2464
|
+
'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest', 'keys', keys));
|
|
2465
|
+
END LOOP;
|
|
2466
|
+
-- A table named for members first: it is the one a team rule asks.
|
|
2467
|
+
SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
|
|
2468
|
+
FROM jsonb_array_elements(found) WITH ORDINALITY AS e(m, at);
|
|
2469
|
+
RETURN found;
|
|
2470
|
+
END $$;
|
|
2471
|
+
|
|
2472
|
+
/*
|
|
2473
|
+
* try_write, with something the copy's owner does first inside the same
|
|
2474
|
+
* subtransaction - putting a person into a team - so it is undone with the
|
|
2475
|
+
* write. A function of its own rather than a fourth argument: an installed
|
|
2476
|
+
* engine upgraded in place would keep the old three-argument one beside it,
|
|
2477
|
+
* and every call would become ambiguous.
|
|
2478
|
+
*/
|
|
2479
|
+
CREATE OR REPLACE FUNCTION __KN__.try_write_joined(role_ text, identity text, statement text, join_ text)
|
|
2480
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
2481
|
+
DECLARE
|
|
2482
|
+
claims text := CASE WHEN identity IS NULL
|
|
2483
|
+
THEN json_build_object('role', role_)::text
|
|
2484
|
+
ELSE json_build_object('sub', identity, 'role', role_)::text END;
|
|
2485
|
+
moved bigint := 0;
|
|
2486
|
+
worked boolean := false;
|
|
2487
|
+
why text := NULL;
|
|
2488
|
+
BEGIN
|
|
2489
|
+
BEGIN
|
|
2490
|
+
EXECUTE join_;
|
|
2491
|
+
EXECUTE 'SET LOCAL statement_timeout = ' || quote_literal('15s');
|
|
2492
|
+
EXECUTE 'SET LOCAL ROLE ' || quote_ident(role_);
|
|
2493
|
+
PERFORM set_config('request.jwt.claims', claims, true);
|
|
2494
|
+
|
|
2495
|
+
EXECUTE statement;
|
|
2496
|
+
GET DIAGNOSTICS moved = ROW_COUNT;
|
|
2497
|
+
worked := true;
|
|
2498
|
+
|
|
2499
|
+
RAISE EXCEPTION 'kryptheon: undoing the write' USING ERRCODE = 'KN001';
|
|
2500
|
+
EXCEPTION
|
|
2501
|
+
WHEN SQLSTATE 'KN001' THEN
|
|
2502
|
+
NULL;
|
|
2503
|
+
WHEN OTHERS THEN
|
|
2504
|
+
worked := false;
|
|
2505
|
+
moved := 0;
|
|
2506
|
+
why := SQLERRM;
|
|
2507
|
+
END;
|
|
2508
|
+
|
|
2509
|
+
RESET ROLE;
|
|
2510
|
+
PERFORM set_config('request.jwt.claims', '', true);
|
|
2511
|
+
PERFORM set_config('statement_timeout', '0', true);
|
|
2512
|
+
RETURN jsonb_build_object('ok', worked, 'count', moved, 'why', why);
|
|
2513
|
+
END $$;
|
|
2514
|
+
|
|
2515
|
+
/* A write's rows, or null when it decided nothing. */
|
|
2516
|
+
CREATE OR REPLACE FUNCTION __KN__.rows_moved(result jsonb)
|
|
2517
|
+
RETURNS bigint LANGUAGE sql IMMUTABLE AS $$
|
|
2518
|
+
SELECT CASE __KN__.what_happened(result)
|
|
2519
|
+
WHEN 'untested' THEN NULL
|
|
2520
|
+
WHEN 'got through' THEN (result->>'count')::bigint
|
|
2521
|
+
ELSE 0 END;
|
|
2522
|
+
$$;
|
|
2523
|
+
|
|
2524
|
+
CREATE OR REPLACE FUNCTION __KN__.teammate(source text, tables jsonb, seeded jsonb)
|
|
2525
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
2526
|
+
DECLARE
|
|
2527
|
+
findings jsonb := '[]'::jsonb;
|
|
2528
|
+
completed jsonb := '[]'::jsonb;
|
|
2529
|
+
blocked jsonb := '[]'::jsonb;
|
|
2530
|
+
done jsonb := '[]'::jsonb;
|
|
2531
|
+
member jsonb;
|
|
2532
|
+
tab jsonb;
|
|
2533
|
+
shape integer;
|
|
2534
|
+
at text;
|
|
2535
|
+
theirs jsonb;
|
|
2536
|
+
overrides jsonb;
|
|
2537
|
+
key jsonb;
|
|
2538
|
+
name text;
|
|
2539
|
+
parent text;
|
|
2540
|
+
as_ text;
|
|
2541
|
+
row_ jsonb;
|
|
2542
|
+
join_ text;
|
|
2543
|
+
refused text;
|
|
2544
|
+
owner text;
|
|
2545
|
+
not_theirs text;
|
|
2546
|
+
target text;
|
|
2547
|
+
column_ text;
|
|
2548
|
+
finding_key text;
|
|
2549
|
+
move record;
|
|
2550
|
+
was bigint;
|
|
2551
|
+
now_ bigint;
|
|
2552
|
+
before_ jsonb;
|
|
2553
|
+
after_ jsonb;
|
|
2554
|
+
can jsonb;
|
|
2555
|
+
changed jsonb;
|
|
2556
|
+
stuck text;
|
|
2557
|
+
BEGIN
|
|
2558
|
+
FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
|
|
2559
|
+
SELECT (s->>'attempt')::integer INTO shape
|
|
2560
|
+
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2561
|
+
WHERE s->>'table' = member->'table'->>'name';
|
|
2562
|
+
CONTINUE WHEN NOT FOUND;
|
|
2563
|
+
shape := coalesce(shape, 0);
|
|
2564
|
+
at := __KN__.always_quote(source) || '.' || __KN__.always_quote(member->'table'->>'name');
|
|
2565
|
+
BEGIN
|
|
2566
|
+
EXECUTE 'SELECT to_jsonb(t) FROM ' || at || ' t WHERE ' || __KN__.always_quote(member->>'person') ||
|
|
2567
|
+
' = ' || quote_literal(__KN__.user_a()) || ' LIMIT 1' INTO theirs;
|
|
2568
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2569
|
+
theirs := NULL;
|
|
2570
|
+
END;
|
|
2571
|
+
CONTINUE WHEN theirs IS NULL;
|
|
2572
|
+
|
|
2573
|
+
-- Their team, as the first person's own membership names it.
|
|
2574
|
+
overrides := jsonb_build_object(member->>'role', member->>'lowest');
|
|
2575
|
+
FOR key IN SELECT * FROM jsonb_array_elements(member->'keys') LOOP
|
|
2576
|
+
FOR name IN SELECT jsonb_array_elements_text(key->'columns') LOOP
|
|
2577
|
+
IF name <> member->>'person' THEN
|
|
2578
|
+
overrides := jsonb_set(overrides, ARRAY[name], coalesce(theirs->name, 'null'::jsonb));
|
|
2579
|
+
END IF;
|
|
2580
|
+
END LOOP;
|
|
2581
|
+
END LOOP;
|
|
2582
|
+
parent := member->'keys'->0->>'refTable';
|
|
2583
|
+
as_ := 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name');
|
|
2584
|
+
|
|
2585
|
+
-- Whether they can be put in the team at all, tried once on its own.
|
|
2586
|
+
refused := NULL;
|
|
2587
|
+
BEGIN
|
|
2588
|
+
row_ := __KN__.row_for(source, member->'table', __KN__.user_b(), '9', overrides, shape);
|
|
2589
|
+
join_ := __KN__.insert_statement(source, member->'table'->>'name', row_);
|
|
2590
|
+
BEGIN
|
|
2591
|
+
EXECUTE join_;
|
|
2592
|
+
RAISE EXCEPTION 'kryptheon: undoing the join' USING ERRCODE = 'KN001';
|
|
2593
|
+
EXCEPTION WHEN SQLSTATE 'KN001' THEN
|
|
2594
|
+
NULL;
|
|
2595
|
+
END;
|
|
2596
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2597
|
+
refused := SQLERRM;
|
|
2598
|
+
END;
|
|
2599
|
+
IF refused IS NOT NULL THEN
|
|
2600
|
+
-- Said once, on the table that would not take them.
|
|
2601
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2602
|
+
'table', member->'table'->>'name',
|
|
2603
|
+
'key', 'role:' || (member->'table'->>'name') || ':' || (member->>'lowest'),
|
|
2604
|
+
'why', as_ || ': I could not add one: ' || refused));
|
|
2605
|
+
CONTINUE;
|
|
2606
|
+
END IF;
|
|
2607
|
+
|
|
2608
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2609
|
+
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2610
|
+
WHERE s->>'table' = tab->>'name');
|
|
2611
|
+
CONTINUE WHEN done ? (tab->>'name');
|
|
2612
|
+
done := done || to_jsonb(tab->>'name');
|
|
2613
|
+
finding_key := 'role:' || (tab->>'name') || ':' || (member->>'lowest');
|
|
2614
|
+
owner := __KN__.owner_column(tab);
|
|
2615
|
+
-- Never their own rows: changing what you made yourself is not a role
|
|
2616
|
+
-- question, and the row that put them in the team is theirs.
|
|
2617
|
+
not_theirs := CASE WHEN owner IS NULL THEN ''
|
|
2618
|
+
ELSE ' WHERE ' || __KN__.always_quote(owner) || ' IS DISTINCT FROM ' || quote_literal(__KN__.user_b()) END;
|
|
2619
|
+
target := __KN__.always_quote(source) || '.' || __KN__.always_quote(tab->>'name');
|
|
2620
|
+
column_ := __KN__.always_quote(__KN__.first_writable(tab));
|
|
2621
|
+
can := '[]'::jsonb;
|
|
2622
|
+
changed := '{}'::jsonb;
|
|
2623
|
+
stuck := NULL;
|
|
2624
|
+
FOR move IN
|
|
2625
|
+
SELECT * FROM (VALUES
|
|
2626
|
+
(1, 'change', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
|
|
2627
|
+
(2, 'delete', 'DELETE FROM ' || target || not_theirs)) AS m(n, what, statement)
|
|
2628
|
+
ORDER BY n
|
|
2629
|
+
LOOP
|
|
2630
|
+
before_ := __KN__.try_write('authenticated', __KN__.user_b(), move.statement);
|
|
2631
|
+
after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move.statement, join_);
|
|
2632
|
+
was := __KN__.rows_moved(before_);
|
|
2633
|
+
now_ := __KN__.rows_moved(after_);
|
|
2634
|
+
IF was IS NULL OR now_ IS NULL THEN
|
|
2635
|
+
stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
|
|
2636
|
+
ELSIF now_ > was THEN
|
|
2637
|
+
can := can || to_jsonb(move.what);
|
|
2638
|
+
changed := jsonb_set(changed, ARRAY[move.what], to_jsonb(now_ - was));
|
|
2639
|
+
END IF;
|
|
2640
|
+
END LOOP;
|
|
2641
|
+
|
|
2642
|
+
IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
|
|
2643
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
2644
|
+
'table', tab->>'name', 'key', finding_key, 'why', as_ || ': ' || stuck));
|
|
2645
|
+
CONTINUE;
|
|
2646
|
+
END IF;
|
|
2647
|
+
completed := completed || to_jsonb(finding_key);
|
|
2648
|
+
IF jsonb_array_length(can) > 0 THEN
|
|
2649
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2650
|
+
'kind', 'role',
|
|
2651
|
+
'table', tab->>'name',
|
|
2652
|
+
'who', member->>'lowest',
|
|
2653
|
+
'via', member->'table'->>'name',
|
|
2654
|
+
'roleColumn', member->>'role',
|
|
2655
|
+
'parent', parent,
|
|
2656
|
+
'can', can,
|
|
2657
|
+
'changed', changed,
|
|
2658
|
+
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
2659
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
2660
|
+
END IF;
|
|
2661
|
+
END LOOP;
|
|
2662
|
+
END LOOP;
|
|
2663
|
+
|
|
2664
|
+
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
|
|
2665
|
+
END $$;
|
|
2666
|
+
|
|
2319
2667
|
-- --------------------------------------------------------------------------
|
|
2320
2668
|
-- The interruption attack: can a half-finished write survive?
|
|
2321
2669
|
--
|
|
@@ -2776,6 +3124,7 @@ DECLARE
|
|
|
2776
3124
|
sown jsonb;
|
|
2777
3125
|
seen jsonb;
|
|
2778
3126
|
wrote jsonb;
|
|
3127
|
+
ranked jsonb;
|
|
2779
3128
|
stranded jsonb;
|
|
2780
3129
|
raced jsonb;
|
|
2781
3130
|
findings jsonb := '[]'::jsonb;
|
|
@@ -2829,10 +3178,11 @@ BEGIN
|
|
|
2829
3178
|
|
|
2830
3179
|
seen := __KN__.impersonate(copy_name, theirs || views);
|
|
2831
3180
|
wrote := __KN__.tamper(copy_name, theirs, sown->'seeded', copy_plan->'policies');
|
|
3181
|
+
ranked := __KN__.teammate(copy_name, theirs, sown->'seeded');
|
|
2832
3182
|
stranded := __KN__.orphan(copy_name, theirs, sown->'seeded');
|
|
2833
3183
|
raced := __KN__.collide(copy_name, theirs, copy_plan->'indexes');
|
|
2834
3184
|
|
|
2835
|
-
findings := (seen->'findings') || (wrote->'findings') || (stranded->'findings');
|
|
3185
|
+
findings := (seen->'findings') || (wrote->'findings') || (ranked->'findings') || (stranded->'findings');
|
|
2836
3186
|
|
|
2837
3187
|
-- A rule that looks itself up breaks the app for whoever it fails for.
|
|
2838
3188
|
-- One finding per table at fault, however many reads ran into it; the
|
|
@@ -2851,6 +3201,7 @@ BEGIN
|
|
|
2851
3201
|
-- fixed from one whose attack simply did not run this time.
|
|
2852
3202
|
attempted := coalesce(seen->'completed', '[]'::jsonb)
|
|
2853
3203
|
|| coalesce(wrote->'completed', '[]'::jsonb)
|
|
3204
|
+
|| coalesce(ranked->'completed', '[]'::jsonb)
|
|
2854
3205
|
|| coalesce(stranded->'completed', '[]'::jsonb);
|
|
2855
3206
|
|
|
2856
3207
|
-- And everything that was not. Said out loud, never left out: a table
|
|
@@ -2858,6 +3209,7 @@ BEGIN
|
|
|
2858
3209
|
not_checked := coalesce(sown->'skipped', '[]'::jsonb)
|
|
2859
3210
|
|| coalesce(seen->'blocked', '[]'::jsonb)
|
|
2860
3211
|
|| coalesce(wrote->'blocked', '[]'::jsonb)
|
|
3212
|
+
|| coalesce(ranked->'blocked', '[]'::jsonb)
|
|
2861
3213
|
|| coalesce(stranded->'notTried', '[]'::jsonb)
|
|
2862
3214
|
|| coalesce(raced->'notTried', '[]'::jsonb);
|
|
2863
3215
|
|
package/finding.js
CHANGED
|
@@ -88,6 +88,11 @@ function listOf(items) {
|
|
|
88
88
|
return list.slice(0, -1).join(', ') + ' and ' + list[list.length - 1];
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** One of a table's things: workspaces -> workspace, companies -> company. */
|
|
92
|
+
function oneOf(table) {
|
|
93
|
+
return String(table || 'team').replace(/ies$/, 'y').replace(/s$/, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
/* --------------------------------------------------------------------------
|
|
92
97
|
How bad it is.
|
|
93
98
|
-------------------------------------------------------------------------- */
|
|
@@ -105,6 +110,11 @@ function severityOf(finding, contents) {
|
|
|
105
110
|
// because the app is broken for whoever it fails for, but not a break-in.
|
|
106
111
|
return 'HIGH';
|
|
107
112
|
}
|
|
113
|
+
if (finding.kind === 'role' || finding.kind === 'bucket') {
|
|
114
|
+
// Something to check, never a confirmed break: whether a viewer may edit,
|
|
115
|
+
// or a bucket may be public, is the owner's decision. So never CRITICAL.
|
|
116
|
+
return 'HIGH';
|
|
117
|
+
}
|
|
108
118
|
if (finding.kind === 'privileged') {
|
|
109
119
|
// Not proven, so never CRITICAL: this is a reach that exists, not a break
|
|
110
120
|
// that was demonstrated. A function anon can call that also writes is worth
|
|
@@ -332,6 +342,15 @@ function headlineFor(finding) {
|
|
|
332
342
|
if (finding.kind === 'privileged') {
|
|
333
343
|
return 'Anyone on the internet can call your ' + finding.fn + ' function, and it runs with full rights.';
|
|
334
344
|
}
|
|
345
|
+
if (finding.kind === 'role') {
|
|
346
|
+
const order = ['delete', 'change', 'add'];
|
|
347
|
+
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
348
|
+
return 'A "' + finding.who + '" in a ' + oneOf(finding.parent) + ' can ' + listOf(does) + ' your ' +
|
|
349
|
+
finding.table + ' table.';
|
|
350
|
+
}
|
|
351
|
+
if (finding.kind === 'bucket') {
|
|
352
|
+
return 'Your ' + finding.table + ' storage bucket is public, but a rule says only some people may read it.';
|
|
353
|
+
}
|
|
335
354
|
if (finding.kind === 'exposed') {
|
|
336
355
|
return 'Your ' + finding.table + ' ' + (finding.isView ? 'view' : 'table') + ' can be read by anyone.';
|
|
337
356
|
}
|
|
@@ -415,6 +434,36 @@ function bodyFor(finding, contents) {
|
|
|
415
434
|
'kept, and your live app was never touched.';
|
|
416
435
|
}
|
|
417
436
|
|
|
437
|
+
if (finding.kind === 'role') {
|
|
438
|
+
// Said as what was done, and then whose decision it is: a viewer who may
|
|
439
|
+
// edit is a hole in one app and the design of another.
|
|
440
|
+
const did = [];
|
|
441
|
+
const rows = (n) => n + (n === 1 ? ' row' : ' rows');
|
|
442
|
+
if ((finding.can || []).includes('change')) did.push('changed ' + rows((finding.changed || {}).change || 1));
|
|
443
|
+
if ((finding.can || []).includes('delete')) did.push('deleted ' + rows((finding.changed || {}).delete || 1));
|
|
444
|
+
const place = oneOf(finding.parent);
|
|
445
|
+
return (
|
|
446
|
+
'I put one of my fake people into the other one\'s ' + place + ' as a "' + finding.who +
|
|
447
|
+
'" - the lowest role in ' + finding.via + '.' + finding.roleColumn + ' - and, signed in as them, I ' +
|
|
448
|
+
listOf(did) + ' in ' + finding.table + ' that belonged to that ' + place + ', on a copy of your app. ' +
|
|
449
|
+
'Before joining, they could not. Every one of those was undone straight away. Whether a "' + finding.who +
|
|
450
|
+
'" may do this is your decision: if it is meant to, nothing needs fixing; if a "' + finding.who +
|
|
451
|
+
'" should only look, the rules on this table let them do more.'
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (finding.kind === 'bucket') {
|
|
456
|
+
// Said as what was read, never as a file taken: no file was opened.
|
|
457
|
+
return (
|
|
458
|
+
'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
|
|
459
|
+
'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
|
|
460
|
+
'You also wrote ' + ((finding.rules || []).length === 1 ? 'a rule' : 'rules') + ' saying who may read it (' +
|
|
461
|
+
listOf(finding.rules || []) + '), so it was probably meant to be private. I only read the ' +
|
|
462
|
+
'bucket\'s setting and its rules; I did not open or list any file. Whether it should be public ' +
|
|
463
|
+
'is yours to say.'
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
418
467
|
if (finding.kind === 'privileged') {
|
|
419
468
|
// Said as what is true, not as what was done - nothing was executed. The
|
|
420
469
|
// reach is real; whether it is a mistake is the person's to say, so the
|
|
@@ -694,6 +743,50 @@ function fixPromptFor(finding) {
|
|
|
694
743
|
'Then sign in as an ordinary user and open the pages that use these tables, and run this ' +
|
|
695
744
|
'check again - the tables it could not test will be tested this time.',
|
|
696
745
|
]
|
|
746
|
+
: finding.kind === 'role'
|
|
747
|
+
? [
|
|
748
|
+
'My app may have a permissions problem - please check it rather than assume it.',
|
|
749
|
+
'',
|
|
750
|
+
'In my database, a member of a ' + oneOf(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
751
|
+
finding.roleColumn + '" is "' + finding.who + '" can ' +
|
|
752
|
+
listOf((finding.can || []).map((what) => ({ change: 'change', delete: 'delete' })[what])) +
|
|
753
|
+
' rows of that ' + oneOf(finding.parent) + ' in the "' + finding.table + '" table. This was proved ' +
|
|
754
|
+
'on a copy of the database: a "' + finding.who + '" was added, and the writes went through.',
|
|
755
|
+
'',
|
|
756
|
+
'First tell me: should a "' + finding.who + '" be able to do that? If yes, change nothing.',
|
|
757
|
+
'',
|
|
758
|
+
'If not, make the rules on "' + finding.table + '" that cover UPDATE, DELETE, INSERT or ALL check ' +
|
|
759
|
+
'the member\'s role as well as membership - for example with a helper that reads "' + finding.via +
|
|
760
|
+
'" for auth.uid() and the roles allowed to edit. Postgres lets a write through if ANY permissive ' +
|
|
761
|
+
'policy allows it, so a strict rule beside a loose one does nothing: list every such policy on "' +
|
|
762
|
+
finding.table + '" and narrow or drop the loose one. Keep the read rule as it is if a "' +
|
|
763
|
+
finding.who + '" should still see these rows.',
|
|
764
|
+
'',
|
|
765
|
+
'Then check every other table where members can write for the same thing.',
|
|
766
|
+
]
|
|
767
|
+
: finding.kind === 'bucket'
|
|
768
|
+
? [
|
|
769
|
+
'My app may have a storage problem - please check it rather than assume it.',
|
|
770
|
+
'',
|
|
771
|
+
'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
|
|
772
|
+
'download it without logging in, and the storage.objects ' +
|
|
773
|
+
((finding.rules || []).length === 1 ? 'policy that names' : 'policies that name') + ' this bucket (' +
|
|
774
|
+
listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
|
|
775
|
+
((finding.rules || []).length === 1 ? 'is' : 'are') + ' not checked for public URLs - so ' +
|
|
776
|
+
((finding.rules || []).length === 1 ? 'it protects' : 'they protect') + ' nothing while it is public.',
|
|
777
|
+
'',
|
|
778
|
+
'First tell me: is every file in this bucket meant to be downloadable by anyone? If yes, change nothing.',
|
|
779
|
+
'',
|
|
780
|
+
'If not, make the bucket private (in the Supabase dashboard: Storage, edit the bucket, turn Public ' +
|
|
781
|
+
'off - or UPDATE storage.buckets SET public = false WHERE id = \'' + finding.bucket + '\'). Then ' +
|
|
782
|
+
'change the app to fetch these files with signed URLs (createSignedUrl) instead of getPublicUrl, ' +
|
|
783
|
+
'or the links it shows will stop working.',
|
|
784
|
+
'',
|
|
785
|
+
'Keep the read rule: once the bucket is private it is what decides who gets a file. Check that it ' +
|
|
786
|
+
'only lets in the right people.',
|
|
787
|
+
'',
|
|
788
|
+
'Then look at every other bucket for the same thing.',
|
|
789
|
+
]
|
|
697
790
|
: finding.kind === 'privileged'
|
|
698
791
|
? [
|
|
699
792
|
'My app may have a security problem - please check it rather than assume it.',
|
|
@@ -863,6 +956,10 @@ function fixPromptFor(finding) {
|
|
|
863
956
|
.join('\n');
|
|
864
957
|
}
|
|
865
958
|
|
|
959
|
+
// Found and said, but never claimed as a break: the owner decides. Counted
|
|
960
|
+
// apart in the report, never towards the exit code or a clean re-check.
|
|
961
|
+
const TO_CHECK = ['privileged', 'role', 'bucket'];
|
|
962
|
+
|
|
866
963
|
/** Everything the report needs about one thing the attack found. */
|
|
867
964
|
function describe(finding) {
|
|
868
965
|
const contents = readContents(finding.columns);
|
|
@@ -873,7 +970,7 @@ function describe(finding) {
|
|
|
873
970
|
// one the tool reasons about without executing, so it says so. The report
|
|
874
971
|
// and the re-check both read this: a "verification required" finding is
|
|
875
972
|
// never counted towards a clean re-check on its own.
|
|
876
|
-
status: finding.kind
|
|
973
|
+
status: TO_CHECK.includes(finding.kind) ? 'verification required' : 'confirmed',
|
|
877
974
|
table: finding.kind === 'privileged' ? finding.fn : finding.table,
|
|
878
975
|
fn: finding.fn,
|
|
879
976
|
args: finding.args,
|
|
@@ -892,14 +989,26 @@ function describe(finding) {
|
|
|
892
989
|
// For a privileged function there is no "why the door is open" the way a
|
|
893
990
|
// table has one; the body already carries the whole explanation, so the
|
|
894
991
|
// cause line would only repeat it. Left empty and skipped by the report.
|
|
895
|
-
cause: finding.kind
|
|
992
|
+
cause: TO_CHECK.includes(finding.kind) ? '' : cause.long,
|
|
896
993
|
who: finding.who,
|
|
897
994
|
can: finding.can,
|
|
995
|
+
via: finding.via,
|
|
996
|
+
roleColumn: finding.roleColumn,
|
|
997
|
+
bucket: finding.bucket,
|
|
898
998
|
proof: finding.kind === 'recursive'
|
|
899
999
|
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
900
1000
|
loopError(finding) + '.'
|
|
901
1001
|
: finding.kind === 'privileged'
|
|
902
1002
|
? 'I did not call "' + finding.fn + '". This is a reach that exists in the grants, not a break I ran.'
|
|
1003
|
+
: finding.kind === 'role'
|
|
1004
|
+
? 'As a "' + finding.who + '" who had just joined, I ' +
|
|
1005
|
+
listOf((finding.can || []).map((what) => {
|
|
1006
|
+
const n = (finding.changed || {})[what] || 1;
|
|
1007
|
+
return (what === 'change' ? 'changed ' : 'deleted ') + n + (n === 1 ? ' row' : ' rows');
|
|
1008
|
+
})) + ' in "' + finding.table + '" that I could not touch before joining. All of it was undone.'
|
|
1009
|
+
: finding.kind === 'bucket'
|
|
1010
|
+
? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
|
1011
|
+
' says who may read it. I did not open any file.'
|
|
903
1012
|
: finding.kind === 'duplicated'
|
|
904
1013
|
? 'I created ' + finding.copies + ' rows in "' + finding.table + '" holding the same ' +
|
|
905
1014
|
finding.column + '.'
|
|
@@ -1004,7 +1113,7 @@ function describeAll(findings) {
|
|
|
1004
1113
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
1005
1114
|
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
1006
1115
|
// fails for, which matters more than a duplicate or a stray row.
|
|
1007
|
-
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, privileged: 5 };
|
|
1116
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, duplicated: 3, orphaned: 4, role: 4.5, bucket: 4.7, privileged: 5 };
|
|
1008
1117
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
1009
1118
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
1010
1119
|
}
|
package/intro.js
CHANGED
|
@@ -78,6 +78,9 @@ function consentLines(target) {
|
|
|
78
78
|
' - Look at the shape of your "' + target + '" tables: their names, their',
|
|
79
79
|
' columns, and the rules about who is allowed to see what.',
|
|
80
80
|
'',
|
|
81
|
+
' - Look at your file storage settings: which buckets are public, and',
|
|
82
|
+
' the rules about who may open their files. Never a file itself.',
|
|
83
|
+
'',
|
|
81
84
|
' - Make one new temporary space inside your database and rebuild that',
|
|
82
85
|
' same shape in it. Your real tables are not changed.',
|
|
83
86
|
'',
|
|
@@ -91,7 +94,8 @@ function consentLines(target) {
|
|
|
91
94
|
'What I will not do:',
|
|
92
95
|
'',
|
|
93
96
|
' - I do not read your real data. Not one customer, order or message.',
|
|
94
|
-
'
|
|
97
|
+
' Apart from the storage settings above, every row I read is a row I',
|
|
98
|
+
' put there myself a moment earlier.',
|
|
95
99
|
'',
|
|
96
100
|
' - I do not change your real tables. Only the temporary copy.',
|
|
97
101
|
'',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
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 cli.check.js && node loop.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node 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",
|
|
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",
|
|
@@ -71,7 +71,9 @@
|
|
|
71
71
|
"check:twin": "node twin.check.js",
|
|
72
72
|
"prepublishOnly": "node package.check.js",
|
|
73
73
|
"check:engines": "node engines.check.js",
|
|
74
|
-
"check:installer": "node installer.check.js"
|
|
74
|
+
"check:installer": "node installer.check.js",
|
|
75
|
+
"check:roles": "node roles.check.js",
|
|
76
|
+
"check:buckets": "node buckets.check.js"
|
|
75
77
|
},
|
|
76
78
|
"keywords": [
|
|
77
79
|
"supabase",
|
package/recheck.js
CHANGED
|
@@ -172,6 +172,8 @@ function whatOf(item) {
|
|
|
172
172
|
if (item.kind === 'duplicated') return 'the same ' + (item.column || 'value') + ' twice';
|
|
173
173
|
if (item.kind === 'orphaned') return 'rows left pointing at nothing';
|
|
174
174
|
if (item.kind === 'privileged') return 'a function anyone can call';
|
|
175
|
+
if (item.kind === 'role') return 'changed by a "' + (item.who || 'viewer') + '"';
|
|
176
|
+
if (item.kind === 'bucket') return 'a public storage bucket';
|
|
175
177
|
if (item.kind === 'recursive') return 'a rule that refers to itself';
|
|
176
178
|
return item.kind;
|
|
177
179
|
}
|
|
@@ -196,6 +198,8 @@ function describe(result) {
|
|
|
196
198
|
// was seen, not as an attack that was refused. Found on a fix test.
|
|
197
199
|
const how = item.kind === 'privileged'
|
|
198
200
|
? 'I looked again, and a visitor with no account can no longer call it.'
|
|
201
|
+
: item.kind === 'bucket'
|
|
202
|
+
? 'I looked again, and the bucket is no longer public.'
|
|
199
203
|
: item.kind === 'recursive'
|
|
200
204
|
? 'I read it again, and its rules answered instead of stopping with an error.'
|
|
201
205
|
: 'I ran the same attack again and it was refused.';
|
package/scan.js
CHANGED
|
@@ -128,7 +128,7 @@ function suggest(schemas) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* The engine, as
|
|
131
|
+
* The engine, as seven operations, so the scan does not know which one it has.
|
|
132
132
|
*
|
|
133
133
|
* `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
|
|
134
134
|
* directly, which meant the SQL engine could only ever be reached by the twin
|
|
@@ -153,6 +153,7 @@ function nodeEngine() {
|
|
|
153
153
|
tamper: (client, into, tables, seeded, policies) =>
|
|
154
154
|
tamper.tamper(client, into, tables, seeded, policies),
|
|
155
155
|
orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
|
|
156
|
+
teammate: (client, into, tables, seeded) => tamper.teammate(client, into, tables, seeded),
|
|
156
157
|
};
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -302,6 +303,14 @@ async function scan(client, sourceSchema, options) {
|
|
|
302
303
|
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
// What the least trusted member of a team can change there. Rolled back
|
|
307
|
+
// like the writes above, and the joining with them.
|
|
308
|
+
const ranked = await engine.teammate(client, copyName, theirs, sown.seeded);
|
|
309
|
+
for (const key of ranked.completed) attempted.push(key);
|
|
310
|
+
for (const stuck of ranked.blocked) {
|
|
311
|
+
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
312
|
+
}
|
|
313
|
+
|
|
305
314
|
// Can a half-finished write survive? Rolled back like the writes above.
|
|
306
315
|
say(' Looking for rows that could point at nothing ...');
|
|
307
316
|
const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
|
|
@@ -395,6 +404,13 @@ async function scan(client, sourceSchema, options) {
|
|
|
395
404
|
for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
|
|
396
405
|
const privileged = privilegedOf(plan);
|
|
397
406
|
|
|
407
|
+
// Storage buckets switched to public that also have a rule saying who may
|
|
408
|
+
// read them. Read from the bucket settings and the rules, never a file.
|
|
409
|
+
// Every bucket read counts as examined, so one made private comes back
|
|
410
|
+
// from a re-check as fixed.
|
|
411
|
+
for (const bucket of plan.buckets || []) attempted.push('bucket:' + bucket.name);
|
|
412
|
+
const buckets = bucketsOf(plan);
|
|
413
|
+
|
|
398
414
|
// Ties read from the original schema: the copy points auth.users keys at
|
|
399
415
|
// a stand-in, and would call every table untied. Owners read from the
|
|
400
416
|
// copy, the way seeding did.
|
|
@@ -406,7 +422,7 @@ async function scan(client, sourceSchema, options) {
|
|
|
406
422
|
notChecked: notChecked,
|
|
407
423
|
attempted: attempted,
|
|
408
424
|
findings: finding.describeAll(
|
|
409
|
-
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
|
|
425
|
+
impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced, privileged, buckets, recursive).map(tagged),
|
|
410
426
|
),
|
|
411
427
|
};
|
|
412
428
|
} finally {
|
|
@@ -477,6 +493,37 @@ function privilegedOf(plan) {
|
|
|
477
493
|
}));
|
|
478
494
|
}
|
|
479
495
|
|
|
496
|
+
/**
|
|
497
|
+
* A public bucket whose read rule says it was meant to be private.
|
|
498
|
+
*
|
|
499
|
+
* Found on two blind tests (OrbitDesk, AtlasPay): a bucket of exports and one
|
|
500
|
+
* of settlement evidence were switched to public. A public bucket hands any
|
|
501
|
+
* file to whoever has its link, and the rules on storage.objects are not asked.
|
|
502
|
+
* A rule that only lets in some people is how the app says the files are not
|
|
503
|
+
* for everyone - so a public bucket with one is worth a look. A rule that lets
|
|
504
|
+
* in everyone anyway (anon or public, and nothing but the bucket's name) says
|
|
505
|
+
* the opposite, and is not reported.
|
|
506
|
+
*/
|
|
507
|
+
function bucketsOf(plan) {
|
|
508
|
+
const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
509
|
+
const narrows = (rule, id) => {
|
|
510
|
+
const roles = schema.roleList(rule.roles);
|
|
511
|
+
const toEveryone = roles.includes('anon') || roles.includes('public');
|
|
512
|
+
const rest = String(rule.qual || '')
|
|
513
|
+
.replace(new RegExp("bucket_id\\s*=\\s*'" + escape(id) + "'(::text)?", 'gi'), '')
|
|
514
|
+
.replace(new RegExp("'" + escape(id) + "'(::text)?\\s*=\\s*bucket_id", 'gi'), '')
|
|
515
|
+
.replace(/\btrue\b|\band\b|[()\s]/gi, '');
|
|
516
|
+
return !toEveryone || rest !== '';
|
|
517
|
+
};
|
|
518
|
+
return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => ({
|
|
519
|
+
kind: 'bucket',
|
|
520
|
+
table: b.name,
|
|
521
|
+
bucket: b.id,
|
|
522
|
+
rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
|
|
523
|
+
columns: [],
|
|
524
|
+
})).filter((f) => f.rules.length);
|
|
525
|
+
}
|
|
526
|
+
|
|
480
527
|
/**
|
|
481
528
|
* What each finding needs to know about its table for its fix to be right:
|
|
482
529
|
* whether the app signs people in through Supabase, whether a row says whose
|
|
@@ -567,7 +614,7 @@ function nightlyRecord(latest, readAt, plan) {
|
|
|
567
614
|
const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
|
|
568
615
|
for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
|
|
569
616
|
}
|
|
570
|
-
const now = plan ? privilegedOf(plan).map(tag) : [];
|
|
617
|
+
const now = plan ? privilegedOf(plan).concat(bucketsOf(plan)).map(tag) : [];
|
|
571
618
|
return Object.assign(record, {
|
|
572
619
|
ranAt: at(run.ran_at),
|
|
573
620
|
source: run.source,
|
|
@@ -737,8 +784,14 @@ function report(result) {
|
|
|
737
784
|
line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
|
|
738
785
|
}
|
|
739
786
|
if (toVerify.length) {
|
|
740
|
-
|
|
741
|
-
|
|
787
|
+
// A viewer who can edit was really tried; a function or a bucket was only
|
|
788
|
+
// read. Either way it is the owner's call, and the line says which.
|
|
789
|
+
const tried = toVerify.some((f) => f.kind === 'role');
|
|
790
|
+
const spotted = toVerify.some((f) => f.kind !== 'role');
|
|
791
|
+
const it = toVerify.length === 1 ? 'it' : 'them';
|
|
792
|
+
line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
|
|
793
|
+
(spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
|
|
794
|
+
: 'whether ' + (toVerify.length === 1 ? 'it is a problem' : 'each is a problem') + ' is your decision.'));
|
|
742
795
|
}
|
|
743
796
|
|
|
744
797
|
for (const item of result.findings) {
|
|
@@ -887,6 +940,7 @@ module.exports = {
|
|
|
887
940
|
scheduleTimes: scheduleTimes,
|
|
888
941
|
notCheckedFromNight: notCheckedFromNight,
|
|
889
942
|
privilegedOf: privilegedOf,
|
|
943
|
+
bucketsOf: bucketsOf,
|
|
890
944
|
loopingTables: loopingTables,
|
|
891
945
|
taggerFor: taggerFor,
|
|
892
946
|
saveNightly: saveNightly,
|
package/schema.js
CHANGED
|
@@ -356,6 +356,48 @@ async function readFunctions(client, schema) {
|
|
|
356
356
|
return rows;
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Supabase Storage's buckets: whether each is public, and the rules on
|
|
361
|
+
* storage.objects that say who may read it (a rule naming the bucket's id).
|
|
362
|
+
*
|
|
363
|
+
* The settings and the rules only - never a file, never a file's name. A
|
|
364
|
+
* database without Supabase Storage, or one this connection cannot read it in,
|
|
365
|
+
* has none. Mirrors read_buckets in engine.sql, query for query.
|
|
366
|
+
*
|
|
367
|
+
* Asked first rather than left to fail: a failed query inside a caller's
|
|
368
|
+
* transaction would abort everything after it.
|
|
369
|
+
*/
|
|
370
|
+
async function readBuckets(client) {
|
|
371
|
+
try {
|
|
372
|
+
const here = await client.query(
|
|
373
|
+
`SELECT to_regclass('storage.buckets') IS NOT NULL
|
|
374
|
+
AND EXISTS (SELECT 1 FROM information_schema.columns
|
|
375
|
+
WHERE table_schema = 'storage' AND table_name = 'buckets' AND column_name = 'public') AS found`,
|
|
376
|
+
);
|
|
377
|
+
if (!here.rows[0].found) return [];
|
|
378
|
+
const { rows } = await client.query(BUCKETS_QUERY);
|
|
379
|
+
return rows[0].buckets;
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const BUCKETS_QUERY =
|
|
386
|
+
`SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
387
|
+
'id', b.id::text,
|
|
388
|
+
'name', b.name::text,
|
|
389
|
+
'public', coalesce(b.public, false),
|
|
390
|
+
'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
391
|
+
'name', p.policyname::text,
|
|
392
|
+
'roles', to_jsonb(p.roles::text[]),
|
|
393
|
+
'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
|
|
394
|
+
FROM pg_policies p
|
|
395
|
+
WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
|
|
396
|
+
AND p.cmd IN ('SELECT', 'ALL')
|
|
397
|
+
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
|
|
398
|
+
) ORDER BY b.id), '[]'::jsonb) AS buckets
|
|
399
|
+
FROM storage.buckets b`;
|
|
400
|
+
|
|
359
401
|
async function readPolicies(client, schema) {
|
|
360
402
|
const { rows } = await client.query(
|
|
361
403
|
`SELECT tablename AS table_name,
|
|
@@ -632,6 +674,7 @@ async function readSchema(client, schema) {
|
|
|
632
674
|
external: external,
|
|
633
675
|
anonFunctions: await readAnonDefinerFunctions(client, schema),
|
|
634
676
|
functions: await readFunctions(client, schema),
|
|
677
|
+
buckets: await readBuckets(client),
|
|
635
678
|
unsupported: unsupported,
|
|
636
679
|
};
|
|
637
680
|
}
|
|
@@ -764,11 +807,26 @@ function copyFunctionStatement(fn, plan, target, copiedNames) {
|
|
|
764
807
|
() => 'CREATE FUNCTION ' + quote(target) + '.',
|
|
765
808
|
);
|
|
766
809
|
// The settings line, e.g. SET search_path TO 'public', 'storage'
|
|
810
|
+
const pinned = /^\s*SET search_path (?:TO|=) /m.test(def);
|
|
767
811
|
def = def.replace(/^(\s*SET search_path (?:TO|=) )(.*)$/m, (all, head, list) =>
|
|
768
812
|
head + list.split(',').map((part) => {
|
|
769
813
|
const bare = part.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
|
|
770
814
|
return bare === plan.schema ? "'" + target + "', " + part.trim() : part.trim();
|
|
771
815
|
}).join(', '));
|
|
816
|
+
// None pinned, so a bare name in the body is looked up on the caller's path
|
|
817
|
+
// when it runs - and the caller's path has never heard of the copy. Found on
|
|
818
|
+
// a blind test (OrbitDesk): member_of and has_role read `workspace_members`
|
|
819
|
+
// bare, so the copy's helpers read the original's members, with the
|
|
820
|
+
// original's workspace_role, against the copy's. Every rule calling has_role
|
|
821
|
+
// failed with "operator does not exist", 13 attacks never ran, and a loop
|
|
822
|
+
// through member_of never fired because the original's table was empty.
|
|
823
|
+
// So the copy's helper is pinned the way the copy itself was built: the copy
|
|
824
|
+
// first, then where the original's bare names would have gone. A BEGIN
|
|
825
|
+
// ATOMIC body has no AS line and needs none - it was bound when it was made.
|
|
826
|
+
if (!pinned) {
|
|
827
|
+
const path = Array.from(new Set([target, plan.schema, 'public', 'extensions']));
|
|
828
|
+
def = def.replace(/\nAS /, '\n SET search_path TO ' + path.map((s) => "'" + s + "'").join(', ') + '\nAS ');
|
|
829
|
+
}
|
|
772
830
|
return rewriteOwnTableRefs(def, plan, target, copiedNames);
|
|
773
831
|
}
|
|
774
832
|
|
|
@@ -1383,6 +1441,7 @@ module.exports = {
|
|
|
1383
1441
|
writeSchema: writeSchema,
|
|
1384
1442
|
diffSchemas: diffSchemas,
|
|
1385
1443
|
readPolicies: readPolicies,
|
|
1444
|
+
readBuckets: readBuckets,
|
|
1386
1445
|
readTypes: readTypes,
|
|
1387
1446
|
readSequenceGrants: readSequenceGrants,
|
|
1388
1447
|
readIndexes: readIndexes,
|
package/sqlengine.js
CHANGED
|
@@ -114,6 +114,15 @@ async function tamper(client, target, into, tables, seeded, policies) {
|
|
|
114
114
|
return rows[0].answer;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** What the least trusted member of a team can change. Rolled back inside the engine. */
|
|
118
|
+
async function teammate(client, target, into, tables, seeded) {
|
|
119
|
+
const { rows } = await client.query(
|
|
120
|
+
'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb) AS answer',
|
|
121
|
+
[into, JSON.stringify(tables), JSON.stringify(seeded)],
|
|
122
|
+
);
|
|
123
|
+
return rows[0].answer;
|
|
124
|
+
}
|
|
125
|
+
|
|
117
126
|
/** Can a half-finished row survive? */
|
|
118
127
|
async function orphan(client, target, into, tables, seeded) {
|
|
119
128
|
const { rows } = await client.query(
|
|
@@ -141,7 +150,7 @@ async function collide(client, target, into, tables, indexes) {
|
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
/**
|
|
144
|
-
* The same
|
|
153
|
+
* The same seven operations `scan.js` asks of an engine, done in SQL.
|
|
145
154
|
*
|
|
146
155
|
* Handed an engine schema that is already installed, because installing and
|
|
147
156
|
* removing it is the caller's business - `withEngine` below - and a scan
|
|
@@ -162,6 +171,7 @@ function adapterFor(target) {
|
|
|
162
171
|
tamper: (client, into, tables, sown, policies) =>
|
|
163
172
|
tamper(client, target, into, tables, sown, policies),
|
|
164
173
|
orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
|
|
174
|
+
teammate: (client, into, tables, sown) => teammate(client, target, into, tables, sown),
|
|
165
175
|
};
|
|
166
176
|
}
|
|
167
177
|
|
|
@@ -194,6 +204,7 @@ module.exports = {
|
|
|
194
204
|
impersonate: impersonate,
|
|
195
205
|
tamper: tamper,
|
|
196
206
|
orphan: orphan,
|
|
207
|
+
teammate: teammate,
|
|
197
208
|
collide: collide,
|
|
198
209
|
adapterFor: adapterFor,
|
|
199
210
|
withEngine: withEngine,
|
package/tamper.js
CHANGED
|
@@ -47,9 +47,13 @@ const ACTORS = [
|
|
|
47
47
|
* The rollback is not a tidy-up, it is the safety property. Nothing this
|
|
48
48
|
* attack does survives the statement that did it.
|
|
49
49
|
*/
|
|
50
|
-
async function tryWrite(client, role, identity, statement, values) {
|
|
50
|
+
async function tryWrite(client, role, identity, statement, values, first) {
|
|
51
51
|
await client.query('BEGIN');
|
|
52
52
|
try {
|
|
53
|
+
// Something the copy's owner does first, inside the same transaction, so
|
|
54
|
+
// it is undone with the write: putting a person into a team for one
|
|
55
|
+
// attempt (see teammate below).
|
|
56
|
+
if (first) await client.query(first.statement, first.values || []);
|
|
53
57
|
await client.query("SET LOCAL statement_timeout = '15s'");
|
|
54
58
|
await client.query('SET LOCAL role TO ' + role);
|
|
55
59
|
await client.query('SELECT set_config($1, $2, true)', [
|
|
@@ -217,6 +221,215 @@ function firstWritable(table) {
|
|
|
217
221
|
return (plain || usable[0] || { name: 'id' }).name;
|
|
218
222
|
}
|
|
219
223
|
|
|
224
|
+
/*
|
|
225
|
+
* What the least trusted member of a team can change.
|
|
226
|
+
*
|
|
227
|
+
* Everything above is one customer against another. Found on two blind tests
|
|
228
|
+
* (OrbitDesk, AtlasPay): a rule written "members of this workspace may change
|
|
229
|
+
* it" lets a viewer change it too, and an UPDATE rule that checks the role is
|
|
230
|
+
* undone by a second one beside it that does not - Postgres lets a write
|
|
231
|
+
* through if any permissive rule allows it. Five planted holes of this shape
|
|
232
|
+
* went unreported, because both fake people were the owner of their own team
|
|
233
|
+
* and nobody else was ever in it.
|
|
234
|
+
*
|
|
235
|
+
* So one of them joins the other's team with the lowest role there is, for
|
|
236
|
+
* one attempt at a time, and tries to change what is in it. Whether a viewer
|
|
237
|
+
* may edit is the owner's decision, so this is reported as something to
|
|
238
|
+
* check, never as a confirmed break.
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
// Labels that say "may only look", tried in this order before falling back
|
|
242
|
+
// to the last label that is not plainly in charge.
|
|
243
|
+
const LEAST_TRUSTED = [/view/i, /read/i, /guest/i, /observ/i, /audit/i];
|
|
244
|
+
const IN_CHARGE = /owner|admin|super|root/i;
|
|
245
|
+
// Owner columns that name who made a row rather than who is in it.
|
|
246
|
+
const CREATOR = /^(created_by|author_id|owner|owner_id)$/i;
|
|
247
|
+
|
|
248
|
+
/** The role a viewer has, out of a team's list of roles, or null. */
|
|
249
|
+
function lowestRole(labels) {
|
|
250
|
+
for (const pattern of LEAST_TRUSTED) {
|
|
251
|
+
const found = labels.find((label) => pattern.test(label));
|
|
252
|
+
if (found) return found;
|
|
253
|
+
}
|
|
254
|
+
const plain = labels.filter((label) => !IN_CHARGE.test(label));
|
|
255
|
+
return plain.length ? plain[plain.length - 1] : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The column holding a member's role, and its lowest value: named for a role,
|
|
260
|
+
* with a fixed list of values (an enum, or a CHECK listing them).
|
|
261
|
+
*/
|
|
262
|
+
function roleColumnOf(table) {
|
|
263
|
+
for (const column of table.columns || []) {
|
|
264
|
+
if (!/role/i.test(column.name)) continue;
|
|
265
|
+
const labels = Array.isArray(column.enum_labels) && column.enum_labels.length
|
|
266
|
+
? column.enum_labels
|
|
267
|
+
: attack.allowedByCheck(table, column.name);
|
|
268
|
+
if (labels.length < 2) continue;
|
|
269
|
+
const lowest = lowestRole(labels);
|
|
270
|
+
if (lowest) return { column: column.name, lowest: lowest };
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether a column is a key on its own: one row per person, at most. */
|
|
276
|
+
function aloneUnique(table, column) {
|
|
277
|
+
return (table.constraints || []).some((constraint) => {
|
|
278
|
+
if (constraint.kind !== 'p' && constraint.kind !== 'u') return false;
|
|
279
|
+
const match = /(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)/i.exec(String(constraint.definition || ''));
|
|
280
|
+
if (!match) return false;
|
|
281
|
+
const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
|
|
282
|
+
return columns.length === 1 && columns[0] === column;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The tables saying who is in which team, and as what: a person, a role, and
|
|
288
|
+
* a key to a table of the app's own that is not the person. A table where the
|
|
289
|
+
* person can appear only once (profiles.org_id) is not one: nobody joins a
|
|
290
|
+
* second team there, so there is no one to add.
|
|
291
|
+
*/
|
|
292
|
+
function memberships(tables) {
|
|
293
|
+
const found = [];
|
|
294
|
+
const names = new Set((tables || []).map((table) => table.name));
|
|
295
|
+
for (const table of tables || []) {
|
|
296
|
+
const person = attack.ownerColumn(table);
|
|
297
|
+
// Whoever made the row is not who is in the team. Found on a blind test:
|
|
298
|
+
// workspace_invites (created_by, role, workspace_id) was taken for the
|
|
299
|
+
// members table, and an invitation made nobody a member of anything.
|
|
300
|
+
if (!person || CREATOR.test(person)) continue;
|
|
301
|
+
const role = roleColumnOf(table);
|
|
302
|
+
if (!role) continue;
|
|
303
|
+
const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
304
|
+
.filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
|
|
305
|
+
if (!keys.length || aloneUnique(table, person)) continue;
|
|
306
|
+
found.push({ table: table, person: person, role: role.column, lowest: role.lowest, keys: keys });
|
|
307
|
+
}
|
|
308
|
+
// A table named for members first: it is the one a team rule asks.
|
|
309
|
+
const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
|
|
310
|
+
return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* For every team table: can a viewer change it?
|
|
315
|
+
*
|
|
316
|
+
* The other fake person joins the first one's team as its lowest role, and
|
|
317
|
+
* every write is tried twice - before joining and after - so only what joining
|
|
318
|
+
* added is counted: a hole open to every customer is already reported above,
|
|
319
|
+
* and a person changing their own rows is the feature. Both rolled back, the
|
|
320
|
+
* joining with them.
|
|
321
|
+
*/
|
|
322
|
+
async function teammate(client, schema, tables, seeded) {
|
|
323
|
+
const findings = [];
|
|
324
|
+
const completed = [];
|
|
325
|
+
const blocked = [];
|
|
326
|
+
const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
|
|
327
|
+
const done = new Set();
|
|
328
|
+
|
|
329
|
+
for (const member of memberships(tables)) {
|
|
330
|
+
if (!shapeFor.has(member.table.name)) continue;
|
|
331
|
+
const at = quote(schema) + '.' + quote(member.table.name);
|
|
332
|
+
let theirs = null;
|
|
333
|
+
try {
|
|
334
|
+
theirs = (await client.query('SELECT * FROM ' + at + ' WHERE ' + quote(member.person) + ' = $1 LIMIT 1',
|
|
335
|
+
[attack.USER_A])).rows[0] || null;
|
|
336
|
+
} catch (err) {
|
|
337
|
+
theirs = null;
|
|
338
|
+
}
|
|
339
|
+
if (!theirs) continue;
|
|
340
|
+
|
|
341
|
+
// Their team, as the first person's own membership names it.
|
|
342
|
+
const overrides = {};
|
|
343
|
+
overrides[member.role] = member.lowest;
|
|
344
|
+
for (const key of member.keys) {
|
|
345
|
+
key.columns.forEach((name) => { if (name !== member.person) overrides[name] = theirs[name]; });
|
|
346
|
+
}
|
|
347
|
+
const parent = member.keys[0].refTable;
|
|
348
|
+
const as = 'as a "' + member.lowest + '" in ' + member.table.name;
|
|
349
|
+
let join = null;
|
|
350
|
+
let refused = null;
|
|
351
|
+
try {
|
|
352
|
+
const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
|
|
353
|
+
join = {
|
|
354
|
+
statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
355
|
+
row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
|
|
356
|
+
values: row.values,
|
|
357
|
+
};
|
|
358
|
+
// Whether they can be put in the team at all, tried once on its own.
|
|
359
|
+
await client.query('BEGIN');
|
|
360
|
+
try {
|
|
361
|
+
await client.query(join.statement, join.values);
|
|
362
|
+
} finally {
|
|
363
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
refused = err.message;
|
|
367
|
+
}
|
|
368
|
+
if (refused) {
|
|
369
|
+
// Said once, on the table that would not take them: every team table
|
|
370
|
+
// went untested for the same reason.
|
|
371
|
+
blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
|
|
372
|
+
why: as + ': I could not add one: ' + refused });
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const table of tables) {
|
|
377
|
+
if (!shapeFor.has(table.name) || done.has(table.name)) continue;
|
|
378
|
+
done.add(table.name);
|
|
379
|
+
const key = 'role:' + table.name + ':' + member.lowest;
|
|
380
|
+
const owner = attack.ownerColumn(table);
|
|
381
|
+
// Never their own rows: changing what you made yourself is not a role
|
|
382
|
+
// question, and the row that put them in the team is theirs.
|
|
383
|
+
const notTheirs = owner ? ' WHERE ' + quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'" : '';
|
|
384
|
+
const target = quote(schema) + '.' + quote(table.name);
|
|
385
|
+
const column = quote(firstWritable(table));
|
|
386
|
+
const moves = [
|
|
387
|
+
{ what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs },
|
|
388
|
+
{ what: 'delete', statement: 'DELETE FROM ' + target + notTheirs },
|
|
389
|
+
];
|
|
390
|
+
const can = [];
|
|
391
|
+
const changed = {};
|
|
392
|
+
let stuck = null;
|
|
393
|
+
for (const move of moves) {
|
|
394
|
+
const before = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, []);
|
|
395
|
+
const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, [], join);
|
|
396
|
+
const counted = (result) => {
|
|
397
|
+
const outcome = whatHappened(result);
|
|
398
|
+
return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
|
|
399
|
+
};
|
|
400
|
+
const was = counted(before);
|
|
401
|
+
const now = counted(after);
|
|
402
|
+
if (was === null || now === null) {
|
|
403
|
+
stuck = now === null ? after.why : before.why;
|
|
404
|
+
} else if (now > was) {
|
|
405
|
+
can.push(move.what);
|
|
406
|
+
changed[move.what] = now - was;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!can.length && stuck) {
|
|
410
|
+
blocked.push({ table: table.name, key: key, why: as + ': ' + stuck });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
completed.push(key);
|
|
414
|
+
if (can.length) {
|
|
415
|
+
findings.push({
|
|
416
|
+
kind: 'role',
|
|
417
|
+
table: table.name,
|
|
418
|
+
who: member.lowest,
|
|
419
|
+
via: member.table.name,
|
|
420
|
+
roleColumn: member.role,
|
|
421
|
+
parent: parent,
|
|
422
|
+
can: can,
|
|
423
|
+
changed: changed,
|
|
424
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
425
|
+
rlsEnabled: table.rlsEnabled,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { findings: findings, completed: completed, blocked: blocked };
|
|
431
|
+
}
|
|
432
|
+
|
|
220
433
|
module.exports = {
|
|
221
434
|
ACTORS: ACTORS,
|
|
222
435
|
tryWrite: tryWrite,
|
|
@@ -224,4 +437,8 @@ module.exports = {
|
|
|
224
437
|
firstWritable: firstWritable,
|
|
225
438
|
writeAsFor: writeAsFor,
|
|
226
439
|
tamper: tamper,
|
|
440
|
+
lowestRole: lowestRole,
|
|
441
|
+
roleColumnOf: roleColumnOf,
|
|
442
|
+
memberships: memberships,
|
|
443
|
+
teammate: teammate,
|
|
227
444
|
};
|