kryptheon-night 0.1.1 → 0.1.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/schema.js +75 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/schema.js CHANGED
@@ -499,6 +499,44 @@ function rewriteSchemaRefs(expr, fromSchema, toSchema) {
499
499
  return String(expr).split(quote(fromSchema) + '.').join(quote(toSchema) + '.').split(fromSchema + '.').join(toSchema + '.');
500
500
  }
501
501
 
502
+ /**
503
+ * Points a reference with no schema on it at the copy.
504
+ *
505
+ * The one the other two rewrites could not see. `pg_get_constraintdef` writes
506
+ * the schema only when the referenced table is *not* reachable through
507
+ * `search_path` - so an app living in `app_something` comes back as
508
+ * "REFERENCES app_something.profiles(id)" and is rewritten, while the same app
509
+ * living in `public` comes back as "REFERENCES profiles(id)" and there is
510
+ * nothing to rewrite. Replayed into the copy, that bare name resolves through
511
+ * `search_path` all over again, and binds to the customer's real table.
512
+ *
513
+ * Found on the first real Supabase project this was ever pointed at, which is
514
+ * the first app that was not in a schema of its own:
515
+ *
516
+ * notes_owner_fkey -> public.profiles (the customer's own table)
517
+ * orders_user_id_fkey -> public.profiles (the customer's own table)
518
+ *
519
+ * Nothing was written to those tables - the copy only pointed at them - but
520
+ * the copy was not the app, which is the same failure three other bugs in this
521
+ * file were. Here it meant every insert into the copy was checked against a
522
+ * table the seeder had put nothing in, so two tables out of four were never
523
+ * attacked and were reported, honestly but uselessly, as not checked.
524
+ *
525
+ * Only names the plan itself owns are touched. A reference to something
526
+ * outside the schema is the stand-in's business, not this one's.
527
+ */
528
+ function qualifyOwnRefs(definition, plan, target) {
529
+ const own = new Set((plan.tables || []).map((table) => table.name));
530
+ // A replacer function, never a replacement string: `$&` and friends are read
531
+ // as instructions inside one, and that has already cost this project an
532
+ // engine that would not install.
533
+ return String(definition).replace(
534
+ /(\bREFERENCES\s+)("?)([^".\s(]+)\2(\s*\()/gi,
535
+ (whole, before, quoteMark, name, after) =>
536
+ (own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole),
537
+ );
538
+ }
539
+
502
540
  /**
503
541
  * Points a foreign key at the stand-in instead of at the real outside table.
504
542
  *
@@ -669,9 +707,13 @@ async function writeSchema(client, plan, target) {
669
707
  for (const constraint of table.constraints) {
670
708
  const isForeign = constraint.kind === 'f';
671
709
  if (isForeign !== wantForeign) continue;
672
- const definition = rewriteExternalRefs(
673
- rewriteSchemaRefs(constraint.definition, plan.schema, target),
674
- plan.external,
710
+ const definition = qualifyOwnRefs(
711
+ rewriteExternalRefs(
712
+ rewriteSchemaRefs(constraint.definition, plan.schema, target),
713
+ plan.external,
714
+ target,
715
+ ),
716
+ plan,
675
717
  target,
676
718
  );
677
719
  statements.push(
@@ -770,6 +812,36 @@ async function writeSchema(client, plan, target) {
770
812
  for (const statement of statements) {
771
813
  await client.query(statement);
772
814
  }
815
+
816
+ // And then look at what was actually built, rather than at what was meant.
817
+ //
818
+ // Everything above this line reasons about strings. The guard at the top of
819
+ // this file reads the statements before they run; this one asks Postgres
820
+ // where the copy ended up pointing, which is the only account of it that
821
+ // cannot be fooled by a spelling nobody anticipated - and one was not. A
822
+ // bare "REFERENCES profiles(id)" replayed into the copy bound to the
823
+ // customer's own table, silently, on every app that lives in `public`.
824
+ //
825
+ // Nothing is written to a table outside the copy either way. The damage is
826
+ // subtler than that: a copy tied to the customer's rows is not the app being
827
+ // attacked, and every verdict taken from it is about something else.
828
+ const { rows: strays } = await client.query(
829
+ `SELECT con.conname, rn.nspname AS points_at
830
+ FROM pg_constraint con
831
+ JOIN pg_class cl ON cl.oid = con.conrelid
832
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
833
+ JOIN pg_class rc ON rc.oid = con.confrelid
834
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
835
+ WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1`,
836
+ [target],
837
+ );
838
+ if (strays.length) {
839
+ throw new Error(
840
+ 'the copy points outside itself, so it is not the app: ' +
841
+ strays.map((row) => row.conname + ' -> ' + row.points_at).join(', '),
842
+ );
843
+ }
844
+
773
845
  return statements;
774
846
  }
775
847