kryptheon-night 0.1.38 → 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
@@ -393,7 +393,7 @@ END $$;
393
393
  * and the first RETURN). `helpers` are { name, nullable } - a yes/no about
394
394
  * the caller, or a role helper that answers NULL for a stranger.
395
395
  */
396
- 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)
397
397
  RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
398
398
  DECLARE
399
399
  text_ text := coalesce(body, '');
@@ -411,6 +411,16 @@ DECLARE
411
411
  h jsonb;
412
412
  asks boolean;
413
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))$')));
414
424
  BEGIN
415
425
  IF text_ ~* writes_re THEN
416
426
  first_act := least(first_act, length((regexp_split_to_array(text_, writes_re, 'i'))[1]) + 1);
@@ -440,6 +450,10 @@ BEGIN
440
450
  IF refuses_on_no AND cond ~* '\yauth\s*\.\s*(uid|jwt)\s*\(' AND cond ~* 'admin|owner|member|role' THEN
441
451
  RETURN jsonb_build_object('guarded', true, 'nullSlips', false);
442
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;
443
457
  END LOOP;
444
458
  RETURN jsonb_build_object('guarded', false, 'nullSlips', slips);
445
459
  END $$;
@@ -497,7 +511,7 @@ BEGIN
497
511
  body := regexp_replace(coalesce(r.def, ''), '--[^\n]*', ' ', 'g');
498
512
  conf := replace(replace(replace(coalesce(r.config, ''), '{', ''), '}', ''), '"', '');
499
513
  writes := body ~* writes_re;
500
- door := __KN__.door_of(body, helpers);
514
+ door := __KN__.door_of(body, helpers, coalesce(r.args, ''));
501
515
  out_ := out_ || jsonb_build_array(jsonb_build_object(
502
516
  'name', r.name,
503
517
  'args', coalesce(r.args, ''),
@@ -3149,6 +3163,11 @@ DECLARE
3149
3163
  before_all jsonb;
3150
3164
  below jsonb;
3151
3165
  listed boolean;
3166
+ team_columns text[];
3167
+ move_sets text;
3168
+ move_elsewhere text;
3169
+ moved_ bigint;
3170
+ move_result jsonb;
3152
3171
  their_team text;
3153
3172
  plain_ bigint;
3154
3173
  label_ text;
@@ -3218,6 +3237,51 @@ BEGIN
3218
3237
  CONTINUE;
3219
3238
  END IF;
3220
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
+
3221
3285
  FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
3222
3286
  CONTINUE WHEN NOT EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
3223
3287
  WHERE s->>'table' = tab->>'name');
package/finding.js CHANGED
@@ -383,6 +383,10 @@ function headlineFor(finding) {
383
383
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
384
384
  finding.table + ' table - and with it their role.';
385
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
+ }
386
390
  if (finding.kind === 'role' && finding.promote) {
387
391
  return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can make themselves ' + aRole(finding.toRole) +
388
392
  ' by changing their own row in your ' + finding.table + ' table.';
@@ -508,6 +512,19 @@ function bodyFor(finding, contents) {
508
512
  );
509
513
  }
510
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
+ }
511
528
  if (finding.kind === 'role' && finding.promote) {
512
529
  // Never the design: a member who can raise their own role has every
513
530
  // right the top role has, in every table that trusts the role.
@@ -955,6 +972,25 @@ function fixPromptFor(finding) {
955
972
  '',
956
973
  'Then check every function that updates "' + finding.via + '" for the same thing.',
957
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
+ ]
958
994
  : finding.kind === 'role' && finding.promote
959
995
  ? [
960
996
  'My app has a security problem - a member can make themselves ' + aRole(finding.toRole) + '.',
@@ -1371,6 +1407,8 @@ function describe(finding) {
1371
1407
  takeover: finding.takeover,
1372
1408
  personColumn: finding.personColumn,
1373
1409
  promote: finding.promote,
1410
+ movein: finding.movein,
1411
+ moved: finding.moved,
1374
1412
  toRole: finding.toRole,
1375
1413
  nullSlips: finding.nullSlips,
1376
1414
  noRule: finding.noRule,
@@ -1400,6 +1438,9 @@ function describe(finding) {
1400
1438
  : finding.kind === 'role' && finding.takeover
1401
1439
  ? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
1402
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.'
1403
1444
  : finding.kind === 'role' && finding.promote
1404
1445
  ? 'As ' + aRole(finding.who) + ' who had just joined, I set "' + finding.roleColumn + '" on my own row of "' +
1405
1446
  finding.via + '" to "' + finding.toRole + '". It was undone.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.38",
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",
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 || '',
package/tamper.js CHANGED
@@ -460,6 +460,52 @@ async function teammate(client, schema, tables, seeded, rules) {
460
460
  continue;
461
461
  }
462
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
+
463
509
  for (const table of tables) {
464
510
  if (!shapeFor.has(table.name) || done.has(table.name)) continue;
465
511
  done.add(table.name);