kryptheon-night 0.1.37 → 0.1.38

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/engine.sql CHANGED
@@ -368,7 +368,17 @@ BEGIN
368
368
  FROM pg_policies p
369
369
  WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
370
370
  AND p.cmd IN ('SELECT', 'ALL')
371
- AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
371
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
372
+ -- The rules for putting files in and taking them out (schema.js BUCKETS_QUERY).
373
+ 'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
374
+ 'name', p.policyname::text,
375
+ 'roles', to_jsonb(p.roles::text[]),
376
+ 'qual', p.qual,
377
+ 'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
378
+ FROM pg_policies p
379
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
380
+ AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
381
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
372
382
  ) ORDER BY b.id), '[]'::jsonb)
373
383
  FROM storage.buckets b $q$ INTO found;
374
384
  RETURN found;
@@ -516,6 +526,7 @@ DECLARE
516
526
  rest text;
517
527
  narrowing jsonb;
518
528
  no_rule boolean;
529
+ writers jsonb;
519
530
  found jsonb := '[]'::jsonb;
520
531
  BEGIN
521
532
  FOR b IN SELECT * FROM jsonb_array_elements(coalesce(buckets, '[]'::jsonb)) LOOP
@@ -531,12 +542,23 @@ BEGIN
531
542
  narrowing := narrowing || to_jsonb(rule->>'name');
532
543
  END IF;
533
544
  END LOOP;
545
+ -- Rules for putting files in or taking them out that only signed-in
546
+ -- people pass (scan.js bucketsOf; blind round 15).
547
+ writers := '[]'::jsonb;
548
+ IF jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0 THEN
549
+ SELECT coalesce(jsonb_agg(w->>'name' ORDER BY ord), '[]'::jsonb) INTO writers
550
+ FROM jsonb_array_elements(coalesce(b->'writeRules', '[]'::jsonb)) WITH ORDINALITY AS e(w, ord)
551
+ WHERE (NOT (coalesce(w->'roles', '[]'::jsonb) ? 'anon' OR coalesce(w->'roles', '[]'::jsonb) ? 'public')
552
+ OR (coalesce(w->>'qual', '') || ' ' || coalesce(w->>'with_check', '')) ~* '\mauth\s*\.\s*(uid|jwt)\s*\(')
553
+ AND (coalesce(w->>'qual', '') || ' ' || coalesce(w->>'with_check', '')) ~* '\mfrom\s+';
554
+ END IF;
534
555
  no_rule := jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0
535
- AND (b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)';
556
+ AND ((b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)'
557
+ OR jsonb_array_length(writers) > 0);
536
558
  IF jsonb_array_length(narrowing) > 0 OR no_rule THEN
537
559
  found := found || jsonb_build_array(jsonb_build_object(
538
560
  'kind', 'bucket', 'table', b->>'name', 'bucket', b->>'id',
539
- 'rules', narrowing, 'noRule', no_rule, 'columns', '[]'::jsonb));
561
+ 'rules', narrowing, 'noRule', no_rule, 'writers', writers, 'columns', '[]'::jsonb));
540
562
  END IF;
541
563
  END LOOP;
542
564
  RETURN found;
@@ -2902,7 +2924,14 @@ BEGIN
2902
2924
  jsonb_build_array(lowest) || coalesce((
2903
2925
  SELECT jsonb_agg(l ORDER BY pos DESC)
2904
2926
  FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
2905
- WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb));
2927
+ WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb),
2928
+ -- The role at the top: an owner if there is one, else the first in charge.
2929
+ 'top', coalesce(
2930
+ (SELECT l FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos) WHERE l ~* 'owner' ORDER BY pos LIMIT 1),
2931
+ (SELECT l FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos) WHERE l ~* 'owner|admin|super|root' ORDER BY pos LIMIT 1)),
2932
+ -- Every role in charge, in the order the list gives them.
2933
+ 'inCharge', coalesce((SELECT jsonb_agg(l ORDER BY pos) FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
2934
+ WHERE l ~* 'owner|admin|super|root'), '[]'::jsonb));
2906
2935
  END IF;
2907
2936
  END LOOP;
2908
2937
  RETURN NULL;
@@ -2968,7 +2997,7 @@ BEGIN
2968
2997
  CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
2969
2998
  found := found || jsonb_build_array(jsonb_build_object(
2970
2999
  'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
2971
- 'roleType', role_->>'type', 'ladder', role_->'ladder', 'keys', keys));
3000
+ 'roleType', role_->>'type', 'ladder', role_->'ladder', 'top', role_->'top', 'inCharge', role_->'inCharge', 'keys', keys));
2972
3001
  END LOOP;
2973
3002
  -- A table named for members first: it is the one a team rule asks.
2974
3003
  SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
@@ -3047,7 +3076,8 @@ DECLARE
3047
3076
  '"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
3048
3077
  in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
3049
3078
  -- A takeover is an UPDATE too (tamper.js COMMAND).
3050
- command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' WHEN 'takeover' THEN 'UPDATE' END;
3079
+ command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE'
3080
+ WHEN 'takeover' THEN 'UPDATE' WHEN 'promote' THEN 'UPDATE' END;
3051
3081
  policy jsonb;
3052
3082
  body text;
3053
3083
  BEGIN
@@ -3119,6 +3149,9 @@ DECLARE
3119
3149
  before_all jsonb;
3120
3150
  below jsonb;
3121
3151
  listed boolean;
3152
+ their_team text;
3153
+ plain_ bigint;
3154
+ label_ text;
3122
3155
  BEGIN
3123
3156
  FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
3124
3157
  SELECT (s->>'attempt')::integer INTO shape
@@ -3242,6 +3275,17 @@ BEGIN
3242
3275
  moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
3243
3276
  'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
3244
3277
  || quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
3278
+ -- Their own membership row, given the role at the top (tamper.js;
3279
+ -- blind round 15: a viewer set their own role to 'owner').
3280
+ IF member->>'top' IS NOT NULL THEN
3281
+ where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())];
3282
+ FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
3283
+ where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
3284
+ END LOOP;
3285
+ moves := moves || jsonb_build_array(jsonb_build_object('what', 'promote', 'anyRole', true,
3286
+ 'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'role') || ' = '
3287
+ || quote_literal(member->>'top') || ' WHERE ' || array_to_string(where_, ' AND ')));
3288
+ END IF;
3245
3289
  END IF;
3246
3290
 
3247
3291
  -- Adding a row to the team, said only of a role whose own name says it
@@ -3325,12 +3369,59 @@ BEGIN
3325
3369
  'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
3326
3370
  || CASE WHEN can ? 'takeover'
3327
3371
  THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
3372
+ ELSE '{}'::jsonb END
3373
+ || CASE WHEN can ? 'promote'
3374
+ THEN jsonb_build_object('promote', true, 'toRole', member->>'top')
3328
3375
  ELSE '{}'::jsonb END);
3329
3376
  EXIT;
3330
3377
  END IF;
3331
3378
  IF NOT listed THEN below := below || to_jsonb(role_); END IF;
3332
3379
  END LOOP;
3333
3380
 
3381
+ -- In charge of their OWN team, reading the first person's (tamper.js;
3382
+ -- blind round 15: a read rule's role check not tied to the row's
3383
+ -- organization let an admin of any organization read every one). Only
3384
+ -- what the role added counts.
3385
+ their_team := NULL;
3386
+ IF EXISTS (SELECT 1 FROM jsonb_object_keys(pointed)) THEN
3387
+ SELECT string_agg(__KN__.always_quote(k) || ' = ' || quote_literal(pointed->>k), ' AND ' ORDER BY k)
3388
+ INTO their_team FROM jsonb_object_keys(pointed) k;
3389
+ ELSIF owner IS NOT NULL THEN
3390
+ their_team := __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a());
3391
+ END IF;
3392
+ IF their_team IS NOT NULL AND jsonb_array_length(coalesce(member->'inCharge', '[]'::jsonb)) > 0 THEN
3393
+ look := 'SELECT 1 FROM ' || target || ' WHERE ' || their_team;
3394
+ plain_ := __KN__.rows_moved(__KN__.try_write('authenticated', __KN__.user_b(), look));
3395
+ FOR label_ IN SELECT jsonb_array_elements_text(member->'inCharge') LOOP
3396
+ finding_key := 'elevated:' || (tab->>'name') || ':' || label_;
3397
+ now_ := __KN__.rows_moved(__KN__.try_write_joined('authenticated', __KN__.user_b(), look,
3398
+ 'UPDATE ' || __KN__.always_quote(source) || '.' || __KN__.always_quote(member->'table'->>'name') || ' SET '
3399
+ || __KN__.always_quote(member->>'role') || ' = ' || quote_literal(label_) || ' WHERE '
3400
+ || __KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())));
3401
+ IF plain_ IS NULL OR now_ IS NULL THEN
3402
+ blocked := blocked || jsonb_build_array(jsonb_build_object('table', tab->>'name', 'key', finding_key,
3403
+ 'why', 'as "' || label_ || '" in their own ' || parent || ' row: reading it failed'));
3404
+ EXIT;
3405
+ END IF;
3406
+ completed := completed || to_jsonb(finding_key);
3407
+ IF now_ > plain_ THEN
3408
+ findings := findings || jsonb_build_array(jsonb_build_object(
3409
+ 'kind', 'crossed',
3410
+ 'table', tab->>'name',
3411
+ 'owner', coalesce(owner, (SELECT k FROM jsonb_object_keys(pointed) k ORDER BY k LIMIT 1)),
3412
+ 'tenant', owner IS NULL,
3413
+ 'readable', now_ - plain_,
3414
+ 'asRole', label_,
3415
+ 'via', member->'table'->>'name',
3416
+ 'roleColumn', member->>'role',
3417
+ 'parent', parent,
3418
+ 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
3419
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
3420
+ EXIT;
3421
+ END IF;
3422
+ END LOOP;
3423
+ END IF;
3424
+
3334
3425
  -- Reading what a member should not see, said only of a table holding a
3335
3426
  -- secret. Every role not in charge, from the lowest up; the first that
3336
3427
  -- can read is the one named.
@@ -3855,6 +3946,26 @@ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3855
3946
  AND m.name IS NOT NULL;
3856
3947
  $$;
3857
3948
 
3949
+ /* Tables nobody can add a row to: every permissive rule for adding rows is a
3950
+ bare INSERT rule, with neither USING nor WITH CHECK, which Postgres reads as
3951
+ "no" (scan.js noInsertsOf; blind round 15's tickets_insert). */
3952
+ CREATE OR REPLACE FUNCTION __KN__.no_insert_findings(plan jsonb)
3953
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3954
+ WITH rules AS (
3955
+ SELECT p->>'table_name' AS t, p->>'name' AS n,
3956
+ upper(coalesce(p->>'cmd', 'ALL')) = 'INSERT' AND p->>'qual' IS NULL AND p->>'with_check' IS NULL AS bare
3957
+ FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
3958
+ WHERE upper(coalesce(p->>'permissive', 'PERMISSIVE')) = 'PERMISSIVE'
3959
+ AND upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')
3960
+ ), per AS (
3961
+ SELECT t, jsonb_agg(n ORDER BY n COLLATE "C") FILTER (WHERE bare) AS names, bool_and(bare) AS all_bare
3962
+ FROM rules GROUP BY t
3963
+ )
3964
+ SELECT coalesce(jsonb_agg(jsonb_build_object('kind', 'noinsert', 'table', t, 'rules', names, 'columns', '[]'::jsonb)
3965
+ ORDER BY t COLLATE "C"), '[]'::jsonb)
3966
+ FROM per WHERE all_bare;
3967
+ $$;
3968
+
3858
3969
  /* The ones of those that still take an empty value. */
3859
3970
  CREATE OR REPLACE FUNCTION __KN__.empty_choice_findings(plan jsonb)
3860
3971
  RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
@@ -4131,6 +4242,7 @@ BEGIN
4131
4242
  || __KN__.search_path_findings(plan)
4132
4243
  || refused
4133
4244
  || __KN__.empty_choice_findings(plan)
4245
+ || __KN__.no_insert_findings(plan)
4134
4246
  || __KN__.bucket_findings(plan->'buckets');
4135
4247
 
4136
4248
  -- Everything genuinely attacked, so a re-check can tell a finding that was
@@ -4149,7 +4261,10 @@ BEGIN
4149
4261
  || coalesce((SELECT jsonb_agg('conflict:' || (f->>'name'))
4150
4262
  FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) f), '[]'::jsonb)
4151
4263
  || coalesce((SELECT jsonb_agg('nullcheck:' || (x->>'table') || ':' || (x->'column'->>'name'))
4152
- FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb);
4264
+ FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb)
4265
+ || coalesce((SELECT jsonb_agg(DISTINCT 'noinsert:' || (p->>'table_name'))
4266
+ FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
4267
+ WHERE upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')), '[]'::jsonb);
4153
4268
 
4154
4269
  -- And everything that was not. Said out loud, never left out: a table
4155
4270
  -- nothing could be put into reads exactly like a table nothing got out of.
package/finding.js CHANGED
@@ -126,12 +126,15 @@ function severityOf(finding, contents) {
126
126
  // because the app is broken for whoever it fails for, but not a break-in.
127
127
  return 'HIGH';
128
128
  }
129
+ // A member who can make themselves the owner: never the design, and the
130
+ // owner's reach is everything - the one role finding that is confirmed.
131
+ if (finding.kind === 'role' && finding.promote) return 'CRITICAL';
129
132
  if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
130
133
  // Something to check, never a confirmed break: whether a viewer may edit,
131
134
  // or a bucket may be public, is the owner's decision. So never CRITICAL.
132
135
  return 'HIGH';
133
136
  }
134
- if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck') {
137
+ if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck' || finding.kind === 'noinsert') {
135
138
  // Read from how a function or a column is declared. A refused upsert
136
139
  // breaks the app for whoever calls it; an open search_path and an empty
137
140
  // value are for the owner to weigh. None of them hands a row to anybody.
@@ -373,10 +376,17 @@ function headlineFor(finding) {
373
376
  if (finding.kind === 'nullcheck') {
374
377
  return 'Your ' + finding.table + ' table accepts rows with no ' + finding.column + ' at all.';
375
378
  }
379
+ if (finding.kind === 'noinsert') {
380
+ return 'Nobody can add a row to your ' + finding.table + ' table: its rule for adding rows checks nothing, and Postgres reads that as no.';
381
+ }
376
382
  if (finding.kind === 'role' && finding.takeover) {
377
383
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
378
384
  finding.table + ' table - and with it their role.';
379
385
  }
386
+ if (finding.kind === 'role' && finding.promote) {
387
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can make themselves ' + aRole(finding.toRole) +
388
+ ' by changing their own row in your ' + finding.table + ' table.';
389
+ }
380
390
  if (finding.kind === 'role') {
381
391
  const order = ['delete', 'change', 'add'];
382
392
  const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
@@ -387,6 +397,9 @@ function headlineFor(finding) {
387
397
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
388
398
  ' table, which holds ' + listOf(finding.secrets || []) + '.';
389
399
  }
400
+ if (finding.kind === 'bucket' && finding.noRule && (finding.writers || []).length) {
401
+ return 'Your ' + finding.table + ' storage bucket is public, though only signed-in members may put files in it.';
402
+ }
390
403
  if (finding.kind === 'bucket' && finding.noRule) {
391
404
  return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
392
405
  }
@@ -399,6 +412,10 @@ function headlineFor(finding) {
399
412
  // The table name goes in front of the sentence rather than inside it. Put
400
413
  // inside, a table called `customers` produced "One customer can read another
401
414
  // customer's customers", which is the kind of line that loses a reader.
415
+ if (finding.asRole) {
416
+ return capital(aRole(finding.asRole)) + ' of any ' + oneOf(finding.parent) + ' can read every other ' + oneOf(finding.parent) +
417
+ '\'s rows in your ' + finding.table + ' table.';
418
+ }
402
419
  if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
403
420
  return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
404
421
  }
@@ -491,6 +508,19 @@ function bodyFor(finding, contents) {
491
508
  );
492
509
  }
493
510
 
511
+ if (finding.kind === 'role' && finding.promote) {
512
+ // Never the design: a member who can raise their own role has every
513
+ // right the top role has, in every table that trusts the role.
514
+ const place = oneOf(finding.parent);
515
+ return (
516
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
517
+ ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' + finding.via + '.' +
518
+ finding.roleColumn + ' on their own membership row to "' + finding.toRole + '" - on a copy of your app. It was ' +
519
+ 'undone straight away. From then on they would have every right ' + aRole(finding.toRole) + ' has, in every ' +
520
+ 'table and function that trusts that role.'
521
+ );
522
+ }
523
+
494
524
  if (finding.kind === 'role') {
495
525
  // Said as what was done, and then whose decision it is: a viewer who may
496
526
  // edit is a hole in one app and the design of another.
@@ -535,8 +565,13 @@ function bodyFor(finding, contents) {
535
565
  return (
536
566
  'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
537
567
  'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
538
- 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
539
- 'something for everyone. I only read the bucket\'s setting and its rules; I did not open or list ' +
568
+ ((finding.writers || []).length
569
+ ? 'No rule says who may read it, but ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
570
+ ' let only signed-in members put files in or take them out - so the app treats these files as its ' +
571
+ 'members\' own, while anyone holding a link can download them. '
572
+ : 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
573
+ 'something for everyone. ') +
574
+ 'I only read the bucket\'s setting and its rules; I did not open or list ' +
540
575
  'any file. Whether it should be public is yours to say.'
541
576
  );
542
577
  }
@@ -587,6 +622,17 @@ function bodyFor(finding, contents) {
587
622
  'anything; this is read from how the column is declared.'
588
623
  );
589
624
  }
625
+ if (finding.kind === 'noinsert') {
626
+ return (
627
+ 'Row level security is on for ' + finding.table + ', and the only ' + ((finding.rules || []).length === 1 ? 'rule' : 'rules') +
628
+ ' for adding rows (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
629
+ ((finding.rules || []).length === 1 ? 'has' : 'have') + ' neither USING nor WITH CHECK. A rule with no ' +
630
+ 'expression is not "allow everything": Postgres treats it as false, so every INSERT - from the app, ' +
631
+ 'and from any function that runs as the user - is refused with "new row violates row-level security ' +
632
+ 'policy". If your app adds ' + finding.table + ' rows, that feature is broken for everyone. This is read ' +
633
+ 'from how the rule is written; I did not insert anything.'
634
+ );
635
+ }
590
636
  if (finding.kind === 'privileged') {
591
637
  // Said as what is true, not as what was done - nothing was executed. The
592
638
  // reach is real; whether it is a mistake is the person's to say, so the
@@ -644,6 +690,17 @@ function bodyFor(finding, contents) {
644
690
  (holds ? ', including ' + holds : '') + '.'
645
691
  );
646
692
  }
693
+ if (finding.asRole) {
694
+ // Blind round 15: a role check in a read rule that was never tied to the
695
+ // row's own organization.
696
+ return (
697
+ 'I made one of my fake people ' + aRole(finding.asRole) + ' of their own ' + oneOf(finding.parent) + ' (' + finding.via + '.' +
698
+ finding.roleColumn + ') and, signed in as them, asked for the other ' + oneOf(finding.parent) + '\'s rows in ' + finding.table +
699
+ ' and got ' + finding.readable + ' back' + (holds ? ', including ' + holds : '') + '. With an ordinary role they got none. ' +
700
+ 'So anybody who is ' + aRole(finding.asRole) + ' anywhere - which in most apps means anybody who creates their own ' +
701
+ oneOf(finding.parent) + ' - can read every ' + oneOf(finding.parent) + '\'s rows here.'
702
+ );
703
+ }
647
704
  if (finding.tenant) {
648
705
  return (
649
706
  'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
@@ -898,6 +955,23 @@ function fixPromptFor(finding) {
898
955
  '',
899
956
  'Then check every function that updates "' + finding.via + '" for the same thing.',
900
957
  ]
958
+ : finding.kind === 'role' && finding.promote
959
+ ? [
960
+ 'My app has a security problem - a member can make themselves ' + aRole(finding.toRole) + '.',
961
+ '',
962
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
963
+ finding.roleColumn + '" is "' + finding.who + '" can update their own row in "' + finding.via + '" and set "' +
964
+ finding.roleColumn + '" to "' + finding.toRole + '". The rule only checks that the row is theirs, not what ' +
965
+ 'they change in it. This was proved on a copy of the database.',
966
+ '',
967
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: a member must never be able to change "' +
968
+ finding.roleColumn + '" on their own row. Let only the roles that manage the team (usually owner and admin) ' +
969
+ 'change roles, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
970
+ '" for auth.uid() - and make sure nobody can raise anyone above their own role. If members need to edit ' +
971
+ 'something else of their own, allow exactly those columns and not "' + finding.roleColumn + '".',
972
+ '',
973
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
974
+ ]
901
975
  : finding.kind === 'role'
902
976
  ? [
903
977
  'My app may have a permissions problem - please check it rather than assume it.',
@@ -943,7 +1017,11 @@ function fixPromptFor(finding) {
943
1017
  'My app may have a storage problem - please check it rather than assume it.',
944
1018
  '',
945
1019
  'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
946
- 'download it without logging in' + (finding.noRule
1020
+ 'download it without logging in' + (finding.noRule && (finding.writers || []).length
1021
+ ? ', and no rule on storage.objects says who may read it - though ' +
1022
+ listOf((finding.writers || []).map((r) => '"' + r + '"')) + ' let only signed-in members upload or delete ' +
1023
+ 'its files, which suggests they are not meant for everyone.'
1024
+ : finding.noRule
947
1025
  ? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
948
1026
  'meant for everyone.'
949
1027
  : ', and the storage.objects ' +
@@ -1019,6 +1097,24 @@ function fixPromptFor(finding) {
1019
1097
  'the right value is), then ALTER TABLE "' + finding.table + '" ALTER COLUMN "' + finding.column +
1020
1098
  '" SET NOT NULL. Change nothing else.',
1021
1099
  ]
1100
+ : finding.kind === 'noinsert'
1101
+ ? [
1102
+ 'My app may have a bug - please check it rather than assume it.',
1103
+ '',
1104
+ 'In my database, the rule' + ((finding.rules || []).length === 1 ? ' ' : 's ') +
1105
+ listOf((finding.rules || []).map((r) => '"' + r + '"')) + ' on "' + finding.table + '" for INSERT ' +
1106
+ ((finding.rules || []).length === 1 ? 'has' : 'have') + ' no WITH CHECK, and there is no other rule that ' +
1107
+ 'allows adding rows. Postgres treats a missing check as false, so nobody can add a row to "' + finding.table +
1108
+ '" - not even through a function that runs as the user.',
1109
+ '',
1110
+ 'First tell me: is adding rows to "' + finding.table + '" meant to work for signed-in users? If only your ' +
1111
+ 'server adds them, change nothing.',
1112
+ '',
1113
+ 'If users should be able to add them: give the rule a WITH CHECK that says who may - for example that ' +
1114
+ 'the user is a member of the row\'s organization (through your membership table or a helper) and that ' +
1115
+ 'the column naming who created it equals auth.uid(). Never use WITH CHECK (true): that lets anyone add ' +
1116
+ 'rows anywhere. Then add one row through the app to make sure it works.',
1117
+ ]
1022
1118
  : finding.kind === 'privileged'
1023
1119
  ? [
1024
1120
  'My app may have a security problem - please check it rather than assume it.',
@@ -1166,7 +1262,23 @@ function fixPromptFor(finding) {
1166
1262
  'Then look for the same missing constraint on every other table and fix those too.',
1167
1263
  ],
1168
1264
  )
1169
- : finding.isView
1265
+ : finding.asRole
1266
+ ? [
1267
+ 'My app has a security problem.',
1268
+ '',
1269
+ 'In "' + finding.table + '", anyone who is "' + finding.asRole + '" in their own ' + oneOf(finding.parent) + ' can read the rows ' +
1270
+ 'of every ' + oneOf(finding.parent) + '. I proved it on a copy: as "' + finding.asRole + '" of one ' + oneOf(finding.parent) +
1271
+ ', I read ' + finding.readable + ' row(s) of another.',
1272
+ '',
1273
+ 'A rule on "' + finding.table + '" checks the caller\'s role without tying it to the row - for example ' +
1274
+ 'exists (select 1 from ' + finding.via + ' m where m.' + finding.roleColumn + ' = \'' + finding.asRole + '\') with no ' +
1275
+ 'condition that m is the signed-in user\'s membership of THIS row\'s ' + oneOf(finding.parent) + '. Fix every rule on "' +
1276
+ finding.table + '": a role check must say m.user_id = auth.uid() AND m.<' + oneOf(finding.parent) + ' id> = the row\'s ' +
1277
+ oneOf(finding.parent) + ' id - or call a helper that does. Keep membership as the way in; do not replace it with USING (true).',
1278
+ '',
1279
+ 'Then check every other rule and helper that checks a role for the same missing link.',
1280
+ ]
1281
+ : finding.isView
1170
1282
  ? [
1171
1283
  'My app has a security problem.',
1172
1284
  '',
@@ -1209,7 +1321,7 @@ function fixPromptFor(finding) {
1209
1321
 
1210
1322
  // Found and said, but never claimed as a break: the owner decides. Counted
1211
1323
  // apart in the report, never towards the exit code or a clean re-check.
1212
- const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck'];
1324
+ const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck', 'noinsert'];
1213
1325
  // Functions, said by their name rather than a table's.
1214
1326
  const ON_A_FUNCTION = ['privileged', 'searchpath', 'conflict'];
1215
1327
 
@@ -1223,7 +1335,7 @@ function describe(finding) {
1223
1335
  // one the tool reasons about without executing, so it says so. The report
1224
1336
  // and the re-check both read this: a "verification required" finding is
1225
1337
  // never counted towards a clean re-check on its own.
1226
- status: TO_CHECK.includes(finding.kind) ? 'verification required' : 'confirmed',
1338
+ status: TO_CHECK.includes(finding.kind) && !(finding.kind === 'role' && finding.promote) ? 'verification required' : 'confirmed',
1227
1339
  table: ON_A_FUNCTION.includes(finding.kind) ? finding.fn : finding.table,
1228
1340
  fn: finding.fn,
1229
1341
  args: finding.args,
@@ -1250,14 +1362,19 @@ function describe(finding) {
1250
1362
  readable: finding.readable,
1251
1363
  secrets: finding.secrets,
1252
1364
  bucket: finding.bucket,
1365
+ rules: finding.rules,
1253
1366
  // An organization's rows read from outside it, and a member taking over
1254
1367
  // another member's row: kept so the re-check and the read-back word them
1255
1368
  // the same way the report did.
1256
1369
  tenant: finding.tenant,
1370
+ asRole: finding.asRole,
1257
1371
  takeover: finding.takeover,
1258
1372
  personColumn: finding.personColumn,
1373
+ promote: finding.promote,
1374
+ toRole: finding.toRole,
1259
1375
  nullSlips: finding.nullSlips,
1260
1376
  noRule: finding.noRule,
1377
+ writers: finding.writers,
1261
1378
  // Read from how a function or column is declared (scan.js searchPathsOf,
1262
1379
  // conflictsIn, emptyChoicesOf); kept so the read-back says it the same way.
1263
1380
  target: finding.target,
@@ -1272,6 +1389,9 @@ function describe(finding) {
1272
1389
  ') on the copy, and it refused. I did not call the function.'
1273
1390
  : finding.kind === 'nullcheck'
1274
1391
  ? 'I read that "' + finding.table + '.' + finding.column + '" may be NULL and has no default. I did not insert anything.'
1392
+ : finding.kind === 'noinsert'
1393
+ ? 'I read that every rule for adding rows to "' + finding.table + '" (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1394
+ ') has neither USING nor WITH CHECK. I did not insert anything.'
1275
1395
  : finding.kind === 'recursive'
1276
1396
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1277
1397
  loopError(finding) + '.'
@@ -1280,6 +1400,12 @@ function describe(finding) {
1280
1400
  : finding.kind === 'role' && finding.takeover
1281
1401
  ? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
1282
1402
  finding.via + '", which I could not touch before joining. It was undone.'
1403
+ : finding.kind === 'role' && finding.promote
1404
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I set "' + finding.roleColumn + '" on my own row of "' +
1405
+ finding.via + '" to "' + finding.toRole + '". It was undone.'
1406
+ : finding.kind === 'crossed' && finding.asRole
1407
+ ? 'As "' + finding.asRole + '" of my own ' + oneOf(finding.parent) + ', I read ' + finding.readable + ' row(s) of "' +
1408
+ finding.table + '" belonging to another; with an ordinary role, none. My role was put back.'
1283
1409
  : finding.kind === 'role'
1284
1410
  ? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
1285
1411
  listOf((finding.can || []).map((what) => {
@@ -1290,7 +1416,9 @@ function describe(finding) {
1290
1416
  ? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
1291
1417
  ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1292
1418
  : finding.kind === 'bucket' && finding.noRule
1293
- ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it. I did not open any file.'
1419
+ ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it' +
1420
+ ((finding.writers || []).length ? ', while ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
1421
+ ' limit uploads and deletes to signed-in members' : '') + '. I did not open any file.'
1294
1422
  : finding.kind === 'bucket'
1295
1423
  ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1296
1424
  ' says who may read it. I did not open any file.'
@@ -1398,7 +1526,7 @@ function describeAll(findings) {
1398
1526
  // Writes above reads: a table somebody emptied is worse than one they read.
1399
1527
  // A looping rule sits with the reads: it breaks the app for everyone it
1400
1528
  // fails for, which matters more than a duplicate or a stray row.
1401
- const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, nullcheck: 6 };
1529
+ const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, noinsert: 5.7, nullcheck: 6 };
1402
1530
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
1403
1531
  return described.sort((a, b) => rank(a) - rank(b));
1404
1532
  }
package/outside.js CHANGED
@@ -79,12 +79,26 @@ async function discover(input, options) {
79
79
  if (!appUrl) throw problem('no-app', 'I need the address of your running app, e.g. https://your-app.example.com');
80
80
 
81
81
  const html = await getText(doFetch, appUrl);
82
- const scripts = scriptUrls(html, appUrl);
83
82
  // The HTML itself sometimes carries the values (server-rendered apps do), so
84
83
  // it is searched too, before the bundles.
85
84
  let text = html;
86
- for (const src of scripts) {
87
- text += '\n' + await getText(doFetch, src).catch(() => '');
85
+ // The scripts the page loads, and the app's own modules those import - a
86
+ // page with no build step keeps its address and key in a config.js that
87
+ // app.js imports. Found on blind round 15: src/app.js did
88
+ // `import { SUPABASE_URL, SUPABASE_ANON_KEY } from "./config.js"`, and the
89
+ // URL mode said there was no Supabase here. Only the app's own address is
90
+ // followed, never a CDN, and only so far.
91
+ const origin = new URL(appUrl).origin;
92
+ const queue = scriptUrls(html, appUrl);
93
+ const seen = new Set(queue);
94
+ for (let i = 0; i < queue.length && i < MAX_SCRIPTS; i++) {
95
+ const body = await getText(doFetch, queue[i]).catch(() => '');
96
+ text += '\n' + body;
97
+ for (const next of importUrls(body, queue[i])) {
98
+ if (seen.has(next) || new URL(next).origin !== origin) continue;
99
+ seen.add(next);
100
+ queue.push(next);
101
+ }
88
102
  }
89
103
 
90
104
  const supabaseUrl = firstMatch(text, /https?:\/\/[a-z0-9-]+\.supabase\.co/i);
@@ -242,6 +256,31 @@ function scriptUrls(html, appUrl) {
242
256
  return dedupe(out);
243
257
  }
244
258
 
259
+ // How many scripts a page and its own imports may add up to. A built app is a
260
+ // handful; this stops a page that imports itself in circles, or a huge
261
+ // unbundled tree, from being read without end.
262
+ const MAX_SCRIPTS = 40;
263
+
264
+ // The modules a script imports by a path - import ... from "./x.js",
265
+ // export ... from "./x.js", import("./x.js") - made absolute against the
266
+ // script's own address. A bare name ("react") is resolved by a bundler or an
267
+ // import map, not by a path, so it is not one.
268
+ function importUrls(source, scriptUrl) {
269
+ const out = [];
270
+ const re = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)["']([^"']+)["']/g;
271
+ let m;
272
+ while ((m = re.exec(String(source || '')))) {
273
+ const spec = m[1];
274
+ if (!/^(\.{1,2}\/|\/|https?:\/\/)/.test(spec)) continue;
275
+ try {
276
+ out.push(new URL(spec, scriptUrl).href);
277
+ } catch (err) {
278
+ /* not a path we can follow */
279
+ }
280
+ }
281
+ return dedupe(out);
282
+ }
283
+
245
284
  function firstMatch(text, re) {
246
285
  const m = String(text).match(re);
247
286
  return m ? m[0] : null;
@@ -403,6 +442,7 @@ module.exports = {
403
442
  prowl: prowl,
404
443
  // exported for the checks
405
444
  scriptUrls: scriptUrls,
445
+ importUrls: importUrls,
406
446
  tableNames: tableNames,
407
447
  anonJwt: anonJwt,
408
448
  rangeCount: rangeCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",
@@ -31,7 +31,7 @@
31
31
  "node": ">=18.0.0"
32
32
  },
33
33
  "scripts": {
34
- "check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node checkfit.check.js && node declared.check.js && node identity.check.js",
34
+ "check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node identity.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",
@@ -79,7 +79,8 @@
79
79
  "check:tenants": "node tenants.check.js",
80
80
  "check:takeover": "node takeover.check.js",
81
81
  "check:checkfit": "node checkfit.check.js",
82
- "check:identity": "node identity.check.js"
82
+ "check:identity": "node identity.check.js",
83
+ "check:elevated": "node elevated.check.js"
83
84
  },
84
85
  "keywords": [
85
86
  "supabase",
package/recheck.js CHANGED
@@ -232,6 +232,7 @@ function whatOf(item) {
232
232
  if (item.kind === 'searchpath') return 'full rights, search_path not pinned';
233
233
  if (item.kind === 'conflict') return 'an upsert Postgres refuses';
234
234
  if (item.kind === 'nullcheck') return 'a ' + (item.column || 'value') + ' that can be left empty';
235
+ if (item.kind === 'noinsert') return 'no row can be added';
235
236
  return item.kind;
236
237
  }
237
238
 
@@ -315,6 +316,8 @@ function describe(result) {
315
316
  : item.kind === 'conflict'
316
317
  ? (elsewhere ? 'I asked Postgres to plan it here, and it accepted it.'
317
318
  : 'I asked Postgres to plan it again, and it accepted it.')
319
+ : item.kind === 'noinsert'
320
+ ? (elsewhere ? 'I looked here, and a rule for adding rows checks something.' : 'I looked again, and a rule for adding rows now checks something.')
318
321
  : item.kind === 'nullcheck'
319
322
  ? (elsewhere ? 'I looked here, and it cannot be left empty.' : 'I looked again, and it can no longer be left empty.')
320
323
  : item.kind === 'recursive'
package/scan.js CHANGED
@@ -419,7 +419,11 @@ async function scan(client, sourceSchema, options) {
419
419
  for (const fn of plan.anonFunctions || []) attempted.push('searchpath:' + fn.name);
420
420
  for (const fn of plan.functions || []) attempted.push('conflict:' + fn.name);
421
421
  for (const x of oneOfColumns(plan)) attempted.push('nullcheck:' + x.table + ':' + x.column.name);
422
- const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan));
422
+ // Only a table that has a rule for adding rows was looked at for this.
423
+ for (const name of new Set((plan.policies || []).filter((p) => /^(INSERT|ALL)$/i.test(String(p.cmd || 'ALL'))).map((p) => p.table_name))) {
424
+ attempted.push('noinsert:' + name);
425
+ }
426
+ const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan), noInsertsOf(plan));
423
427
 
424
428
  // Storage buckets switched to public that also have a rule saying who may
425
429
  // read them. Read from the bucket settings and the rules, never a file.
@@ -573,6 +577,34 @@ const ONE_OF = /^CHECK \(+"?([A-Za-z_][\w$]*)"?\)?(?:::[\w ]+)? = ANY \(/;
573
577
  * fills the column: no default, not generated, and a CHECK that does not
574
578
  * mention NULL itself (a CHECK that does has decided the question).
575
579
  */
580
+ /**
581
+ * Tables nobody can add a row to: every rule for adding rows is an INSERT
582
+ * rule written with neither USING nor WITH CHECK, which Postgres reads as
583
+ * "no" - measured on PostgreSQL 18, a bare `create policy p on t for insert
584
+ * to authenticated;` refuses every insert. A rule for ALL, or one with a
585
+ * check, is a way in, and the table is left alone. Mirrors no_insert_findings
586
+ * in engine.sql.
587
+ *
588
+ * Found on blind round 15 (Northstar Ops on Supabase): tickets_insert was
589
+ * written that way, so create_ticket failed for every user - the app's main
590
+ * feature - and nothing in the report said so.
591
+ */
592
+ function noInsertsOf(plan) {
593
+ const byTable = new Map();
594
+ for (const p of (plan && plan.policies) || []) {
595
+ if (String(p.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
596
+ const cmd = String(p.cmd || 'ALL').toUpperCase();
597
+ if (cmd !== 'INSERT' && cmd !== 'ALL') continue;
598
+ if (!byTable.has(p.table_name)) byTable.set(p.table_name, { bare: [], open: false });
599
+ const entry = byTable.get(p.table_name);
600
+ if (cmd === 'INSERT' && p.qual == null && p.with_check == null) entry.bare.push(p.name);
601
+ else entry.open = true;
602
+ }
603
+ return Array.from(byTable.keys()).sort()
604
+ .filter((table) => byTable.get(table).bare.length && !byTable.get(table).open)
605
+ .map((table) => ({ kind: 'noinsert', table: table, rules: byTable.get(table).bare.slice().sort(), columns: [] }));
606
+ }
607
+
576
608
  function emptyChoicesOf(plan) {
577
609
  return oneOfColumns(plan)
578
610
  .filter((x) => !x.column.not_null && x.column.default_expr == null && !x.column.generated)
@@ -773,18 +805,35 @@ function bucketsOf(plan) {
773
805
  .replace(/\btrue\b|\band\b|[()\s]/gi, '');
774
806
  return !toEveryone || rest !== '';
775
807
  };
776
- return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => ({
777
- kind: 'bucket',
778
- table: b.name,
779
- bucket: b.id,
780
- rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
781
- // No read rule at all, and a name that says what is in it is not for
782
- // everyone. Found on a blind test (HarborLine): customer-exports was
783
- // public, and its one read rule had failed to load (a text compared to a
784
- // uuid) - with nothing to read, the bucket was never mentioned.
785
- noRule: !(b.readRules || []).length && PRIVATE_BUCKET.test(String(b.name)),
786
- columns: [],
787
- })).filter((f) => f.rules.length || f.noRule);
808
+ // A rule for putting files in or taking them out that only signed-in people
809
+ // pass AND that looks a file up in the app's own data (a subquery over a
810
+ // table: an attachments row, a membership). That is files the app treats as
811
+ // its members' records. A rule that only asks whose folder it is - the
812
+ // usual public avatars bucket - is not this.
813
+ const membersOnly = (rule) => {
814
+ const roles = schema.roleList(rule.roles);
815
+ const text = String(rule.qual || '') + ' ' + String(rule.with_check || '');
816
+ const open = roles.includes('anon') || roles.includes('public');
817
+ return (!open || /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(text)) && /\bfrom\s+/i.test(text);
818
+ };
819
+ return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => {
820
+ const writers = (b.writeRules || []).filter(membersOnly).map((rule) => rule.name);
821
+ return {
822
+ kind: 'bucket',
823
+ table: b.name,
824
+ bucket: b.id,
825
+ rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
826
+ // No read rule at all, and something else saying its files are not for
827
+ // everyone: a name that says what is in it (HarborLine: customer-exports,
828
+ // whose one read rule had failed to load), or rules that let only
829
+ // signed-in members put files in or take them out (blind round 15:
830
+ // workspace-files, written only through an attachments row of the
831
+ // uploader's, and readable by anyone with a link).
832
+ noRule: !(b.readRules || []).length && (PRIVATE_BUCKET.test(String(b.name)) || writers.length > 0),
833
+ writers: !(b.readRules || []).length ? writers : [],
834
+ columns: [],
835
+ };
836
+ }).filter((f) => f.rules.length || f.noRule);
788
837
  }
789
838
 
790
839
  // Names that say a bucket's files are somebody's own business. Whole words
@@ -1243,6 +1292,7 @@ module.exports = {
1243
1292
  privilegedOf: privilegedOf,
1244
1293
  searchPathsOf: searchPathsOf,
1245
1294
  emptyChoicesOf: emptyChoicesOf,
1295
+ noInsertsOf: noInsertsOf,
1246
1296
  oneOfColumns: oneOfColumns,
1247
1297
  conflictTargetsOf: conflictTargetsOf,
1248
1298
  partialWhereFor: partialWhereFor,
package/schema.js CHANGED
@@ -454,7 +454,18 @@ const BUCKETS_QUERY =
454
454
  FROM pg_policies p
455
455
  WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
456
456
  AND p.cmd IN ('SELECT', 'ALL')
457
- AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
457
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
458
+ -- The rules for putting files in and taking them out: who the app
459
+ -- lets write a bucket says who its files are for (scan.js bucketsOf).
460
+ 'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
461
+ 'name', p.policyname::text,
462
+ 'roles', to_jsonb(p.roles::text[]),
463
+ 'qual', p.qual,
464
+ 'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
465
+ FROM pg_policies p
466
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
467
+ AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
468
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
458
469
  ) ORDER BY b.id), '[]'::jsonb) AS buckets
459
470
  FROM storage.buckets b`;
460
471
 
package/tamper.js CHANGED
@@ -297,7 +297,10 @@ function roleColumnOf(table) {
297
297
  // the order reads are tried in, so the least trusted one that can read a
298
298
  // secret is the one named.
299
299
  const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
300
- if (lowest) return { column: column.name, type: column.type, lowest: lowest, ladder: ladder };
300
+ // The role at the top: an owner if there is one, else the first in charge.
301
+ const top = labels.find((label) => /owner/i.test(label)) || labels.find((label) => IN_CHARGE.test(label)) || null;
302
+ const inCharge = labels.filter((label) => IN_CHARGE.test(label));
303
+ if (lowest) return { column: column.name, type: column.type, lowest: lowest, ladder: ladder, top: top, inCharge: inCharge };
301
304
  }
302
305
  return null;
303
306
  }
@@ -334,7 +337,7 @@ function memberships(tables) {
334
337
  .filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
335
338
  if (!keys.length || aloneUnique(table, person)) continue;
336
339
  found.push({ table: table, person: person, role: role.column, roleType: role.type, lowest: role.lowest,
337
- ladder: role.ladder, keys: keys });
340
+ ladder: role.ladder, top: role.top, inCharge: role.inCharge || [], keys: keys });
338
341
  }
339
342
  // A table named for members first: it is the one a team rule asks.
340
343
  const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
@@ -343,7 +346,7 @@ function memberships(tables) {
343
346
 
344
347
  // What each kind of write is called in a rule.
345
348
  // A takeover is an UPDATE too: a rule for updating is what it would hide behind.
346
- const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE' };
349
+ const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE', promote: 'UPDATE' };
347
350
 
348
351
  /**
349
352
  * Whether the app's own rules name this role among those allowed to make
@@ -502,6 +505,21 @@ async function teammate(client, schema, tables, seeded, rules) {
502
505
  statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
503
506
  values: [],
504
507
  });
508
+ // Their own membership row, given the role at the top. Found on blind
509
+ // round 15 (Northstar Ops on Supabase): memberships_update checked only
510
+ // user_id = auth.uid(), so a viewer set their own role to 'owner'.
511
+ // Before joining they have no row, so only what joining added counts.
512
+ if (member.top) {
513
+ const ownRow = [quote(member.person) + " = '" + attack.USER_B + "'"]
514
+ .concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
515
+ moves.push({
516
+ what: 'promote',
517
+ anyRole: true,
518
+ statement: 'UPDATE ' + target + ' SET ' + quote(member.role) + " = '" + String(member.top).split("'").join("''") +
519
+ "' WHERE " + ownRow.join(' AND '),
520
+ values: [],
521
+ });
522
+ }
505
523
  }
506
524
  // Adding a row to the team, said only of a role whose own name says it
507
525
  // may only look: a member adding rows is the feature, a viewer adding
@@ -591,12 +609,60 @@ async function teammate(client, schema, tables, seeded, rules) {
591
609
  below: below.slice(),
592
610
  columns: (table.columns || []).map((c) => c.name),
593
611
  rlsEnabled: table.rlsEnabled,
594
- }, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {}));
612
+ }, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {},
613
+ can.includes('promote') ? { promote: true, toRole: member.top } : {}));
595
614
  break;
596
615
  }
597
616
  if (!listed) below.push(role);
598
617
  }
599
618
 
619
+ // In charge of their OWN team, reading the first person's. Found on
620
+ // blind round 15 (Northstar Ops on Supabase): projects_select let in
621
+ // anyone for whom `exists (select 1 from memberships m where m.role =
622
+ // 'admin')` - not tied to the project's organization - so an admin of
623
+ // any organization read every organization's projects. The second
624
+ // person's own membership is given each role in charge, one at a time,
625
+ // and the first person's team's rows are asked for; only what the role
626
+ // added counts. A table that names neither the team nor a person is
627
+ // not asked.
628
+ const theirTeam = Object.keys(pointed).length
629
+ ? Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'").join(' AND ')
630
+ : owner ? quote(owner) + " = '" + attack.USER_A + "'" : null;
631
+ if (theirTeam && member.inCharge.length) {
632
+ const look = 'SELECT 1 FROM ' + target + ' WHERE ' + theirTeam;
633
+ const plain = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
634
+ for (const label of member.inCharge) {
635
+ const key = 'elevated:' + table.name + ':' + label;
636
+ const raise = {
637
+ statement: 'UPDATE ' + quote(schema) + '.' + quote(member.table.name) + ' SET ' + quote(member.role) + ' = $1 WHERE ' +
638
+ quote(member.person) + ' = $2',
639
+ values: [label, attack.USER_B],
640
+ };
641
+ const now = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, [], raise));
642
+ if (plain === null || now === null) {
643
+ blocked.push({ table: table.name, key: key, why: 'as "' + label + '" in their own ' + parent + ' row: reading it failed' });
644
+ break;
645
+ }
646
+ completed.push(key);
647
+ if (now > plain) {
648
+ findings.push({
649
+ kind: 'crossed',
650
+ table: table.name,
651
+ owner: owner || Object.keys(pointed).sort()[0],
652
+ tenant: !owner,
653
+ readable: now - plain,
654
+ asRole: label,
655
+ via: member.table.name,
656
+ roleColumn: member.role,
657
+ parent: parent,
658
+ columns: (table.columns || []).map((c) => c.name),
659
+ rlsEnabled: table.rlsEnabled,
660
+ });
661
+ break;
662
+ }
663
+ }
664
+ }
665
+
600
666
  // Reading what a member should not see, said only of a table holding a
601
667
  // secret. Every role not in charge, from the lowest up; the first that
602
668
  // can read is the one named. A member reading their team's ordinary