kryptheon-night 0.1.22 → 0.1.25

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/attack.js CHANGED
@@ -228,6 +228,70 @@ function foreignKeys(table) {
228
228
  return keys;
229
229
  }
230
230
 
231
+ /** The single-column primary key of a table, or null if it has none. */
232
+ function primaryKeyOf(table) {
233
+ for (const constraint of table.constraints || []) {
234
+ if (constraint.kind !== 'p') continue;
235
+ const match = /PRIMARY KEY \(([^)]+)\)/i.exec(constraint.definition);
236
+ if (!match) continue;
237
+ const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
238
+ // A composite key is not something a single `<thing>_id` column points at.
239
+ return columns.length === 1 ? columns[0] : null;
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * The table a column named `<thing>_id` is pointing at, if there is one.
246
+ *
247
+ * The name has to match a table that is really here, and the types have to
248
+ * agree. Both, because `stripe_id` matches nothing and `org_id integer` does
249
+ * not point at an `orgs.id` that is a uuid.
250
+ */
251
+ function parentFor(column, tables) {
252
+ const stem = /^(.+)_id$/i.exec(column.name);
253
+ if (!stem) return null;
254
+ const wanted = stem[1].toLowerCase();
255
+ const parent = (tables || []).find((table) => {
256
+ const name = table.name.toLowerCase();
257
+ return name === wanted || name === wanted + 's' || name === wanted + 'es';
258
+ });
259
+ if (!parent) return null;
260
+
261
+ const key = primaryKeyOf(parent);
262
+ if (!key) return null;
263
+ const keyColumn = (parent.columns || []).find((c) => c.name === key);
264
+ if (!keyColumn || keyColumn.type !== column.type) return null;
265
+
266
+ return { table: parent, keyColumn: key, type: keyColumn.type };
267
+ }
268
+
269
+ /**
270
+ * The keys a table would have if it had been given them: a `<thing>_id`
271
+ * column naming a table that is here, with no foreign key on it.
272
+ *
273
+ * Found on a blind test. A production snapshot had no foreign keys at all, so
274
+ * workspace_members.workspace_id was seeded with an invented value that named
275
+ * no workspace. A view joining members to their workspaces then showed
276
+ * nothing, and "anyone can list every workspace and who is in it" - a view
277
+ * granted to logged-out visitors - read as an empty room.
278
+ *
279
+ * Only ever used to pick a value. Nothing is refused for lack of one: the
280
+ * database never promised the parent would be there.
281
+ */
282
+ function impliedKeys(table, tables) {
283
+ const tied = new Set();
284
+ for (const key of foreignKeys(table)) key.columns.forEach((name) => tied.add(name));
285
+ const keys = [];
286
+ for (const column of table.columns || []) {
287
+ if (tied.has(column.name)) continue;
288
+ const parent = parentFor(column, tables);
289
+ if (!parent || parent.table.name === table.name) continue;
290
+ keys.push({ columns: [column.name], refTable: parent.table.name, refColumns: [parent.keyColumn] });
291
+ }
292
+ return keys;
293
+ }
294
+
231
295
  /**
232
296
  * Parents before children.
233
297
  *
@@ -236,6 +300,9 @@ function foreignKeys(table) {
236
300
  * pointed at, because every app has one of these. A cycle is left in whatever
237
301
  * order it arrived: it cannot be satisfied anyway, and the table that fails is
238
302
  * reported rather than dropped.
303
+ *
304
+ * A key the app never declared counts too. A member row seeded before the
305
+ * workspace it names has nothing real to point at.
239
306
  */
240
307
  function dependencyOrder(tables) {
241
308
  const byName = new Map(tables.map((t) => [t.name, t]));
@@ -246,7 +313,7 @@ function dependencyOrder(tables) {
246
313
  const visit = (table) => {
247
314
  if (done.has(table.name) || visiting.has(table.name)) return;
248
315
  visiting.add(table.name);
249
- for (const key of foreignKeys(table)) {
316
+ for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
250
317
  const parent = byName.get(key.refTable);
251
318
  if (parent && parent.name !== table.name) visit(parent);
252
319
  }
@@ -265,8 +332,15 @@ function dependencyOrder(tables) {
265
332
  * The whole row, not one column of it. A composite key has to point at a pair
266
333
  * that exists together: borrowing each column from a separate row would build
267
334
  * a combination the parent never had.
335
+ *
336
+ * `mine` is the row seeded for the same person, when there is one. Without it
337
+ * both fake people were put in the first workspace - the same team, both as
338
+ * its owner - and every rule written "members of this workspace may read it"
339
+ * let each read the other's rows. Six tables of one blind test were reported
340
+ * as one customer reading another's data, when they were teammates.
268
341
  */
269
- async function existingRow(client, schema, key) {
342
+ async function existingRow(client, schema, key, mine) {
343
+ if (mine) return key.refColumns.map((name) => mine[name]);
270
344
  try {
271
345
  const columns = key.refColumns.map((name, i) => quote(name) + ' AS v' + i).join(', ');
272
346
  const { rows } = await client.query(
@@ -291,19 +365,28 @@ async function existingRow(client, schema, key) {
291
365
  * they collide on that column and on nothing else. Without it a second unique
292
366
  * column elsewhere in the table would refuse the insert, and the refusal would
293
367
  * be read as the app defending itself.
368
+ *
369
+ * `seeding` is only passed by the seeder: `rows`, the rows already seeded for
370
+ * each person, table by table, so a row points at its own person's parent;
371
+ * and `implied`, the keys the app never declared, so a `workspace_id` with no
372
+ * foreign key still names a workspace that is there.
294
373
  */
295
- async function rowFor(client, schema, table, person, distinct, overrides, attempt) {
374
+ async function rowFor(client, schema, table, person, distinct, overrides, attempt, seeding) {
296
375
  const forced = overrides || {};
297
376
  const owner = ownerColumn(table);
298
377
  const columns = [];
299
378
  const values = [];
379
+ const sown = (seeding && seeding.rows) || new Map();
380
+ const implied = (seeding && seeding.implied) || [];
300
381
 
301
382
  // Every column that takes part in a foreign key, and the value it has to
302
383
  // hold. Resolved one key at a time so that all of a composite key's columns
303
- // come from the same parent row.
384
+ // come from the same parent row. Declared keys first, so a key the app wrote
385
+ // always wins over one guessed from a name.
304
386
  const borrowed = new Map();
305
- for (const key of foreignKeys(table)) {
306
- const row = await existingRow(client, schema, key);
387
+ for (const key of foreignKeys(table).concat(implied)) {
388
+ const theirs = sown.get(key.refTable);
389
+ const row = await existingRow(client, schema, key, theirs && theirs.get(person));
307
390
  if (!row) continue;
308
391
  key.columns.forEach((name, i) => {
309
392
  if (!borrowed.has(name)) borrowed.set(name, row[i]);
@@ -345,6 +428,14 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
345
428
  // fact about how readColumns fills the shape, not about Postgres, and the
346
429
  // day it changes this is the only thing standing in the way.
347
430
  if (column.generated || column.identity) continue;
431
+ // A key nobody declared, pointing at a parent that is there. Filled even
432
+ // where a default or a null would do, because a row that names no real
433
+ // parent is invisible to every rule and view that joins through it.
434
+ if (borrowed.has(column.name)) {
435
+ columns.push(column.name);
436
+ values.push(borrowed.get(column.name));
437
+ continue;
438
+ }
348
439
  // Anything with a default can supply its own value.
349
440
  if (column.default_expr) continue;
350
441
  if (!column.not_null) continue;
@@ -361,21 +452,36 @@ async function rowFor(client, schema, table, person, distinct, overrides, attemp
361
452
  return { columns: columns, values: values };
362
453
  }
363
454
 
364
- /** Puts a built row in. Separate so the same row can be raced against itself. */
365
- function insertRow(client, schema, table, row) {
455
+ /**
456
+ * Puts a built row in. Separate so the same row can be raced against itself.
457
+ * `back` asks for the row as it landed, defaults and all.
458
+ */
459
+ function insertRow(client, schema, table, row, back) {
366
460
  const where = 'INSERT INTO ' + quote(schema) + '.' + quote(table);
461
+ const tail = back ? ' RETURNING *' : '';
367
462
  // A table of nothing but an id and its defaults leaves no columns to name,
368
463
  // and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a spelling
369
464
  // for exactly this, and without it every settings and flags table in the
370
465
  // world came back as one that could not be checked.
371
- if (!row.columns.length) return client.query(where + ' DEFAULT VALUES');
466
+ if (!row.columns.length) return client.query(where + ' DEFAULT VALUES' + tail);
372
467
  const placeholders = row.values.map((_, i) => '$' + (i + 1));
373
468
  return client.query(
374
- where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')',
469
+ where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')' + tail,
375
470
  row.values,
376
471
  );
377
472
  }
378
473
 
474
+ /** Every table some other table points at, by a key it declared or one it implies. */
475
+ function parentTables(tables) {
476
+ const parents = new Set();
477
+ for (const table of tables) {
478
+ for (const key of foreignKeys(table).concat(impliedKeys(table, tables))) {
479
+ if (key.refTable !== table.name) parents.add(key.refTable);
480
+ }
481
+ }
482
+ return parents;
483
+ }
484
+
379
485
  // How many differently-shaped values to try before giving up on a table. The
380
486
  // rules that reject the first attempt - a domain insisting on an @, a CHECK on
381
487
  // a length - cannot be read out of the catalogue, so the only honest way to
@@ -395,39 +501,57 @@ const SHAPES_TO_TRY = 5;
395
501
  async function seed(client, schema, tables) {
396
502
  const seeded = [];
397
503
  const skipped = [];
504
+ // Each person's own rows, table by table, so that what they own points at
505
+ // their own workspace rather than the first one that went in.
506
+ const rows = new Map();
507
+ const parents = parentTables(tables);
398
508
  for (const table of dependencyOrder(tables)) {
399
509
  const owner = ownerColumn(table);
510
+ const seeding = { rows: rows, implied: impliedKeys(table, tables) };
400
511
 
401
512
  // A table with nobody's name on it still gets a row. Without one, an open
402
513
  // door cannot be told from an empty room: a logged-out stranger reads zero
403
514
  // rows either way, and the tool reports the app as safe. Settings tables,
404
515
  // waitlists and contact forms are exactly this shape, and exactly the ones
405
516
  // that get left open.
406
- const people = owner ? [USER_A, USER_B] : [USER_A];
517
+ //
518
+ // One that other tables point at - a workspace, a team, an organisation -
519
+ // gets one for each person, so the two of them are in different teams
520
+ // and a team rule is tested across teams rather than inside one. Some
521
+ // tables only ever hold one row, so a pair that will not go in falls back
522
+ // to the single row it always had.
523
+ const casts = owner ? [[USER_A, USER_B]]
524
+ : parents.has(table.name) ? [[USER_A, USER_B], [USER_A]] : [[USER_A]];
407
525
 
408
526
  // Each attempt offers a differently-shaped set of values. A table whose
409
527
  // rules reject all of them is reported, never quietly passed over.
410
528
  let refused = null;
411
529
  let landed = false;
412
530
  let worked = 0;
413
- for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
414
- try {
415
- let nth = 0;
416
- for (const person of people) {
417
- nth += 1;
418
- const row = await rowFor(client, schema, table, person, nth, null, attempt);
419
- await insertRow(client, schema, table.name, row);
531
+ for (const people of casts) {
532
+ for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
533
+ const theirs = new Map();
534
+ try {
535
+ let nth = 0;
536
+ for (const person of people) {
537
+ nth += 1;
538
+ const row = await rowFor(client, schema, table, person, nth, null, attempt, seeding);
539
+ const result = await insertRow(client, schema, table.name, row, true);
540
+ theirs.set(person, result.rows[0]);
541
+ }
542
+ landed = true;
543
+ worked = attempt;
544
+ rows.set(table.name, theirs);
545
+ } catch (err) {
546
+ refused = err.message;
547
+ // A half-seeded table would make the next attempt collide with its own
548
+ // first row, so anything that did go in is taken back out.
549
+ await client
550
+ .query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
551
+ .catch(() => {});
420
552
  }
421
- landed = true;
422
- worked = attempt;
423
- } catch (err) {
424
- refused = err.message;
425
- // A half-seeded table would make the next attempt collide with its own
426
- // first row, so anything that did go in is taken back out.
427
- await client
428
- .query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
429
- .catch(() => {});
430
553
  }
554
+ if (landed) break;
431
555
  }
432
556
 
433
557
  if (landed) {
@@ -513,6 +637,17 @@ function refusalMeans(message) {
513
637
  * in its own right - and it names the table at fault, which is often not the
514
638
  * one that was being read.
515
639
  */
640
+ /**
641
+ * The other shape the same loop takes. When the rule reaches its own table
642
+ * through a helper function rather than directly, Postgres does not see a
643
+ * policy recursing - it runs out of stack - and the message names no table.
644
+ * Found on a blind test: every signed-in read failed with "stack depth limit
645
+ * exceeded", and the report filed each one only as untested.
646
+ */
647
+ function tooDeep(message) {
648
+ return /stack depth limit exceeded/i.test(String(message || ''));
649
+ }
650
+
516
651
  function recursionIn(message) {
517
652
  const found = /infinite recursion detected in policy for relation "([^"]+)"/i.exec(String(message || ''));
518
653
  return found ? found[1] : null;
@@ -561,8 +696,11 @@ async function impersonate(client, schema, tables) {
561
696
  // with nobody's name on it fails just the same, and it is the same
562
697
  // broken page.
563
698
  for (const [answer, who] of [[anon, 'anyone'], [asA, 'signed-in']]) {
564
- const relation = !Array.isArray(answer) && recursionIn(answer.blocked);
565
- if (relation) looped.push({ relation: relation, table: table.name, who: who });
699
+ if (Array.isArray(answer)) continue;
700
+ const relation = recursionIn(answer.blocked);
701
+ // A loop with no table named is recorded with none; the scan works out
702
+ // which table's rule it is from the rules and helpers it has read.
703
+ if (relation || tooDeep(answer.blocked)) looped.push({ relation: relation || null, table: table.name, who: who });
566
704
  }
567
705
  // Only a read of the table itself that came back with a verdict, for both
568
706
  // callers, shows its rules no longer loop. A read that failed for some
@@ -617,9 +755,14 @@ module.exports = {
617
755
  USER_D: USER_D,
618
756
  refusalMeans: refusalMeans,
619
757
  recursionIn: recursionIn,
758
+ tooDeep: tooDeep,
620
759
  ownerColumn: ownerColumn,
621
760
  fitTo: fitTo,
622
761
  foreignKeys: foreignKeys,
762
+ primaryKeyOf: primaryKeyOf,
763
+ parentFor: parentFor,
764
+ impliedKeys: impliedKeys,
765
+ parentTables: parentTables,
623
766
  dependencyOrder: dependencyOrder,
624
767
  valueFor: valueFor,
625
768
  allowedByCheck: allowedByCheck,
@@ -614,7 +614,7 @@ async function main() {
614
614
  try {
615
615
  process.exitCode = await runVerb(asked.command, client, target);
616
616
  } catch (err) {
617
- block(trouble.explain(err, connection), fail);
617
+ block(trouble.explain(err, connection, { connected: true }), fail);
618
618
  process.exitCode = 2;
619
619
  } finally {
620
620
  await client.end().catch(() => {});
@@ -668,7 +668,7 @@ async function main() {
668
668
  // Anything that went wrong mid-scan. The copy has already been dropped by
669
669
  // the `finally` inside the scan itself, so there is nothing to clean up
670
670
  // here - only something to say.
671
- block(trouble.explain(err, connection), fail);
671
+ block(trouble.explain(err, connection, { connected: true }), fail);
672
672
  process.exitCode = 2;
673
673
  } finally {
674
674
  await client.end().catch(() => {});