kryptheon-night 0.1.37 → 0.1.39

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;
@@ -383,7 +393,7 @@ END $$;
383
393
  * and the first RETURN). `helpers` are { name, nullable } - a yes/no about
384
394
  * the caller, or a role helper that answers NULL for a stranger.
385
395
  */
386
- CREATE OR REPLACE FUNCTION __KN__.door_of(body text, helpers jsonb)
396
+ CREATE OR REPLACE FUNCTION __KN__.door_of(body text, helpers jsonb, args text DEFAULT NULL)
387
397
  RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
388
398
  DECLARE
389
399
  text_ text := coalesce(body, '');
@@ -401,6 +411,16 @@ DECLARE
401
411
  h jsonb;
402
412
  asks boolean;
403
413
  slips boolean := false;
414
+ -- schema.js namesSomebody: an argument that could point at somebody else's
415
+ -- row. With none, "is anybody signed in" turns away everyone it needs to.
416
+ names_somebody boolean := args IS NULL OR EXISTS (
417
+ SELECT 1 FROM unnest(string_to_array(args, ',')) a(raw),
418
+ LATERAL (SELECT regexp_split_to_array(btrim(regexp_replace(btrim(a.raw), '^(in|inout|variadic)\s+', '', 'i')), '\s+') AS w) p
419
+ WHERE btrim(a.raw) <> '' AND (
420
+ array_to_string(CASE WHEN array_length(p.w, 1) > 1 THEN p.w[2:] ELSE p.w END, ' ')
421
+ ~* '\y(uuid|oid|int[248]?|integer|bigint|smallint|numeric|serial|bigserial)\y'
422
+ OR (array_length(p.w, 1) > 1 AND replace(p.w[1], '"', '')
423
+ ~* '(^|_)(ids?|uids?|orgs?|organizations?|tenants?|teams?|workspaces?|accounts?|users?|owners?|members?|projects?|customers?|compan(y|ies))$')));
404
424
  BEGIN
405
425
  IF text_ ~* writes_re THEN
406
426
  first_act := least(first_act, length((regexp_split_to_array(text_, writes_re, 'i'))[1]) + 1);
@@ -430,6 +450,10 @@ BEGIN
430
450
  IF refuses_on_no AND cond ~* '\yauth\s*\.\s*(uid|jwt)\s*\(' AND cond ~* 'admin|owner|member|role' THEN
431
451
  RETURN jsonb_build_object('guarded', true, 'nullSlips', false);
432
452
  END IF;
453
+ IF NOT names_somebody AND (cond ~* '^\(?\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+null\s*\)?$'
454
+ OR cond ~* '^not\s*\(\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+not\s+null\s*\)$') THEN
455
+ RETURN jsonb_build_object('guarded', true, 'nullSlips', false);
456
+ END IF;
433
457
  END LOOP;
434
458
  RETURN jsonb_build_object('guarded', false, 'nullSlips', slips);
435
459
  END $$;
@@ -487,7 +511,7 @@ BEGIN
487
511
  body := regexp_replace(coalesce(r.def, ''), '--[^\n]*', ' ', 'g');
488
512
  conf := replace(replace(replace(coalesce(r.config, ''), '{', ''), '}', ''), '"', '');
489
513
  writes := body ~* writes_re;
490
- door := __KN__.door_of(body, helpers);
514
+ door := __KN__.door_of(body, helpers, coalesce(r.args, ''));
491
515
  out_ := out_ || jsonb_build_array(jsonb_build_object(
492
516
  'name', r.name,
493
517
  'args', coalesce(r.args, ''),
@@ -516,6 +540,7 @@ DECLARE
516
540
  rest text;
517
541
  narrowing jsonb;
518
542
  no_rule boolean;
543
+ writers jsonb;
519
544
  found jsonb := '[]'::jsonb;
520
545
  BEGIN
521
546
  FOR b IN SELECT * FROM jsonb_array_elements(coalesce(buckets, '[]'::jsonb)) LOOP
@@ -531,12 +556,23 @@ BEGIN
531
556
  narrowing := narrowing || to_jsonb(rule->>'name');
532
557
  END IF;
533
558
  END LOOP;
559
+ -- Rules for putting files in or taking them out that only signed-in
560
+ -- people pass (scan.js bucketsOf; blind round 15).
561
+ writers := '[]'::jsonb;
562
+ IF jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0 THEN
563
+ SELECT coalesce(jsonb_agg(w->>'name' ORDER BY ord), '[]'::jsonb) INTO writers
564
+ FROM jsonb_array_elements(coalesce(b->'writeRules', '[]'::jsonb)) WITH ORDINALITY AS e(w, ord)
565
+ WHERE (NOT (coalesce(w->'roles', '[]'::jsonb) ? 'anon' OR coalesce(w->'roles', '[]'::jsonb) ? 'public')
566
+ OR (coalesce(w->>'qual', '') || ' ' || coalesce(w->>'with_check', '')) ~* '\mauth\s*\.\s*(uid|jwt)\s*\(')
567
+ AND (coalesce(w->>'qual', '') || ' ' || coalesce(w->>'with_check', '')) ~* '\mfrom\s+';
568
+ END IF;
534
569
  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?)([-_.]|$)';
570
+ AND ((b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)'
571
+ OR jsonb_array_length(writers) > 0);
536
572
  IF jsonb_array_length(narrowing) > 0 OR no_rule THEN
537
573
  found := found || jsonb_build_array(jsonb_build_object(
538
574
  'kind', 'bucket', 'table', b->>'name', 'bucket', b->>'id',
539
- 'rules', narrowing, 'noRule', no_rule, 'columns', '[]'::jsonb));
575
+ 'rules', narrowing, 'noRule', no_rule, 'writers', writers, 'columns', '[]'::jsonb));
540
576
  END IF;
541
577
  END LOOP;
542
578
  RETURN found;
@@ -2902,7 +2938,14 @@ BEGIN
2902
2938
  jsonb_build_array(lowest) || coalesce((
2903
2939
  SELECT jsonb_agg(l ORDER BY pos DESC)
2904
2940
  FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
2905
- WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb));
2941
+ WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb),
2942
+ -- The role at the top: an owner if there is one, else the first in charge.
2943
+ 'top', coalesce(
2944
+ (SELECT l FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos) WHERE l ~* 'owner' ORDER BY pos LIMIT 1),
2945
+ (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)),
2946
+ -- Every role in charge, in the order the list gives them.
2947
+ 'inCharge', coalesce((SELECT jsonb_agg(l ORDER BY pos) FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
2948
+ WHERE l ~* 'owner|admin|super|root'), '[]'::jsonb));
2906
2949
  END IF;
2907
2950
  END LOOP;
2908
2951
  RETURN NULL;
@@ -2968,7 +3011,7 @@ BEGIN
2968
3011
  CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
2969
3012
  found := found || jsonb_build_array(jsonb_build_object(
2970
3013
  'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
2971
- 'roleType', role_->>'type', 'ladder', role_->'ladder', 'keys', keys));
3014
+ 'roleType', role_->>'type', 'ladder', role_->'ladder', 'top', role_->'top', 'inCharge', role_->'inCharge', 'keys', keys));
2972
3015
  END LOOP;
2973
3016
  -- A table named for members first: it is the one a team rule asks.
2974
3017
  SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
@@ -3047,7 +3090,8 @@ DECLARE
3047
3090
  '"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
3048
3091
  in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
3049
3092
  -- 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;
3093
+ command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE'
3094
+ WHEN 'takeover' THEN 'UPDATE' WHEN 'promote' THEN 'UPDATE' END;
3051
3095
  policy jsonb;
3052
3096
  body text;
3053
3097
  BEGIN
@@ -3119,6 +3163,14 @@ DECLARE
3119
3163
  before_all jsonb;
3120
3164
  below jsonb;
3121
3165
  listed boolean;
3166
+ team_columns text[];
3167
+ move_sets text;
3168
+ move_elsewhere text;
3169
+ moved_ bigint;
3170
+ move_result jsonb;
3171
+ their_team text;
3172
+ plain_ bigint;
3173
+ label_ text;
3122
3174
  BEGIN
3123
3175
  FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
3124
3176
  SELECT (s->>'attempt')::integer INTO shape
@@ -3185,6 +3237,51 @@ BEGIN
3185
3237
  CONTINUE;
3186
3238
  END IF;
3187
3239
 
3240
+ -- Their own membership row - the one in their own team - moved into the
3241
+ -- first person's team at the role at the top, before they join
3242
+ -- (tamper.js; blind round 16, Meridian: memberships_update_self checked
3243
+ -- only user_id = auth.uid()). One row only.
3244
+ SELECT coalesce(array_agg(k ORDER BY k), ARRAY[]::text[]) INTO team_columns
3245
+ FROM jsonb_object_keys(team) k WHERE team->>k IS NOT NULL;
3246
+ IF member->>'top' IS NOT NULL AND array_length(team_columns, 1) IS NOT NULL THEN
3247
+ finding_key := 'movein:' || (member->'table'->>'name');
3248
+ SELECT string_agg(__KN__.always_quote(k) || ' = ' || quote_literal(team->>k), ', ' ORDER BY k),
3249
+ string_agg(__KN__.always_quote(k) || ' IS DISTINCT FROM ' || quote_literal(team->>k), ' OR ' ORDER BY k)
3250
+ INTO move_sets, move_elsewhere FROM unnest(team_columns) k;
3251
+ move_sets := move_sets || ', ' || __KN__.always_quote(member->>'role') || ' = ' || quote_literal(member->>'top');
3252
+ move_result := __KN__.try_write('authenticated', __KN__.user_b(),
3253
+ 'UPDATE ' || at || ' SET ' || move_sets || ' WHERE ctid = (SELECT ctid FROM ' || at || ' WHERE '
3254
+ || __KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())
3255
+ || ' AND (' || move_elsewhere || ') LIMIT 1)');
3256
+ moved_ := __KN__.rows_moved(move_result);
3257
+ IF moved_ IS NULL THEN
3258
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
3259
+ 'table', member->'table'->>'name', 'key', finding_key,
3260
+ 'why', 'moving their own ' || (member->'table'->>'name') || ' row into another ' || parent || ': '
3261
+ || (move_result->>'why')));
3262
+ ELSE
3263
+ completed := completed || to_jsonb(finding_key);
3264
+ IF moved_ > 0 THEN
3265
+ findings := findings || jsonb_build_array(jsonb_build_object(
3266
+ 'kind', 'role',
3267
+ 'table', member->'table'->>'name',
3268
+ 'who', 'anyone signed in',
3269
+ 'via', member->'table'->>'name',
3270
+ 'roleColumn', member->>'role',
3271
+ 'parent', parent,
3272
+ 'can', jsonb_build_array('movein'),
3273
+ 'changed', jsonb_build_object('movein', moved_),
3274
+ 'below', '[]'::jsonb,
3275
+ 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(member->'table'->'columns') c), '[]'::jsonb),
3276
+ 'rlsEnabled', coalesce((member->'table'->>'rlsEnabled')::boolean, false),
3277
+ 'promote', true,
3278
+ 'movein', true,
3279
+ 'toRole', member->>'top',
3280
+ 'moved', to_jsonb(team_columns)));
3281
+ END IF;
3282
+ END IF;
3283
+ END IF;
3284
+
3188
3285
  FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
3189
3286
  CONTINUE WHEN NOT EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
3190
3287
  WHERE s->>'table' = tab->>'name');
@@ -3242,6 +3339,17 @@ BEGIN
3242
3339
  moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
3243
3340
  'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
3244
3341
  || quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
3342
+ -- Their own membership row, given the role at the top (tamper.js;
3343
+ -- blind round 15: a viewer set their own role to 'owner').
3344
+ IF member->>'top' IS NOT NULL THEN
3345
+ where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())];
3346
+ FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
3347
+ where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
3348
+ END LOOP;
3349
+ moves := moves || jsonb_build_array(jsonb_build_object('what', 'promote', 'anyRole', true,
3350
+ 'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'role') || ' = '
3351
+ || quote_literal(member->>'top') || ' WHERE ' || array_to_string(where_, ' AND ')));
3352
+ END IF;
3245
3353
  END IF;
3246
3354
 
3247
3355
  -- Adding a row to the team, said only of a role whose own name says it
@@ -3325,12 +3433,59 @@ BEGIN
3325
3433
  'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
3326
3434
  || CASE WHEN can ? 'takeover'
3327
3435
  THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
3436
+ ELSE '{}'::jsonb END
3437
+ || CASE WHEN can ? 'promote'
3438
+ THEN jsonb_build_object('promote', true, 'toRole', member->>'top')
3328
3439
  ELSE '{}'::jsonb END);
3329
3440
  EXIT;
3330
3441
  END IF;
3331
3442
  IF NOT listed THEN below := below || to_jsonb(role_); END IF;
3332
3443
  END LOOP;
3333
3444
 
3445
+ -- In charge of their OWN team, reading the first person's (tamper.js;
3446
+ -- blind round 15: a read rule's role check not tied to the row's
3447
+ -- organization let an admin of any organization read every one). Only
3448
+ -- what the role added counts.
3449
+ their_team := NULL;
3450
+ IF EXISTS (SELECT 1 FROM jsonb_object_keys(pointed)) THEN
3451
+ SELECT string_agg(__KN__.always_quote(k) || ' = ' || quote_literal(pointed->>k), ' AND ' ORDER BY k)
3452
+ INTO their_team FROM jsonb_object_keys(pointed) k;
3453
+ ELSIF owner IS NOT NULL THEN
3454
+ their_team := __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a());
3455
+ END IF;
3456
+ IF their_team IS NOT NULL AND jsonb_array_length(coalesce(member->'inCharge', '[]'::jsonb)) > 0 THEN
3457
+ look := 'SELECT 1 FROM ' || target || ' WHERE ' || their_team;
3458
+ plain_ := __KN__.rows_moved(__KN__.try_write('authenticated', __KN__.user_b(), look));
3459
+ FOR label_ IN SELECT jsonb_array_elements_text(member->'inCharge') LOOP
3460
+ finding_key := 'elevated:' || (tab->>'name') || ':' || label_;
3461
+ now_ := __KN__.rows_moved(__KN__.try_write_joined('authenticated', __KN__.user_b(), look,
3462
+ 'UPDATE ' || __KN__.always_quote(source) || '.' || __KN__.always_quote(member->'table'->>'name') || ' SET '
3463
+ || __KN__.always_quote(member->>'role') || ' = ' || quote_literal(label_) || ' WHERE '
3464
+ || __KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())));
3465
+ IF plain_ IS NULL OR now_ IS NULL THEN
3466
+ blocked := blocked || jsonb_build_array(jsonb_build_object('table', tab->>'name', 'key', finding_key,
3467
+ 'why', 'as "' || label_ || '" in their own ' || parent || ' row: reading it failed'));
3468
+ EXIT;
3469
+ END IF;
3470
+ completed := completed || to_jsonb(finding_key);
3471
+ IF now_ > plain_ THEN
3472
+ findings := findings || jsonb_build_array(jsonb_build_object(
3473
+ 'kind', 'crossed',
3474
+ 'table', tab->>'name',
3475
+ 'owner', coalesce(owner, (SELECT k FROM jsonb_object_keys(pointed) k ORDER BY k LIMIT 1)),
3476
+ 'tenant', owner IS NULL,
3477
+ 'readable', now_ - plain_,
3478
+ 'asRole', label_,
3479
+ 'via', member->'table'->>'name',
3480
+ 'roleColumn', member->>'role',
3481
+ 'parent', parent,
3482
+ 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
3483
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
3484
+ EXIT;
3485
+ END IF;
3486
+ END LOOP;
3487
+ END IF;
3488
+
3334
3489
  -- Reading what a member should not see, said only of a table holding a
3335
3490
  -- secret. Every role not in charge, from the lowest up; the first that
3336
3491
  -- can read is the one named.
@@ -3855,6 +4010,26 @@ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
3855
4010
  AND m.name IS NOT NULL;
3856
4011
  $$;
3857
4012
 
4013
+ /* Tables nobody can add a row to: every permissive rule for adding rows is a
4014
+ bare INSERT rule, with neither USING nor WITH CHECK, which Postgres reads as
4015
+ "no" (scan.js noInsertsOf; blind round 15's tickets_insert). */
4016
+ CREATE OR REPLACE FUNCTION __KN__.no_insert_findings(plan jsonb)
4017
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
4018
+ WITH rules AS (
4019
+ SELECT p->>'table_name' AS t, p->>'name' AS n,
4020
+ upper(coalesce(p->>'cmd', 'ALL')) = 'INSERT' AND p->>'qual' IS NULL AND p->>'with_check' IS NULL AS bare
4021
+ FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
4022
+ WHERE upper(coalesce(p->>'permissive', 'PERMISSIVE')) = 'PERMISSIVE'
4023
+ AND upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')
4024
+ ), per AS (
4025
+ SELECT t, jsonb_agg(n ORDER BY n COLLATE "C") FILTER (WHERE bare) AS names, bool_and(bare) AS all_bare
4026
+ FROM rules GROUP BY t
4027
+ )
4028
+ SELECT coalesce(jsonb_agg(jsonb_build_object('kind', 'noinsert', 'table', t, 'rules', names, 'columns', '[]'::jsonb)
4029
+ ORDER BY t COLLATE "C"), '[]'::jsonb)
4030
+ FROM per WHERE all_bare;
4031
+ $$;
4032
+
3858
4033
  /* The ones of those that still take an empty value. */
3859
4034
  CREATE OR REPLACE FUNCTION __KN__.empty_choice_findings(plan jsonb)
3860
4035
  RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
@@ -4131,6 +4306,7 @@ BEGIN
4131
4306
  || __KN__.search_path_findings(plan)
4132
4307
  || refused
4133
4308
  || __KN__.empty_choice_findings(plan)
4309
+ || __KN__.no_insert_findings(plan)
4134
4310
  || __KN__.bucket_findings(plan->'buckets');
4135
4311
 
4136
4312
  -- Everything genuinely attacked, so a re-check can tell a finding that was
@@ -4149,7 +4325,10 @@ BEGIN
4149
4325
  || coalesce((SELECT jsonb_agg('conflict:' || (f->>'name'))
4150
4326
  FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) f), '[]'::jsonb)
4151
4327
  || coalesce((SELECT jsonb_agg('nullcheck:' || (x->>'table') || ':' || (x->'column'->>'name'))
4152
- FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb);
4328
+ FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb)
4329
+ || coalesce((SELECT jsonb_agg(DISTINCT 'noinsert:' || (p->>'table_name'))
4330
+ FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
4331
+ WHERE upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')), '[]'::jsonb);
4153
4332
 
4154
4333
  -- And everything that was not. Said out loud, never left out: a table
4155
4334
  -- 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,21 @@ 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.movein) {
387
+ return 'Anyone signed in can make themselves ' + aRole(finding.toRole) + ' of any ' + oneOf(finding.parent) +
388
+ ' by moving their own row in your ' + finding.table + ' table into it.';
389
+ }
390
+ if (finding.kind === 'role' && finding.promote) {
391
+ return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can make themselves ' + aRole(finding.toRole) +
392
+ ' by changing their own row in your ' + finding.table + ' table.';
393
+ }
380
394
  if (finding.kind === 'role') {
381
395
  const order = ['delete', 'change', 'add'];
382
396
  const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
@@ -387,6 +401,9 @@ function headlineFor(finding) {
387
401
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
388
402
  ' table, which holds ' + listOf(finding.secrets || []) + '.';
389
403
  }
404
+ if (finding.kind === 'bucket' && finding.noRule && (finding.writers || []).length) {
405
+ return 'Your ' + finding.table + ' storage bucket is public, though only signed-in members may put files in it.';
406
+ }
390
407
  if (finding.kind === 'bucket' && finding.noRule) {
391
408
  return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
392
409
  }
@@ -399,6 +416,10 @@ function headlineFor(finding) {
399
416
  // The table name goes in front of the sentence rather than inside it. Put
400
417
  // inside, a table called `customers` produced "One customer can read another
401
418
  // customer's customers", which is the kind of line that loses a reader.
419
+ if (finding.asRole) {
420
+ return capital(aRole(finding.asRole)) + ' of any ' + oneOf(finding.parent) + ' can read every other ' + oneOf(finding.parent) +
421
+ '\'s rows in your ' + finding.table + ' table.';
422
+ }
402
423
  if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
403
424
  return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
404
425
  }
@@ -491,6 +512,32 @@ function bodyFor(finding, contents) {
491
512
  );
492
513
  }
493
514
 
515
+ if (finding.kind === 'role' && finding.movein) {
516
+ // Never the design: nobody was invited, and the team they moved into is
517
+ // somebody else's.
518
+ const place = oneOf(finding.parent);
519
+ return (
520
+ 'Signed in as one of my fake people - not a member of the other one\'s ' + place + ', never invited - I ' +
521
+ 'changed their own row in ' + finding.via + ': ' + listOf((finding.moved || []).map((c) => finding.via + '.' + c)) +
522
+ ' to the other ' + place + ', and ' + finding.via + '.' + finding.roleColumn + ' to "' + finding.toRole +
523
+ '" - on a copy of your app. It went through, and was undone straight away. Anybody who can sign up and has a ' +
524
+ place + ' of their own can do this to every ' + place + ' whose id they learn, and from then on has every right ' +
525
+ aRole(finding.toRole) + ' has there.'
526
+ );
527
+ }
528
+ if (finding.kind === 'role' && finding.promote) {
529
+ // Never the design: a member who can raise their own role has every
530
+ // right the top role has, in every table that trusts the role.
531
+ const place = oneOf(finding.parent);
532
+ return (
533
+ 'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
534
+ ' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' + finding.via + '.' +
535
+ finding.roleColumn + ' on their own membership row to "' + finding.toRole + '" - on a copy of your app. It was ' +
536
+ 'undone straight away. From then on they would have every right ' + aRole(finding.toRole) + ' has, in every ' +
537
+ 'table and function that trusts that role.'
538
+ );
539
+ }
540
+
494
541
  if (finding.kind === 'role') {
495
542
  // Said as what was done, and then whose decision it is: a viewer who may
496
543
  // edit is a hole in one app and the design of another.
@@ -535,8 +582,13 @@ function bodyFor(finding, contents) {
535
582
  return (
536
583
  'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
537
584
  '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 ' +
585
+ ((finding.writers || []).length
586
+ ? 'No rule says who may read it, but ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
587
+ ' let only signed-in members put files in or take them out - so the app treats these files as its ' +
588
+ 'members\' own, while anyone holding a link can download them. '
589
+ : 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
590
+ 'something for everyone. ') +
591
+ 'I only read the bucket\'s setting and its rules; I did not open or list ' +
540
592
  'any file. Whether it should be public is yours to say.'
541
593
  );
542
594
  }
@@ -587,6 +639,17 @@ function bodyFor(finding, contents) {
587
639
  'anything; this is read from how the column is declared.'
588
640
  );
589
641
  }
642
+ if (finding.kind === 'noinsert') {
643
+ return (
644
+ 'Row level security is on for ' + finding.table + ', and the only ' + ((finding.rules || []).length === 1 ? 'rule' : 'rules') +
645
+ ' for adding rows (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
646
+ ((finding.rules || []).length === 1 ? 'has' : 'have') + ' neither USING nor WITH CHECK. A rule with no ' +
647
+ 'expression is not "allow everything": Postgres treats it as false, so every INSERT - from the app, ' +
648
+ 'and from any function that runs as the user - is refused with "new row violates row-level security ' +
649
+ 'policy". If your app adds ' + finding.table + ' rows, that feature is broken for everyone. This is read ' +
650
+ 'from how the rule is written; I did not insert anything.'
651
+ );
652
+ }
590
653
  if (finding.kind === 'privileged') {
591
654
  // Said as what is true, not as what was done - nothing was executed. The
592
655
  // reach is real; whether it is a mistake is the person's to say, so the
@@ -644,6 +707,17 @@ function bodyFor(finding, contents) {
644
707
  (holds ? ', including ' + holds : '') + '.'
645
708
  );
646
709
  }
710
+ if (finding.asRole) {
711
+ // Blind round 15: a role check in a read rule that was never tied to the
712
+ // row's own organization.
713
+ return (
714
+ 'I made one of my fake people ' + aRole(finding.asRole) + ' of their own ' + oneOf(finding.parent) + ' (' + finding.via + '.' +
715
+ finding.roleColumn + ') and, signed in as them, asked for the other ' + oneOf(finding.parent) + '\'s rows in ' + finding.table +
716
+ ' and got ' + finding.readable + ' back' + (holds ? ', including ' + holds : '') + '. With an ordinary role they got none. ' +
717
+ 'So anybody who is ' + aRole(finding.asRole) + ' anywhere - which in most apps means anybody who creates their own ' +
718
+ oneOf(finding.parent) + ' - can read every ' + oneOf(finding.parent) + '\'s rows here.'
719
+ );
720
+ }
647
721
  if (finding.tenant) {
648
722
  return (
649
723
  'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
@@ -898,6 +972,42 @@ function fixPromptFor(finding) {
898
972
  '',
899
973
  'Then check every function that updates "' + finding.via + '" for the same thing.',
900
974
  ]
975
+ : finding.kind === 'role' && finding.movein
976
+ ? [
977
+ 'My app has a security problem - anyone signed in can make themselves ' + aRole(finding.toRole) + ' of any ' +
978
+ oneOf(finding.parent) + '.',
979
+ '',
980
+ 'In my database, a signed-in user can update their own row in "' + finding.via + '" and change ' +
981
+ listOf((finding.moved || []).map((c) => '"' + c + '"')) + ' to another ' + oneOf(finding.parent) +
982
+ ' - one they were never invited to - and "' + finding.roleColumn + '" to "' + finding.toRole + '". The rule only ' +
983
+ 'checks that the row is theirs, not what they change in it. This was proved on a copy of the database.',
984
+ '',
985
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change ' +
986
+ listOf((finding.moved || []).map((c) => '"' + c + '"')) + ' on an existing row, and a member must never be ' +
987
+ 'able to change "' + finding.roleColumn + '" on their own row. Joining happens only by an invitation that ' +
988
+ 'somebody who manages that ' + oneOf(finding.parent) + ' made. Let only the roles that manage the team change ' +
989
+ 'membership rows, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
990
+ '" for auth.uid() and the row\'s own ' + listOf((finding.moved || []).map((c) => '"' + c + '"')) + '.',
991
+ '',
992
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
993
+ ]
994
+ : finding.kind === 'role' && finding.promote
995
+ ? [
996
+ 'My app has a security problem - a member can make themselves ' + aRole(finding.toRole) + '.',
997
+ '',
998
+ 'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
999
+ finding.roleColumn + '" is "' + finding.who + '" can update their own row in "' + finding.via + '" and set "' +
1000
+ finding.roleColumn + '" to "' + finding.toRole + '". The rule only checks that the row is theirs, not what ' +
1001
+ 'they change in it. This was proved on a copy of the database.',
1002
+ '',
1003
+ 'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: a member must never be able to change "' +
1004
+ finding.roleColumn + '" on their own row. Let only the roles that manage the team (usually owner and admin) ' +
1005
+ 'change roles, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
1006
+ '" for auth.uid() - and make sure nobody can raise anyone above their own role. If members need to edit ' +
1007
+ 'something else of their own, allow exactly those columns and not "' + finding.roleColumn + '".',
1008
+ '',
1009
+ 'Then check every function that updates "' + finding.via + '" for the same thing.',
1010
+ ]
901
1011
  : finding.kind === 'role'
902
1012
  ? [
903
1013
  'My app may have a permissions problem - please check it rather than assume it.',
@@ -943,7 +1053,11 @@ function fixPromptFor(finding) {
943
1053
  'My app may have a storage problem - please check it rather than assume it.',
944
1054
  '',
945
1055
  'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
946
- 'download it without logging in' + (finding.noRule
1056
+ 'download it without logging in' + (finding.noRule && (finding.writers || []).length
1057
+ ? ', and no rule on storage.objects says who may read it - though ' +
1058
+ listOf((finding.writers || []).map((r) => '"' + r + '"')) + ' let only signed-in members upload or delete ' +
1059
+ 'its files, which suggests they are not meant for everyone.'
1060
+ : finding.noRule
947
1061
  ? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
948
1062
  'meant for everyone.'
949
1063
  : ', and the storage.objects ' +
@@ -1019,6 +1133,24 @@ function fixPromptFor(finding) {
1019
1133
  'the right value is), then ALTER TABLE "' + finding.table + '" ALTER COLUMN "' + finding.column +
1020
1134
  '" SET NOT NULL. Change nothing else.',
1021
1135
  ]
1136
+ : finding.kind === 'noinsert'
1137
+ ? [
1138
+ 'My app may have a bug - please check it rather than assume it.',
1139
+ '',
1140
+ 'In my database, the rule' + ((finding.rules || []).length === 1 ? ' ' : 's ') +
1141
+ listOf((finding.rules || []).map((r) => '"' + r + '"')) + ' on "' + finding.table + '" for INSERT ' +
1142
+ ((finding.rules || []).length === 1 ? 'has' : 'have') + ' no WITH CHECK, and there is no other rule that ' +
1143
+ 'allows adding rows. Postgres treats a missing check as false, so nobody can add a row to "' + finding.table +
1144
+ '" - not even through a function that runs as the user.',
1145
+ '',
1146
+ 'First tell me: is adding rows to "' + finding.table + '" meant to work for signed-in users? If only your ' +
1147
+ 'server adds them, change nothing.',
1148
+ '',
1149
+ 'If users should be able to add them: give the rule a WITH CHECK that says who may - for example that ' +
1150
+ 'the user is a member of the row\'s organization (through your membership table or a helper) and that ' +
1151
+ 'the column naming who created it equals auth.uid(). Never use WITH CHECK (true): that lets anyone add ' +
1152
+ 'rows anywhere. Then add one row through the app to make sure it works.',
1153
+ ]
1022
1154
  : finding.kind === 'privileged'
1023
1155
  ? [
1024
1156
  'My app may have a security problem - please check it rather than assume it.',
@@ -1166,7 +1298,23 @@ function fixPromptFor(finding) {
1166
1298
  'Then look for the same missing constraint on every other table and fix those too.',
1167
1299
  ],
1168
1300
  )
1169
- : finding.isView
1301
+ : finding.asRole
1302
+ ? [
1303
+ 'My app has a security problem.',
1304
+ '',
1305
+ 'In "' + finding.table + '", anyone who is "' + finding.asRole + '" in their own ' + oneOf(finding.parent) + ' can read the rows ' +
1306
+ 'of every ' + oneOf(finding.parent) + '. I proved it on a copy: as "' + finding.asRole + '" of one ' + oneOf(finding.parent) +
1307
+ ', I read ' + finding.readable + ' row(s) of another.',
1308
+ '',
1309
+ 'A rule on "' + finding.table + '" checks the caller\'s role without tying it to the row - for example ' +
1310
+ 'exists (select 1 from ' + finding.via + ' m where m.' + finding.roleColumn + ' = \'' + finding.asRole + '\') with no ' +
1311
+ 'condition that m is the signed-in user\'s membership of THIS row\'s ' + oneOf(finding.parent) + '. Fix every rule on "' +
1312
+ finding.table + '": a role check must say m.user_id = auth.uid() AND m.<' + oneOf(finding.parent) + ' id> = the row\'s ' +
1313
+ oneOf(finding.parent) + ' id - or call a helper that does. Keep membership as the way in; do not replace it with USING (true).',
1314
+ '',
1315
+ 'Then check every other rule and helper that checks a role for the same missing link.',
1316
+ ]
1317
+ : finding.isView
1170
1318
  ? [
1171
1319
  'My app has a security problem.',
1172
1320
  '',
@@ -1209,7 +1357,7 @@ function fixPromptFor(finding) {
1209
1357
 
1210
1358
  // Found and said, but never claimed as a break: the owner decides. Counted
1211
1359
  // apart in the report, never towards the exit code or a clean re-check.
1212
- const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck'];
1360
+ const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck', 'noinsert'];
1213
1361
  // Functions, said by their name rather than a table's.
1214
1362
  const ON_A_FUNCTION = ['privileged', 'searchpath', 'conflict'];
1215
1363
 
@@ -1223,7 +1371,7 @@ function describe(finding) {
1223
1371
  // one the tool reasons about without executing, so it says so. The report
1224
1372
  // and the re-check both read this: a "verification required" finding is
1225
1373
  // never counted towards a clean re-check on its own.
1226
- status: TO_CHECK.includes(finding.kind) ? 'verification required' : 'confirmed',
1374
+ status: TO_CHECK.includes(finding.kind) && !(finding.kind === 'role' && finding.promote) ? 'verification required' : 'confirmed',
1227
1375
  table: ON_A_FUNCTION.includes(finding.kind) ? finding.fn : finding.table,
1228
1376
  fn: finding.fn,
1229
1377
  args: finding.args,
@@ -1250,14 +1398,21 @@ function describe(finding) {
1250
1398
  readable: finding.readable,
1251
1399
  secrets: finding.secrets,
1252
1400
  bucket: finding.bucket,
1401
+ rules: finding.rules,
1253
1402
  // An organization's rows read from outside it, and a member taking over
1254
1403
  // another member's row: kept so the re-check and the read-back word them
1255
1404
  // the same way the report did.
1256
1405
  tenant: finding.tenant,
1406
+ asRole: finding.asRole,
1257
1407
  takeover: finding.takeover,
1258
1408
  personColumn: finding.personColumn,
1409
+ promote: finding.promote,
1410
+ movein: finding.movein,
1411
+ moved: finding.moved,
1412
+ toRole: finding.toRole,
1259
1413
  nullSlips: finding.nullSlips,
1260
1414
  noRule: finding.noRule,
1415
+ writers: finding.writers,
1261
1416
  // Read from how a function or column is declared (scan.js searchPathsOf,
1262
1417
  // conflictsIn, emptyChoicesOf); kept so the read-back says it the same way.
1263
1418
  target: finding.target,
@@ -1272,6 +1427,9 @@ function describe(finding) {
1272
1427
  ') on the copy, and it refused. I did not call the function.'
1273
1428
  : finding.kind === 'nullcheck'
1274
1429
  ? 'I read that "' + finding.table + '.' + finding.column + '" may be NULL and has no default. I did not insert anything.'
1430
+ : finding.kind === 'noinsert'
1431
+ ? 'I read that every rule for adding rows to "' + finding.table + '" (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1432
+ ') has neither USING nor WITH CHECK. I did not insert anything.'
1275
1433
  : finding.kind === 'recursive'
1276
1434
  ? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
1277
1435
  loopError(finding) + '.'
@@ -1280,6 +1438,15 @@ function describe(finding) {
1280
1438
  : finding.kind === 'role' && finding.takeover
1281
1439
  ? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
1282
1440
  finding.via + '", which I could not touch before joining. It was undone.'
1441
+ : finding.kind === 'role' && finding.movein
1442
+ ? 'Signed in, and not a member, I moved my own row of "' + finding.via + '" into the other ' + oneOf(finding.parent) +
1443
+ ' with "' + finding.roleColumn + '" set to "' + finding.toRole + '". It was undone.'
1444
+ : finding.kind === 'role' && finding.promote
1445
+ ? 'As ' + aRole(finding.who) + ' who had just joined, I set "' + finding.roleColumn + '" on my own row of "' +
1446
+ finding.via + '" to "' + finding.toRole + '". It was undone.'
1447
+ : finding.kind === 'crossed' && finding.asRole
1448
+ ? 'As "' + finding.asRole + '" of my own ' + oneOf(finding.parent) + ', I read ' + finding.readable + ' row(s) of "' +
1449
+ finding.table + '" belonging to another; with an ordinary role, none. My role was put back.'
1283
1450
  : finding.kind === 'role'
1284
1451
  ? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
1285
1452
  listOf((finding.can || []).map((what) => {
@@ -1290,7 +1457,9 @@ function describe(finding) {
1290
1457
  ? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
1291
1458
  ((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
1292
1459
  : 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.'
1460
+ ? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it' +
1461
+ ((finding.writers || []).length ? ', while ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
1462
+ ' limit uploads and deletes to signed-in members' : '') + '. I did not open any file.'
1294
1463
  : finding.kind === 'bucket'
1295
1464
  ? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
1296
1465
  ' says who may read it. I did not open any file.'
@@ -1398,7 +1567,7 @@ function describeAll(findings) {
1398
1567
  // Writes above reads: a table somebody emptied is worse than one they read.
1399
1568
  // A looping rule sits with the reads: it breaks the app for everyone it
1400
1569
  // 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 };
1570
+ 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
1571
  const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
1403
1572
  return described.sort((a, b) => rank(a) - rank(b));
1404
1573
  }
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.39",
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
@@ -242,8 +242,29 @@ function guardedAtTheDoor(body, helpers) {
242
242
  *
243
243
  * `helpers` are names (yes/no helpers about the caller) or { name, nullable }
244
244
  * (a role helper, which answers NULL for a stranger).
245
+ *
246
+ * "Is anybody signed in at all" - IF auth.uid() IS NULL THEN RAISE - counts
247
+ * only when the function takes nothing that could point at somebody else's
248
+ * row (`args`, as pg_get_function_identity_arguments says them). Found on a
249
+ * blind test (Meridian): bootstrap_organization(p_name text) opened with it,
250
+ * made a new organization owned by the caller, and was reported as "anyone
251
+ * on the internet can call it". A visitor with no account is turned away, and
252
+ * a signed-in one can only make their own. Given an organization's id, the
253
+ * same line would let any stranger who signed up act on that organization,
254
+ * so there it is still no guard.
245
255
  */
246
- function doorOf(body, helpers) {
256
+ function namesSomebody(args) {
257
+ if (args === undefined || args === null) return true;
258
+ return String(args).split(',').map((a) => a.trim()).filter(Boolean).some((a) => {
259
+ const words = a.replace(/^(in|inout|variadic)\s+/i, '').split(/\s+/);
260
+ const name = words.length > 1 ? words[0].replace(/"/g, '') : '';
261
+ const type = words.slice(words.length > 1 ? 1 : 0).join(' ');
262
+ return /\b(uuid|oid|int[248]?|integer|bigint|smallint|numeric|serial|bigserial)\b/i.test(type) ||
263
+ /(^|_)(ids?|uids?|orgs?|organizations?|tenants?|teams?|workspaces?|accounts?|users?|owners?|members?|projects?|customers?|compan(y|ies))$/i.test(name);
264
+ });
265
+ }
266
+
267
+ function doorOf(body, helpers, args) {
247
268
  const text = String(body || '');
248
269
  const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
249
270
  const returns = /\breturn\b/i.exec(text);
@@ -267,6 +288,9 @@ function doorOf(body, helpers) {
267
288
  if (refusesOnNo && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) {
268
289
  return { guarded: true, nullSlips: false };
269
290
  }
291
+ const signedIn = /^\(?\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+null\s*\)?$/i.test(cond) ||
292
+ /^not\s*\(\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+not\s+null\s*\)$/i.test(cond);
293
+ if (signedIn && !namesSomebody(args)) return { guarded: true, nullSlips: false };
270
294
  if (list.some((h) => h.nullable && asks(cond, h.name))) {
271
295
  const refuses = refusesOnNo || /\bnot\s+in\s*\(/i.test(cond) || /<>|!=/.test(cond);
272
296
  const nullSafe = /\bcoalesce\s*\(/i.test(cond) || /\bis\s+null\b/i.test(cond) || /\bis\s+not\s+true\s*$/i.test(cond);
@@ -351,7 +375,7 @@ async function readAnonDefinerFunctions(client, schema, role) {
351
375
  // org_role was reported beside the functions it guards.
352
376
  const aboutCaller = (row.returns_bool === true || row.returns_enum === true) && !writes &&
353
377
  /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
354
- const door = doorOf(body, helpers);
378
+ const door = doorOf(body, helpers, row.args || '');
355
379
  return {
356
380
  name: row.name,
357
381
  args: row.args || '',
@@ -454,7 +478,18 @@ const BUCKETS_QUERY =
454
478
  FROM pg_policies p
455
479
  WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
456
480
  AND p.cmd IN ('SELECT', 'ALL')
457
- AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
481
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
482
+ -- The rules for putting files in and taking them out: who the app
483
+ -- lets write a bucket says who its files are for (scan.js bucketsOf).
484
+ 'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
485
+ 'name', p.policyname::text,
486
+ 'roles', to_jsonb(p.roles::text[]),
487
+ 'qual', p.qual,
488
+ 'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
489
+ FROM pg_policies p
490
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
491
+ AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
492
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
458
493
  ) ORDER BY b.id), '[]'::jsonb) AS buckets
459
494
  FROM storage.buckets b`;
460
495
 
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
@@ -457,6 +460,52 @@ async function teammate(client, schema, tables, seeded, rules) {
457
460
  continue;
458
461
  }
459
462
 
463
+ // Their own membership row - the one in their own team - moved into the
464
+ // first person's team, at the role at the top. Found on blind round 16
465
+ // (Meridian): memberships_update_self checked only user_id = auth.uid(),
466
+ // so anybody who had signed up and made a team of their own could move
467
+ // that row into any other organization as its owner, without ever being
468
+ // invited. Tried before they join, so nothing an invitation gave them
469
+ // counts. One row only: two of theirs moved into one team would be
470
+ // refused as duplicates and say nothing.
471
+ const teamColumns = Object.keys(team).filter((name) => team[name] !== null && team[name] !== undefined).sort();
472
+ if (member.top && teamColumns.length) {
473
+ const key = 'movein:' + member.table.name;
474
+ const literal = (value) => "'" + String(value).split("'").join("''") + "'";
475
+ const sets = teamColumns.map((name) => quote(name) + ' = ' + literal(team[name]))
476
+ .concat([quote(member.role) + ' = ' + literal(member.top)]);
477
+ const elsewhere = teamColumns.map((name) => quote(name) + ' IS DISTINCT FROM ' + literal(team[name])).join(' OR ');
478
+ const statement = 'UPDATE ' + at + ' SET ' + sets.join(', ') + ' WHERE ctid = (SELECT ctid FROM ' + at + ' WHERE ' +
479
+ quote(member.person) + " = '" + attack.USER_B + "' AND (" + elsewhere + ') LIMIT 1)';
480
+ const result = await tryWrite(client, 'authenticated', attack.USER_B, statement, []);
481
+ const moved = counted(result);
482
+ if (moved === null) {
483
+ blocked.push({ table: member.table.name, key: key, why: 'moving their own ' + member.table.name + ' row into another ' +
484
+ parent + ': ' + result.why });
485
+ } else {
486
+ completed.push(key);
487
+ if (moved > 0) {
488
+ findings.push({
489
+ kind: 'role',
490
+ table: member.table.name,
491
+ who: 'anyone signed in',
492
+ via: member.table.name,
493
+ roleColumn: member.role,
494
+ parent: parent,
495
+ can: ['movein'],
496
+ changed: { movein: moved },
497
+ below: [],
498
+ columns: (member.table.columns || []).map((c) => c.name),
499
+ rlsEnabled: member.table.rlsEnabled,
500
+ promote: true,
501
+ movein: true,
502
+ toRole: member.top,
503
+ moved: teamColumns,
504
+ });
505
+ }
506
+ }
507
+ }
508
+
460
509
  for (const table of tables) {
461
510
  if (!shapeFor.has(table.name) || done.has(table.name)) continue;
462
511
  done.add(table.name);
@@ -502,6 +551,21 @@ async function teammate(client, schema, tables, seeded, rules) {
502
551
  statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
503
552
  values: [],
504
553
  });
554
+ // Their own membership row, given the role at the top. Found on blind
555
+ // round 15 (Northstar Ops on Supabase): memberships_update checked only
556
+ // user_id = auth.uid(), so a viewer set their own role to 'owner'.
557
+ // Before joining they have no row, so only what joining added counts.
558
+ if (member.top) {
559
+ const ownRow = [quote(member.person) + " = '" + attack.USER_B + "'"]
560
+ .concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
561
+ moves.push({
562
+ what: 'promote',
563
+ anyRole: true,
564
+ statement: 'UPDATE ' + target + ' SET ' + quote(member.role) + " = '" + String(member.top).split("'").join("''") +
565
+ "' WHERE " + ownRow.join(' AND '),
566
+ values: [],
567
+ });
568
+ }
505
569
  }
506
570
  // Adding a row to the team, said only of a role whose own name says it
507
571
  // may only look: a member adding rows is the feature, a viewer adding
@@ -591,12 +655,60 @@ async function teammate(client, schema, tables, seeded, rules) {
591
655
  below: below.slice(),
592
656
  columns: (table.columns || []).map((c) => c.name),
593
657
  rlsEnabled: table.rlsEnabled,
594
- }, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {}));
658
+ }, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {},
659
+ can.includes('promote') ? { promote: true, toRole: member.top } : {}));
595
660
  break;
596
661
  }
597
662
  if (!listed) below.push(role);
598
663
  }
599
664
 
665
+ // In charge of their OWN team, reading the first person's. Found on
666
+ // blind round 15 (Northstar Ops on Supabase): projects_select let in
667
+ // anyone for whom `exists (select 1 from memberships m where m.role =
668
+ // 'admin')` - not tied to the project's organization - so an admin of
669
+ // any organization read every organization's projects. The second
670
+ // person's own membership is given each role in charge, one at a time,
671
+ // and the first person's team's rows are asked for; only what the role
672
+ // added counts. A table that names neither the team nor a person is
673
+ // not asked.
674
+ const theirTeam = Object.keys(pointed).length
675
+ ? Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'").join(' AND ')
676
+ : owner ? quote(owner) + " = '" + attack.USER_A + "'" : null;
677
+ if (theirTeam && member.inCharge.length) {
678
+ const look = 'SELECT 1 FROM ' + target + ' WHERE ' + theirTeam;
679
+ const plain = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
680
+ for (const label of member.inCharge) {
681
+ const key = 'elevated:' + table.name + ':' + label;
682
+ const raise = {
683
+ statement: 'UPDATE ' + quote(schema) + '.' + quote(member.table.name) + ' SET ' + quote(member.role) + ' = $1 WHERE ' +
684
+ quote(member.person) + ' = $2',
685
+ values: [label, attack.USER_B],
686
+ };
687
+ const now = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, [], raise));
688
+ if (plain === null || now === null) {
689
+ blocked.push({ table: table.name, key: key, why: 'as "' + label + '" in their own ' + parent + ' row: reading it failed' });
690
+ break;
691
+ }
692
+ completed.push(key);
693
+ if (now > plain) {
694
+ findings.push({
695
+ kind: 'crossed',
696
+ table: table.name,
697
+ owner: owner || Object.keys(pointed).sort()[0],
698
+ tenant: !owner,
699
+ readable: now - plain,
700
+ asRole: label,
701
+ via: member.table.name,
702
+ roleColumn: member.role,
703
+ parent: parent,
704
+ columns: (table.columns || []).map((c) => c.name),
705
+ rlsEnabled: table.rlsEnabled,
706
+ });
707
+ break;
708
+ }
709
+ }
710
+ }
711
+
600
712
  // Reading what a member should not see, said only of a table holding a
601
713
  // secret. Every role not in charge, from the lowest up; the first that
602
714
  // can read is the one named. A member reading their team's ordinary