kryptheon-night 0.1.1 → 0.1.3

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 +141 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon-night",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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,57 @@ 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
+ // A quoted identifier can hold anything, including spaces and quotes of its
534
+ // own doubled up, so it is matched as a whole rather than as a run of safe
535
+ // characters. The first attempt used [^".\s(]+ inside the quotes and
536
+ // silently did not match `REFERENCES "Group Table"(...)` - which the twin
537
+ // fixture has, because it was built out of every shape that has ever been
538
+ // read wrong, and it caught this the first time it ran.
539
+ //
540
+ // The unquoted alternative excludes a dot, so a name that is already
541
+ // schema-qualified is left alone: that is the other rewrite's work.
542
+ return String(definition).replace(
543
+ /(\bREFERENCES\s+)("(?:[^"]|"")*"|[^\s(".]+)(\s*\()/gi,
544
+ (whole, before, written, after) => {
545
+ const name = written.charAt(0) === '"'
546
+ ? written.slice(1, -1).split('""').join('"')
547
+ : written;
548
+ return own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole;
549
+ },
550
+ );
551
+ }
552
+
502
553
  /**
503
554
  * Points a foreign key at the stand-in instead of at the real outside table.
504
555
  *
@@ -669,9 +720,13 @@ async function writeSchema(client, plan, target) {
669
720
  for (const constraint of table.constraints) {
670
721
  const isForeign = constraint.kind === 'f';
671
722
  if (isForeign !== wantForeign) continue;
672
- const definition = rewriteExternalRefs(
673
- rewriteSchemaRefs(constraint.definition, plan.schema, target),
674
- plan.external,
723
+ const definition = qualifyOwnRefs(
724
+ rewriteExternalRefs(
725
+ rewriteSchemaRefs(constraint.definition, plan.schema, target),
726
+ plan.external,
727
+ target,
728
+ ),
729
+ plan,
675
730
  target,
676
731
  );
677
732
  statements.push(
@@ -767,9 +822,89 @@ async function writeSchema(client, plan, target) {
767
822
  }
768
823
 
769
824
  mustStayInside(statements, target);
770
- for (const statement of statements) {
771
- await client.query(statement);
825
+ // Built with the copy first on the search_path, so a bare name binds to the
826
+ // copy and not to whatever the customer happens to have.
827
+ //
828
+ // This is the structural half, and it went in after chasing the same bug
829
+ // through three different kinds of expression. Postgres writes a name
830
+ // without its schema whenever that name is already reachable - so a real app
831
+ // in `public` hands back "REFERENCES profiles(id)" and
832
+ // "nextval('orders_id_seq'::regclass)", and a rewrite that goes looking for
833
+ // a schema name finds nothing to change in either. Replayed into the copy,
834
+ // both bound to the customer's own objects: the foreign keys pointed at
835
+ // their tables, and the copy drew its keys from their sequences, which
836
+ // advanced them.
837
+ //
838
+ // Rewriting each kind of expression in turn is a game with no end - index
839
+ // predicates, checks that call a function, view bodies. Naming the copy
840
+ // first on the path ends all of them at once, because it changes what a
841
+ // bare name means rather than trying to find every place one can appear.
842
+ //
843
+ // `public` stays on the path, after the copy, because the copy legitimately
844
+ // needs what lives there - gen_random_uuid() and the like. `extensions` is
845
+ // where Supabase keeps them; a schema on the path that does not exist is
846
+ // ignored rather than an error.
847
+ const { rows: pathRows } = await client.query('SHOW search_path');
848
+ const restoreTo = pathRows[0].search_path;
849
+ await client.query('SET search_path TO ' + quote(target) + ', public, extensions');
850
+ try {
851
+ for (const statement of statements) {
852
+ await client.query(statement);
853
+ }
854
+ } finally {
855
+ await client.query('SET search_path TO ' + restoreTo).catch(() => {});
772
856
  }
857
+
858
+ // And then look at what was actually built, rather than at what was meant.
859
+ //
860
+ // Everything above this line reasons about strings. The guard at the top of
861
+ // this file reads the statements before they run; this one asks Postgres
862
+ // where the copy ended up pointing, which is the only account of it that
863
+ // cannot be fooled by a spelling nobody anticipated - and one was not. A
864
+ // bare "REFERENCES profiles(id)" replayed into the copy bound to the
865
+ // customer's own table, silently, on every app that lives in `public`.
866
+ //
867
+ // Nothing is written to a table outside the copy either way. The damage is
868
+ // subtler than that: a copy tied to the customer's rows is not the app being
869
+ // attacked, and every verdict taken from it is about something else.
870
+ // Foreign keys AND column defaults, because the second one is where this
871
+ // hid after the first was closed: a serial column's default is
872
+ // nextval('<sequence>'), and the copy was calling the customer's. Nothing
873
+ // was written to their tables, but nextval advances a sequence, so their
874
+ // database changed - and "we never touch your live app" says at all.
875
+ //
876
+ // Asked of pg_depend rather than of the text, so it holds for any
877
+ // expression that ends up pointing at a relation, not only the ones anybody
878
+ // thought to look at. pg_catalog is excluded because everything depends on
879
+ // it; anything else outside the copy is the bug.
880
+ const { rows: strays } = await client.query(
881
+ `SELECT con.conname AS what, rn.nspname AS points_at
882
+ FROM pg_constraint con
883
+ JOIN pg_class cl ON cl.oid = con.conrelid
884
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
885
+ JOIN pg_class rc ON rc.oid = con.confrelid
886
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
887
+ WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1
888
+ UNION ALL
889
+ SELECT cl.relname || '.' || a.attname || ' default' AS what,
890
+ rn.nspname || '.' || rc.relname AS points_at
891
+ FROM pg_depend d
892
+ JOIN pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_attrdef'::regclass
893
+ JOIN pg_class cl ON cl.oid = ad.adrelid
894
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
895
+ JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
896
+ JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
897
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
898
+ WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'`,
899
+ [target],
900
+ );
901
+ if (strays.length) {
902
+ throw new Error(
903
+ 'the copy points outside itself, so it is not the app: ' +
904
+ strays.map((row) => row.what + ' -> ' + row.points_at).join(', '),
905
+ );
906
+ }
907
+
773
908
  return statements;
774
909
  }
775
910
 
@@ -915,6 +1050,7 @@ module.exports = {
915
1050
  readExternalTargets: readExternalTargets,
916
1051
  referenceIn: referenceIn,
917
1052
  rewriteSchemaRefs: rewriteSchemaRefs,
1053
+ qualifyOwnRefs: qualifyOwnRefs,
918
1054
  stubNameFor: stubNameFor,
919
1055
  IDENTITIES: IDENTITIES,
920
1056
  quote: quote,