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 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 === 'privileged' ? 'verification required' : 'confirmed',
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 === 'privileged' ? '' : cause.long,
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
- ' Every row I read is a row I put there myself a moment earlier.',
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.25",
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 six operations, so the scan does not know which one it has.
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
- line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') +
741
- ' to check - I did not attack ' + (toVerify.length === 1 ? 'it' : 'them') + ', only spotted the risk.');
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 six operations `scan.js` asks of an engine, done in SQL.
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
  };