kryptheon-night 0.1.4 → 0.1.6

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/schema.js CHANGED
@@ -1,1058 +1,1137 @@
1
- // Reading a backend's shape, and rebuilding it somewhere safe.
2
- //
3
- // The night shift never touches the live app. It reads the shape - tables,
4
- // columns, constraints, whether row level security is on, and the policies
5
- // themselves - rebuilds that shape in a throwaway database, seeds fake people
6
- // into it, and attacks that.
7
- //
8
- // The thing to be careful about is not the copying. It is that a copy which
9
- // differs from the original, even slightly, produces verdicts about a database
10
- // nobody is running. A policy that comes across subtly changed is worse than no
11
- // copy at all: it looks like a real answer. So everything here is built to fail
12
- // loudly rather than to produce an approximate copy quietly - see
13
- // `unsupported` below, which is checked by the caller and is not advisory.
14
- //
15
- // What is deliberately not copied: data. None of it, ever - that is the whole
16
- // promise. Also not copied: triggers and functions.
17
- //
18
- // What IS copied, each because leaving it out changed an answer:
19
- //
20
- // unique indexes - they decide whether the same thing can exist twice. An
21
- // app that enforces uniqueness with CREATE UNIQUE INDEX rather than a
22
- // UNIQUE constraint would arrive at the copy with none of it, and be
23
- // reported as broken for having got it right.
24
- // views - a view runs with its creator's rights unless it says otherwise,
25
- // so one over a protected table hands out every row in it. Skipping them
26
- // meant never looking at that door.
27
- // enums and domains - the app's own types. Borrowed from the original,
28
- // they made the copy depend on it; and a view that mentions an enum took
29
- // the scan down outright.
30
-
31
- /* --------------------------------------------------------------------------
32
- Reading.
33
- -------------------------------------------------------------------------- */
34
-
35
- /** Every table in a schema, with whether row level security is switched on. */
36
- async function readTables(client, schema) {
37
- const { rows } = await client.query(
38
- `SELECT c.relname AS name,
39
- c.relrowsecurity AS rls_enabled,
40
- c.relforcerowsecurity AS rls_forced
41
- FROM pg_class c
42
- JOIN pg_namespace n ON n.oid = c.relnamespace
43
- WHERE n.nspname = $1 AND c.relkind = 'r'
44
- ORDER BY c.relname`,
45
- [schema],
46
- );
47
- return rows;
48
- }
49
-
50
- /**
51
- * The columns of one table, as Postgres itself would write them.
52
- *
53
- * format_type is used rather than information_schema because it gives the type
54
- * back exactly - numeric(10,2) stays numeric(10,2) - and a column that comes
55
- * back as the wrong width can change what a policy comparison does.
56
- */
57
- async function readColumns(client, schema, table) {
58
- const { rows } = await client.query(
59
- `SELECT a.attname AS name,
60
- format_type(a.atttypid, a.atttypmod) AS type,
61
- a.attnotnull AS not_null,
62
- pg_get_expr(d.adbin, d.adrelid) AS default_expr,
63
- -- 's' for a stored generated column. Its value is computed from the
64
- -- others, so it can neither be given a DEFAULT nor be inserted
65
- -- into; treating it as an ordinary column crashed the copy outright.
66
- NULLIF(a.attgenerated, '') AS generated,
67
- a.attidentity <> '' AS identity,
68
- -- What can legally go in here, where the type itself says so. An
69
- -- enum column takes one of a fixed list and nothing else, and a
70
- -- generated test string is not on that list.
71
- -- Cast to text[] on purpose. array_agg over enumlabel produces
72
- -- name[], which node-postgres has no parser for, so it arrives as
73
- -- the raw string "{new,paid,shipped}" - and the first "label" is
74
- -- then the character "{". Exactly what pg_policies.roles did.
75
- (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
76
- FROM pg_enum e WHERE e.enumtypid = t.oid) AS enum_labels,
77
- -- A domain is a type with a rule attached. The rule cannot be
78
- -- guessed at, but the type underneath it can be used.
79
- CASE WHEN t.typtype = 'd' THEN format_type(t.typbasetype, a.atttypmod) END AS base_type,
80
- t.typcategory = 'A' AS is_array
81
- FROM pg_attribute a
82
- JOIN pg_type t ON t.oid = a.atttypid
83
- LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
84
- WHERE a.attrelid = format('%I.%I', $1::text, $2::text)::regclass
85
- AND a.attnum > 0
86
- AND NOT a.attisdropped
87
- ORDER BY a.attnum`,
88
- [schema, table],
89
- );
90
- return rows;
91
- }
92
-
93
- /** Primary keys, uniques and checks, in Postgres's own words. */
94
- async function readConstraints(client, schema, table) {
95
- const { rows } = await client.query(
96
- `SELECT con.conname AS name,
97
- pg_get_constraintdef(con.oid) AS definition,
98
- con.contype AS kind
99
- FROM pg_constraint con
100
- WHERE con.conrelid = format('%I.%I', $1::text, $2::text)::regclass
101
- ORDER BY con.conname`,
102
- [schema, table],
103
- );
104
- return rows;
105
- }
106
-
107
- /**
108
- * Who was granted what on the sequences a table depends on.
109
- *
110
- * Without these the copy is not the app, in the one way that matters most.
111
- * Supabase grants anon USAGE on every sequence in public, so a stranger can
112
- * insert into a table whose key is a serial. The copy replayed the table
113
- * grants and not the sequence ones, so every insert the tampering attack
114
- * tried came back 'permission denied for sequence' - which reads as the
115
- * attack being beaten. "A stranger can add rows to your table" was never
116
- * reported on any table with a serial key, which is most tables, and it was
117
- * silent: it looked exactly like an app that had got it right.
118
- *
119
- * Only sequences a column owns. Those are the ones the copy has - measured,
120
- * not assumed: serial, bigserial and both kinds of identity all come out
121
- * with the same name in the copy. A sequence standing on its own does not,
122
- * and nothing inserts into one, so a grant on it changes no verdict.
123
- */
124
- async function readSequenceGrants(client, schema) {
125
- const { rows } = await client.query(
126
- `SELECT DISTINCT s.relname AS sequence_name,
127
- CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee,
128
- a.privilege_type
129
- FROM pg_class s
130
- JOIN pg_namespace n ON n.oid = s.relnamespace
131
- JOIN pg_depend d ON d.objid = s.oid
132
- AND d.classid = 'pg_class'::regclass
133
- AND d.deptype IN ('a', 'i')
134
- CROSS JOIN LATERAL aclexplode(s.relacl) a
135
- WHERE n.nspname = $1 AND s.relkind = 'S'
136
- AND (CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END)
137
- IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
138
- ORDER BY 1, 2, 3`,
139
- [schema],
140
- );
141
- return rows;
142
- }
143
-
144
- /**
145
- * Unique indexes, which are the other half of "can this happen twice".
146
- *
147
- * Read separately from constraints because `CREATE UNIQUE INDEX` and
148
- * `UNIQUE (...)` are different objects in Postgres and real apps use both. An
149
- * app whose uniqueness lives in an index would arrive at the copy with none of
150
- * it, and the Collision attack would then report every such app as broken - a
151
- * false alarm on exactly the apps that got it right.
152
- *
153
- * Primary keys and indexes backing a constraint are left out: those come along
154
- * with the constraint itself, and creating them again is an error.
155
- */
156
- async function readIndexes(client, schema) {
157
- const { rows } = await client.query(
158
- `SELECT c.relname AS name,
159
- t.relname AS table_name,
160
- pg_get_indexdef(i.indexrelid) AS definition
161
- FROM pg_index i
162
- JOIN pg_class c ON c.oid = i.indexrelid
163
- JOIN pg_class t ON t.oid = i.indrelid
164
- JOIN pg_namespace n ON n.oid = c.relnamespace
165
- WHERE n.nspname = $1
166
- AND i.indisunique
167
- AND NOT i.indisprimary
168
- AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.indexrelid)
169
- ORDER BY t.relname, c.relname`,
170
- [schema],
171
- );
172
- return rows;
173
- }
174
-
175
- /**
176
- * The policies. These are the thing under test, so they are copied verbatim.
177
- *
178
- * pg_policies hands back `qual` and `with_check` already rendered as SQL, which
179
- * is what makes an exact copy possible at all.
180
- */
181
- async function readPolicies(client, schema) {
182
- const { rows } = await client.query(
183
- `SELECT tablename AS table_name,
184
- policyname AS name,
185
- permissive,
186
- -- Cast on purpose. pg_policies.roles is name[], which
187
- -- node-postgres has no parser for, so it arrives as the raw
188
- -- string "{anon,authenticated}" - and code that treats it as a
189
- -- list gets the characters of that string instead of the roles.
190
- -- roleList() was written to undo that; casting here means there is
191
- -- nothing to undo. The third time this exact shape has cost a bug,
192
- -- and the one the two engines disagreed about on their first run.
193
- roles::text[] AS roles,
194
- cmd,
195
- qual,
196
- with_check
197
- FROM pg_policies
198
- WHERE schemaname = $1
199
- ORDER BY tablename, policyname`,
200
- [schema],
201
- );
202
- return rows;
203
- }
204
-
205
- /**
206
- * Who was granted what. A policy is irrelevant if the grant is not there.
207
- *
208
- * Restricted to ordinary tables on purpose. role_table_grants also lists views
209
- * and materialised views, and replaying a grant on a view the copy does not
210
- * contain fails outright - which took down the scan of any app with a view in
211
- * it, and almost every app has one.
212
- */
213
- async function readGrants(client, schema) {
214
- const { rows } = await client.query(
215
- `SELECT g.table_name, g.grantee, g.privilege_type
216
- FROM information_schema.role_table_grants g
217
- JOIN pg_class c ON c.relname = g.table_name
218
- JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
219
- WHERE g.table_schema = $1
220
- AND c.relkind = 'r'
221
- AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
222
- ORDER BY g.table_name, g.grantee, g.privilege_type`,
223
- [schema],
224
- );
225
- return rows;
226
- }
227
-
228
- /**
229
- * Views, which are a door of their own.
230
- *
231
- * A view runs with its creator's rights unless it says otherwise, so a view
232
- * over a table that row level security protects hands out every row in that
233
- * table to anyone allowed to read the view. The policy is intact, the
234
- * dashboard is green, and the data is gone. `security_invoker` is what turns
235
- * that off, and it lives in reloptions, so it is copied with the view.
236
- */
237
- async function readViews(client, schema) {
238
- const { rows } = await client.query(
239
- `SELECT c.relname AS name,
240
- pg_get_viewdef(c.oid, true) AS definition,
241
- c.relkind = 'm' AS materialised,
242
- array_to_string(c.reloptions, ', ') AS options
243
- FROM pg_class c
244
- JOIN pg_namespace n ON n.oid = c.relnamespace
245
- WHERE n.nspname = $1 AND c.relkind IN ('v', 'm')
246
- ORDER BY c.relname`,
247
- [schema],
248
- );
249
- return rows;
250
- }
251
-
252
- /** Views, with the columns they expose, so a finding can name what leaked. */
253
- async function readViewsWithColumns(client, schema) {
254
- const views = await readViews(client, schema);
255
- for (const view of views) {
256
- view.columns = await readColumns(client, schema, view.name);
257
- }
258
- return views;
259
- }
260
-
261
- /** Who was granted what on a view. Separate, because views are created later. */
262
- async function readViewGrants(client, schema) {
263
- const { rows } = await client.query(
264
- `SELECT g.table_name, g.grantee, g.privilege_type
265
- FROM information_schema.role_table_grants g
266
- JOIN pg_class c ON c.relname = g.table_name
267
- JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
268
- WHERE g.table_schema = $1
269
- AND c.relkind IN ('v', 'm')
270
- AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
271
- ORDER BY g.table_name, g.grantee, g.privilege_type`,
272
- [schema],
273
- );
274
- return rows;
275
- }
276
-
277
- /**
278
- * The types the app made for itself: enums, and domains.
279
- *
280
- * Copied, so that the copy stands on its own. It used to borrow them from the
281
- * schema it was copied from, which worked right up until a view mentioned one:
282
- * pg_get_viewdef writes a literal as 'paid'::app.order_status, the rewrite
283
- * turned app into the copy, and the copy had no such type. Any app with a view
284
- * over an enum column - which is a great many of them - crashed the scan
285
- * outright, with a stack trace where the report should have been.
286
- *
287
- * Borrowing was quietly wrong anyway. A copy that reaches back into the
288
- * original for anything is a copy that can change under the attack.
289
- */
290
- async function readTypes(client, schema) {
291
- const { rows } = await client.query(
292
- `SELECT t.typname AS name,
293
- t.typtype AS kind,
294
- CASE WHEN t.typtype = 'e' THEN (
295
- SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
296
- FROM pg_enum e WHERE e.enumtypid = t.oid
297
- ) END AS labels,
298
- CASE WHEN t.typtype = 'd'
299
- THEN format_type(t.typbasetype, t.typtypmod) END AS base_type,
300
- CASE WHEN t.typtype = 'd' THEN (
301
- SELECT array_agg(pg_get_constraintdef(c.oid) ORDER BY c.conname)
302
- FROM pg_constraint c WHERE c.contypid = t.oid
303
- ) END AS constraints,
304
- t.typnotnull AS not_null,
305
- CASE WHEN t.typtype = 'd' THEN t.typdefault END AS default_value
306
- FROM pg_type t
307
- JOIN pg_namespace n ON n.oid = t.typnamespace
308
- WHERE n.nspname = $1
309
- AND t.typtype IN ('e', 'd')
310
- -- Enums first. A domain can be built on one, and a domain created
311
- -- before the enum it stands on is a type that does not exist yet.
312
- ORDER BY CASE WHEN t.typtype = 'e' THEN 0 ELSE 1 END, t.typname`,
313
- [schema],
314
- );
315
- return rows;
316
- }
317
-
318
- /** What a foreign key points at, pulled out of Postgres's own wording. */
319
- function referenceIn(definition) {
320
- const match = /FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+([^\s(]+)\s*\(([^)]+)\)/i.exec(String(definition));
321
- if (!match) return null;
322
- const strip = (text) => text.trim().replace(/^"(.*)"$/, '$1');
323
- const target = match[2].trim().split(/\.(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(strip);
324
- return {
325
- schema: target.length > 1 ? target[0] : null,
326
- table: target[target.length - 1],
327
- columns: match[3].split(',').map(strip),
328
- };
329
- }
330
-
331
- /**
332
- * The name a stand-in for an outside table is given inside the copy.
333
- *
334
- * `taken` is every name the customer already uses, and the stand-in keeps
335
- * moving until it clashes with none of them. Identifying our own tables by
336
- * their prefix alone meant a customer table that happened to start with the
337
- * same letters was quietly dropped from every attack - a real hole reported as
338
- * nothing at all, which is the failure this whole program exists to avoid.
339
- */
340
- function stubNameFor(refSchema, refTable, taken) {
341
- const used = new Set(taken || []);
342
- const base = 'kn_ext__' + refSchema + '__' + refTable;
343
- let name = base;
344
- let nth = 2;
345
- while (used.has(name)) {
346
- name = base + '__' + nth;
347
- nth += 1;
348
- }
349
- return name;
350
- }
351
-
352
- /**
353
- * Tables outside this schema that its foreign keys point at.
354
- *
355
- * Nearly every Supabase app has `references auth.users(id)`, and the copy
356
- * cannot carry that as written: pointed at the real auth.users, every seeded
357
- * row becomes a write into the customer's own authentication table. So the
358
- * shape of the outside table is read - columns and types, never rows - and a
359
- * stand-in is built inside the copy instead.
360
- *
361
- * A target whose shape cannot be read at all is returned with no columns, and
362
- * the caller treats that as unsupported rather than guessing at it.
363
- */
364
- async function readExternalTargets(client, schema, tables) {
365
- const wanted = new Map();
366
- for (const table of tables) {
367
- for (const constraint of table.constraints || []) {
368
- if (constraint.kind !== 'f') continue;
369
- const points = referenceIn(constraint.definition);
370
- if (!points || !points.schema || points.schema === schema) continue;
371
- const key = points.schema + '.' + points.table;
372
- if (!wanted.has(key)) {
373
- wanted.set(key, { schema: points.schema, table: points.table, columns: new Set() });
374
- }
375
- points.columns.forEach((column) => wanted.get(key).columns.add(column));
376
- }
377
- }
378
-
379
- const targets = [];
380
- for (const entry of wanted.values()) {
381
- let columns = [];
382
- try {
383
- const { rows } = await client.query(
384
- `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type
385
- FROM pg_attribute a
386
- WHERE a.attrelid = format('%I.%I', $1::text, $2::text)::regclass
387
- AND a.attname = ANY($3) AND a.attnum > 0 AND NOT a.attisdropped
388
- ORDER BY a.attnum`,
389
- [entry.schema, entry.table, Array.from(entry.columns)],
390
- );
391
- columns = rows;
392
- } catch (err) {
393
- columns = [];
394
- }
395
- targets.push({
396
- schema: entry.schema,
397
- table: entry.table,
398
- columns: columns,
399
- stub: stubNameFor(entry.schema, entry.table, tables.map((t) => t.name)),
400
- wanted: Array.from(entry.columns),
401
- });
402
- }
403
- return targets;
404
- }
405
-
406
- /**
407
- * Everything needed to rebuild a schema, and everything that could not be.
408
- *
409
- * `unsupported` is the important half. A caller that ignores it is attacking a
410
- * database that is not the customer's.
411
- */
412
- async function readSchema(client, schema) {
413
- const tables = await readTables(client, schema);
414
- const unsupported = [];
415
- const built = [];
416
-
417
- for (const table of tables) {
418
- const columns = await readColumns(client, schema, table.name);
419
- const constraints = await readConstraints(client, schema, table.name);
420
-
421
- built.push({
422
- name: table.name,
423
- rlsEnabled: table.rls_enabled,
424
- rlsForced: table.rls_forced,
425
- columns: columns,
426
- constraints: constraints,
427
- });
428
- }
429
-
430
- // A foreign key pointing outside this schema gets a stand-in inside the
431
- // copy. Quietly dropping it instead would change what seeding is allowed to
432
- // insert, and pointing it at the real table would make every seeded row a
433
- // write into the customer's own data.
434
- const external = await readExternalTargets(client, schema, built);
435
- for (const target of external) {
436
- if (target.columns.length !== target.wanted.length) {
437
- unsupported.push(
438
- 'a foreign key points at ' + target.schema + '.' + target.table +
439
- ', and I could not read its shape to stand in for it',
440
- );
441
- }
442
- }
443
-
444
- return {
445
- schema: schema,
446
- tables: built,
447
- policies: await readPolicies(client, schema),
448
- grants: await readGrants(client, schema),
449
- sequenceGrants: await readSequenceGrants(client, schema),
450
- types: await readTypes(client, schema),
451
- indexes: await readIndexes(client, schema),
452
- views: await readViewsWithColumns(client, schema),
453
- viewGrants: await readViewGrants(client, schema),
454
- external: external,
455
- unsupported: unsupported,
456
- };
457
- }
458
-
459
- /* --------------------------------------------------------------------------
460
- Rebuilding.
461
- -------------------------------------------------------------------------- */
462
-
463
- function quote(name) {
464
- return '"' + String(name).split('"').join('""') + '"';
465
- }
466
-
467
- /**
468
- * The roles a policy applies to, as a list.
469
- *
470
- * pg_policies hands this back as the raw Postgres array literal - the string
471
- * "{anon,authenticated}", not an array - because node-postgres has no parser
472
- * registered for name[]. Measured, not assumed. Treating it as an array gives
473
- * either a crash or, worse, a policy created for the wrong roles.
474
- */
475
- function roleList(roles) {
476
- if (Array.isArray(roles)) return roles;
477
- const raw = String(roles === null || roles === undefined ? '' : roles).trim();
478
- if (!raw || raw === '{}') return [];
479
- const inner = raw.startsWith('{') && raw.endsWith('}') ? raw.slice(1, -1) : raw;
480
- return inner
481
- .split(',')
482
- .map((part) => part.trim().replace(/^"(.*)"$/, '$1'))
483
- .filter(Boolean);
484
- }
485
-
486
- /**
487
- * Points anything schema-qualified at the copy instead of the original.
488
- *
489
- * Both spellings, and that is the whole point of this existing. Postgres
490
- * writes a name unquoted when it does not need quoting, so a foreign key came
491
- * back as "REFERENCES app.profiles(id)" while only the quoted form was being
492
- * rewritten - and the copy was created holding a live reference into the
493
- * customer's real schema. Every insert into the copy would then have been
494
- * checked against the customer's real table, which is the one thing this is
495
- * built never to touch.
496
- */
497
- function rewriteSchemaRefs(expr, fromSchema, toSchema) {
498
- if (!expr) return null;
499
- return String(expr).split(quote(fromSchema) + '.').join(quote(toSchema) + '.').split(fromSchema + '.').join(toSchema + '.');
500
- }
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
-
553
- /**
554
- * Points a foreign key at the stand-in instead of at the real outside table.
555
- *
556
- * Both spellings again, for the same reason the schema rewrite handles both:
557
- * Postgres writes a name unquoted whenever it does not have to quote it, and
558
- * missing one spelling leaves the copy holding a live reference into the
559
- * customer's database.
560
- */
561
- function rewriteExternalRefs(expr, external, toSchema) {
562
- let text = String(expr);
563
- for (const target of external || []) {
564
- const stub = quote(toSchema) + '.' + quote(target.stub);
565
- text = text
566
- .split(quote(target.schema) + '.' + quote(target.table))
567
- .join(stub)
568
- .split(target.schema + '.' + target.table)
569
- .join(stub);
570
- }
571
- return text;
572
- }
573
-
574
- /** Two people who do not exist, used wherever a stand-in row is needed. */
575
- const IDENTITIES = ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'];
576
-
577
- /** Something of the right type to put in a stand-in row. */
578
- function stubValue(type, nth) {
579
- const kind = String(type).toLowerCase();
580
- if (kind === 'uuid') return "'" + IDENTITIES[nth] + "'";
581
- if (/^(integer|bigint|smallint|numeric|decimal|real|double)/.test(kind)) return String(nth + 1);
582
- if (/^bool/.test(kind)) return nth === 0 ? 'true' : 'false';
583
- if (/^(timestamp|date)/.test(kind)) return 'now()';
584
- return "'kryptheon-" + (nth + 1) + "'";
585
- }
586
-
587
- /** Which of these roles this database actually has. */
588
- async function existingRoles(client, wanted) {
589
- const { rows } = await client.query('SELECT rolname FROM pg_roles WHERE rolname = ANY($1)', [wanted]);
590
- return rows.map((row) => row.rolname);
591
- }
592
-
593
- /**
594
- * Nothing may be written outside the copy. Ever.
595
- *
596
- * The product is sold on one sentence - we never touch your live app - and
597
- * this is the line that keeps it true. It is a structural guard rather than a
598
- * careful habit, because the two statements that broke the promise were
599
- * written carefully and sat there for weeks: every check built its own
600
- * auth.uid() first, so replacing the customer's looked exactly like doing
601
- * nothing.
602
- *
603
- * Every statement has to name the copy schema, quoted or not. A statement that
604
- * does not is not run, and the scan stops rather than guessing.
605
- */
606
- function mustStayInside(statements, target) {
607
- const bare = new RegExp('(^|[^A-Za-z0-9_"])' + target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.');
608
- for (const statement of statements) {
609
- if (statement.includes(quote(target)) || bare.test(statement)) continue;
610
- throw new Error(
611
- 'refusing to run a statement that does not stay inside the copy ' + target + ': ' + statement,
612
- );
613
- }
614
- return statements;
615
- }
616
-
617
- /**
618
- * Builds the schema again, in a database we own.
619
- *
620
- * Order matters: tables, then constraints, then grants, then policies. A policy
621
- * cannot be created before the table it guards, and a grant given after a
622
- * policy would widen access the original did not have.
623
- */
624
- async function writeSchema(client, plan, target) {
625
- const statements = ['CREATE SCHEMA ' + quote(target)];
626
-
627
- // The app's own types first: a column, a check, a view or a cast can all
628
- // name one, and nothing that mentions a type can be created before it.
629
- for (const made of plan.types || []) {
630
- if (made.kind === 'e') {
631
- statements.push(
632
- 'CREATE TYPE ' + quote(target) + '.' + quote(made.name) + ' AS ENUM (' +
633
- (made.labels || []).map((label) => "'" + String(label).split("'").join("''") + "'").join(', ') + ')',
634
- );
635
- } else {
636
- const here = (text) => rewriteSchemaRefs(text, plan.schema, target);
637
- // A domain can stand on another of the app's own types, and its rule can
638
- // name one too, so both go through the same rewrite a column does.
639
- const parts = ['CREATE DOMAIN ' + quote(target) + '.' + quote(made.name) + ' AS ' + here(made.base_type)];
640
- if (made.default_value) parts.push('DEFAULT ' + here(made.default_value));
641
- if (made.not_null) parts.push('NOT NULL');
642
- for (const rule of made.constraints || []) parts.push(here(rule));
643
- statements.push(parts.join(' '));
644
- }
645
- }
646
-
647
- for (const table of plan.tables) {
648
- const columns = table.columns.map((column) => {
649
- // The type is rewritten like everything else now that the copy has its
650
- // own. Left alone, the copy leaned on the original for them.
651
- const parts = [quote(column.name), rewriteSchemaRefs(column.type, plan.schema, target)];
652
- const fallback = rewriteSchemaRefs(column.default_expr, plan.schema, target);
653
- if (column.generated) {
654
- // A stored generated column carries its expression in default_expr, and
655
- // replaying that as a DEFAULT is a syntax error - "cannot use column
656
- // reference in DEFAULT expression" - which took the whole scan down.
657
- parts.push('GENERATED ALWAYS AS (' + fallback + ') STORED');
658
- } else if (column.identity) {
659
- parts.push('GENERATED ALWAYS AS IDENTITY');
660
- } else if (fallback) {
661
- parts.push('DEFAULT ' + fallback);
662
- }
663
- if (column.not_null && !column.generated && !column.identity) parts.push('NOT NULL');
664
- return parts.join(' ');
665
- });
666
-
667
- // Sequences first, or a serial column's default has nothing to point at.
668
- for (const column of table.columns) {
669
- const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
670
- if (!match) continue;
671
- const bare = match[1].split('.').pop().split('"').join('');
672
- statements.push('CREATE SEQUENCE IF NOT EXISTS ' + quote(target) + '.' + quote(bare));
673
- }
674
-
675
- statements.push(
676
- 'CREATE TABLE ' + quote(target) + '.' + quote(table.name) + ' (' + columns.join(', ') + ')',
677
- );
678
-
679
- // And the sequence belongs to its column, the way serial makes it.
680
- //
681
- // Not tidiness. A sequence a column owns is linked to it in pg_depend,
682
- // and that link is how the grants on it are found again - so a copy
683
- // whose sequences stand loose reads back as having no sequence grants
684
- // at all, however many were replayed onto it. It also means the
685
- // sequence goes when the copy's table goes, which is what the original
686
- // does.
687
- for (const column of table.columns) {
688
- const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
689
- if (!match) continue;
690
- const bare = match[1].split('.').pop().split('"').join('');
691
- statements.push(
692
- 'ALTER SEQUENCE ' + quote(target) + '.' + quote(bare) + ' OWNED BY ' +
693
- quote(target) + '.' + quote(table.name) + '.' + quote(column.name),
694
- );
695
- }
696
- }
697
-
698
- // Stand-ins for the tables outside this schema that its foreign keys point
699
- // at, with two rows already in them so seeding has something to reference.
700
- // Built before the constraints, because a foreign key cannot be added to a
701
- // table that is not there yet.
702
- for (const outside of plan.external || []) {
703
- const stub = quote(target) + '.' + quote(outside.stub);
704
- const columns = outside.columns.map((column) => quote(column.name) + ' ' + column.type + ' NOT NULL');
705
- const keyed = outside.columns.map((column) => quote(column.name)).join(', ');
706
- statements.push('CREATE TABLE ' + stub + ' (' + columns.join(', ') + ', PRIMARY KEY (' + keyed + '))');
707
- for (let nth = 0; nth < IDENTITIES.length; nth++) {
708
- const values = outside.columns.map((column) => stubValue(column.type, nth));
709
- statements.push('INSERT INTO ' + stub + ' VALUES (' + values.join(', ') + ')');
710
- }
711
- }
712
-
713
- // Keys and uniques across every table first, then the foreign keys.
714
- // Constraints used to be added table by table, so a foreign key on `orders`
715
- // was created before the primary key on `profiles` existed and Postgres
716
- // refused it: "no unique constraint matching given keys". A foreign key can
717
- // only be added once the thing it points at is already unique.
718
- for (const wantForeign of [false, true]) {
719
- for (const table of plan.tables) {
720
- for (const constraint of table.constraints) {
721
- const isForeign = constraint.kind === 'f';
722
- if (isForeign !== wantForeign) continue;
723
- const definition = qualifyOwnRefs(
724
- rewriteExternalRefs(
725
- rewriteSchemaRefs(constraint.definition, plan.schema, target),
726
- plan.external,
727
- target,
728
- ),
729
- plan,
730
- target,
731
- );
732
- statements.push(
733
- 'ALTER TABLE ' + quote(target) + '.' + quote(table.name) +
734
- ' ADD CONSTRAINT ' + quote(constraint.name) + ' ' + definition,
735
- );
736
- }
737
- }
738
- }
739
-
740
- // Unique indexes, once every table exists. The schema name inside the
741
- // definition is rewritten for the same reason a foreign key's was: Postgres
742
- // writes it unquoted, and every single index definition carries it. Replayed
743
- // as-is, the copy would build its indexes on the customer's real tables.
744
- for (const index of plan.indexes || []) {
745
- statements.push(rewriteSchemaRefs(index.definition, plan.schema, target));
746
- }
747
-
748
- // Views last, because they read from the tables above. Copied rather than
749
- // skipped because a view is a way into a table: it runs with its creator
750
- // rights unless it says security_invoker, so a view over a protected table
751
- // hands out every row in it. Leaving views out meant never looking at that
752
- // door at all.
753
- for (const view of plan.views || []) {
754
- const body = rewriteExternalRefs(rewriteSchemaRefs(view.definition, plan.schema, target), plan.external, target);
755
- const options = view.options ? ' WITH (' + view.options + ')' : '';
756
- statements.push(
757
- 'CREATE ' + (view.materialised ? 'MATERIALIZED VIEW ' : 'VIEW ') +
758
- quote(target) + '.' + quote(view.name) + options + ' AS ' + body,
759
- );
760
- }
761
-
762
- for (const grant of plan.viewGrants || []) {
763
- const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
764
- statements.push(
765
- 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
766
- );
767
- }
768
-
769
- // The copy needs the roles PostgREST switches into to be able to reach it.
770
- // Granted on the copy's own schema and nowhere else.
771
- //
772
- // What used to be here, and must never come back: CREATE SCHEMA auth,
773
- // CREATE OR REPLACE FUNCTION auth.uid(), and GRANT USAGE ON SCHEMA auth.
774
- // Those ran against the customer's live database. The first overwrote their
775
- // own authentication function; the third opened a schema they may have
776
- // deliberately closed. The policies reference auth.uid() and that is fine -
777
- // calling their function is a read, and a read is all we are ever allowed.
778
- // If it is missing, their app does not work either, and the copy failing to
779
- // build says so honestly instead of papering over it.
780
- for (const role of await existingRoles(client, ['anon', 'authenticated'])) {
781
- statements.push('GRANT USAGE ON SCHEMA ' + quote(target) + ' TO ' + quote(role));
782
- }
783
-
784
- for (const grant of plan.grants) {
785
- const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
786
- statements.push(
787
- 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
788
- );
789
- }
790
-
791
- // The sequences, on the same terms as the tables. A table grant without
792
- // the sequence grant that goes with it is a copy nobody can insert into.
793
- for (const grant of plan.sequenceGrants || []) {
794
- const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
795
- statements.push(
796
- 'GRANT ' + grant.privilege_type + ' ON SEQUENCE ' + quote(target) + '.' +
797
- quote(grant.sequence_name) + ' TO ' + who,
798
- );
799
- }
800
-
801
- for (const table of plan.tables) {
802
- if (table.rlsEnabled) {
803
- statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' ENABLE ROW LEVEL SECURITY');
804
- }
805
- if (table.rlsForced) {
806
- statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' FORCE ROW LEVEL SECURITY');
807
- }
808
- }
809
-
810
- for (const policy of plan.policies) {
811
- const roles = roleList(policy.roles).join(', ') || 'PUBLIC';
812
- const parts = [
813
- 'CREATE POLICY ' + quote(policy.name),
814
- 'ON ' + quote(target) + '.' + quote(policy.table_name),
815
- 'AS ' + (policy.permissive === 'PERMISSIVE' ? 'PERMISSIVE' : 'RESTRICTIVE'),
816
- 'FOR ' + policy.cmd,
817
- 'TO ' + roles,
818
- ];
819
- if (policy.qual) parts.push('USING (' + policy.qual + ')');
820
- if (policy.with_check) parts.push('WITH CHECK (' + policy.with_check + ')');
821
- statements.push(parts.join(' '));
822
- }
823
-
824
- mustStayInside(statements, target);
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(() => {});
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
-
908
- return statements;
909
- }
910
-
911
- /* --------------------------------------------------------------------------
912
- Checking the copy is the original.
913
- -------------------------------------------------------------------------- */
914
-
915
- /** A piece of SQL with its own schema name taken off, quoted or not. */
916
- function withoutSchema(text, plan) {
917
- return String(text == null ? '' : text)
918
- .split(quote(plan.schema) + '.').join('')
919
- .split(plan.schema + '.').join('');
920
- }
921
-
922
- /**
923
- * Where a copy differs from what it was copied from.
924
- *
925
- * Anything here means the verdicts that follow are about the wrong database,
926
- * so this returning empty is a precondition for attacking, not a nicety.
927
- */
928
- function diffSchemas(source, copy) {
929
- const differences = [];
930
-
931
- // The stand-ins exist only in the copy, by design. Comparing them against
932
- // an original that never had them would report every Supabase app as a
933
- // copy that came out wrong.
934
- const standIns = new Set((source.external || []).map((e) => e.stub));
935
- const mine = (list) => list.filter((t) => !standIns.has(t.name));
936
- const named = (list) => mine(list).map((t) => t.name).sort().join(', ');
937
- if (named(source.tables) !== named(copy.tables)) {
938
- differences.push('tables differ: ' + named(source.tables) + ' vs ' + named(copy.tables));
939
- }
940
-
941
- for (const table of source.tables) {
942
- const mirror = copy.tables.find((t) => t.name === table.name);
943
- if (!mirror) continue;
944
-
945
- if (table.rlsEnabled !== mirror.rlsEnabled) {
946
- differences.push(
947
- table.name + ': row level security is ' + (table.rlsEnabled ? 'on' : 'off') +
948
- ' but the copy has it ' + (mirror.rlsEnabled ? 'on' : 'off'),
949
- );
950
- }
951
- if (table.rlsForced !== mirror.rlsForced) {
952
- differences.push(table.name + ': forced row level security does not match');
953
- }
954
-
955
- // The schema name comes off the type first. A column of the app's own enum
956
- // reads as app.order_status in the original and as <copy>.order_status in
957
- // the copy - the same type, built twice, and calling that a difference
958
- // would stop every scan of an app that has one.
959
- const shape = (plan, t) =>
960
- t.columns.map((c) => c.name + ' ' + withoutSchema(c.type, plan) + (c.not_null ? ' NOT NULL' : '')).join(' | ');
961
- if (shape(source, table) !== shape(copy, mirror)) {
962
- differences.push(table.name + ' columns differ:\n ' + shape(source, table) + '\n ' + shape(copy, mirror));
963
- }
964
- }
965
-
966
- // The app's own types. An enum that arrived with a label missing narrows
967
- // what the attacks are able to insert, and nothing else here would notice.
968
- const typeText = (plan) =>
969
- (plan.types || [])
970
- .map((made) =>
971
- made.name + ' ' + made.kind + ' ' + (made.labels || []).join(',') + ' ' +
972
- withoutSchema(made.base_type, plan) + ' ' +
973
- (made.constraints || []).map((rule) => withoutSchema(rule, plan)).join(' '))
974
- .sort();
975
- const sourceTypes = typeText(source);
976
- const copyTypes = typeText(copy);
977
- for (const made of sourceTypes) {
978
- if (!copyTypes.includes(made)) differences.push('a type did not come across whole: ' + made);
979
- }
980
-
981
- // The sequence grants, for the same reason they are copied at all: without
982
- // them nothing can insert, and every write attack reads as the app
983
- // defending itself.
984
- const sequenceText = (plan) =>
985
- (plan.sequenceGrants || [])
986
- .map((g) => g.sequence_name + ' ' + g.grantee + ' ' + g.privilege_type)
987
- .sort();
988
- const sourceSequences = sequenceText(source);
989
- const copySequences = sequenceText(copy);
990
- for (const one of sourceSequences) {
991
- if (!copySequences.includes(one)) {
992
- differences.push('a sequence grant did not come across: ' + one);
993
- }
994
- }
995
-
996
- // Uniqueness decides whether the Collision attack has anything to report, so
997
- // a unique index that failed to come across has to be caught here rather
998
- // than turn into a confident finding about a table that was actually fine.
999
- // The schema name is stripped before comparing, since it differs by design.
1000
- const indexText = (plan) =>
1001
- (plan.indexes || [])
1002
- .map((index) => withoutSchema(index.definition, plan))
1003
- .sort();
1004
- const sourceIndexes = indexText(source);
1005
- const copyIndexes = indexText(copy);
1006
- if (sourceIndexes.length !== copyIndexes.length) {
1007
- differences.push(
1008
- 'the copy has ' + copyIndexes.length + ' unique indexes, the original has ' + sourceIndexes.length,
1009
- );
1010
- }
1011
- for (let i = 0; i < Math.max(sourceIndexes.length, copyIndexes.length); i++) {
1012
- if (sourceIndexes[i] !== copyIndexes[i]) {
1013
- differences.push('a unique index came across changed:\n ' + sourceIndexes[i] + '\n ' + copyIndexes[i]);
1014
- }
1015
- }
1016
-
1017
- // The policies matter most, so they are compared word for word.
1018
- const asText = (list, schema) =>
1019
- list
1020
- .map((p) =>
1021
- [p.table_name, p.name, p.permissive, roleList(p.roles).join('+'), p.cmd, p.qual, p.with_check]
1022
- .map((x) => String(x === null || x === undefined ? '' : x))
1023
- .join(' :: '),
1024
- )
1025
- .sort();
1026
-
1027
- const sourcePolicies = asText(source.policies);
1028
- const copyPolicies = asText(copy.policies);
1029
- if (sourcePolicies.length !== copyPolicies.length) {
1030
- differences.push('the copy has ' + copyPolicies.length + ' policies, the original has ' + sourcePolicies.length);
1031
- }
1032
- for (let i = 0; i < Math.max(sourcePolicies.length, copyPolicies.length); i++) {
1033
- if (sourcePolicies[i] !== copyPolicies[i]) {
1034
- differences.push('a policy came across changed:\n ' + sourcePolicies[i] + '\n ' + copyPolicies[i]);
1035
- }
1036
- }
1037
-
1038
- return differences;
1039
- }
1040
-
1041
- module.exports = {
1042
- readSchema: readSchema,
1043
- writeSchema: writeSchema,
1044
- diffSchemas: diffSchemas,
1045
- readPolicies: readPolicies,
1046
- readTypes: readTypes,
1047
- readSequenceGrants: readSequenceGrants,
1048
- readIndexes: readIndexes,
1049
- readViews: readViews,
1050
- readExternalTargets: readExternalTargets,
1051
- referenceIn: referenceIn,
1052
- rewriteSchemaRefs: rewriteSchemaRefs,
1053
- qualifyOwnRefs: qualifyOwnRefs,
1054
- stubNameFor: stubNameFor,
1055
- IDENTITIES: IDENTITIES,
1056
- quote: quote,
1057
- roleList: roleList,
1058
- };
1
+ // Reading a backend's shape, and rebuilding it somewhere safe.
2
+ //
3
+ // The night shift never touches the live app. It reads the shape - tables,
4
+ // columns, constraints, whether row level security is on, and the policies
5
+ // themselves - rebuilds that shape in a throwaway database, seeds fake people
6
+ // into it, and attacks that.
7
+ //
8
+ // The thing to be careful about is not the copying. It is that a copy which
9
+ // differs from the original, even slightly, produces verdicts about a database
10
+ // nobody is running. A policy that comes across subtly changed is worse than no
11
+ // copy at all: it looks like a real answer. So everything here is built to fail
12
+ // loudly rather than to produce an approximate copy quietly - see
13
+ // `unsupported` below, which is checked by the caller and is not advisory.
14
+ //
15
+ // What is deliberately not copied: data. None of it, ever - that is the whole
16
+ // promise. Also not copied: triggers and functions.
17
+ //
18
+ // What IS copied, each because leaving it out changed an answer:
19
+ //
20
+ // unique indexes - they decide whether the same thing can exist twice. An
21
+ // app that enforces uniqueness with CREATE UNIQUE INDEX rather than a
22
+ // UNIQUE constraint would arrive at the copy with none of it, and be
23
+ // reported as broken for having got it right.
24
+ // views - a view runs with its creator's rights unless it says otherwise,
25
+ // so one over a protected table hands out every row in it. Skipping them
26
+ // meant never looking at that door.
27
+ // enums and domains - the app's own types. Borrowed from the original,
28
+ // they made the copy depend on it; and a view that mentions an enum took
29
+ // the scan down outright.
30
+
31
+ /* --------------------------------------------------------------------------
32
+ Reading.
33
+ -------------------------------------------------------------------------- */
34
+
35
+ /** Every table in a schema, with whether row level security is switched on. */
36
+ async function readTables(client, schema) {
37
+ const { rows } = await client.query(
38
+ `SELECT c.relname AS name,
39
+ c.relrowsecurity AS rls_enabled,
40
+ c.relforcerowsecurity AS rls_forced
41
+ FROM pg_class c
42
+ JOIN pg_namespace n ON n.oid = c.relnamespace
43
+ WHERE n.nspname = $1 AND c.relkind = 'r'
44
+ ORDER BY c.relname`,
45
+ [schema],
46
+ );
47
+ return rows;
48
+ }
49
+
50
+ /**
51
+ * The columns of one table, as Postgres itself would write them.
52
+ *
53
+ * format_type is used rather than information_schema because it gives the type
54
+ * back exactly - numeric(10,2) stays numeric(10,2) - and a column that comes
55
+ * back as the wrong width can change what a policy comparison does.
56
+ */
57
+ async function readColumns(client, schema, table) {
58
+ const { rows } = await client.query(
59
+ `SELECT a.attname AS name,
60
+ format_type(a.atttypid, a.atttypmod) AS type,
61
+ a.attnotnull AS not_null,
62
+ pg_get_expr(d.adbin, d.adrelid) AS default_expr,
63
+ -- 's' for a stored generated column. Its value is computed from the
64
+ -- others, so it can neither be given a DEFAULT nor be inserted
65
+ -- into; treating it as an ordinary column crashed the copy outright.
66
+ NULLIF(a.attgenerated, '') AS generated,
67
+ a.attidentity <> '' AS identity,
68
+ -- What can legally go in here, where the type itself says so. An
69
+ -- enum column takes one of a fixed list and nothing else, and a
70
+ -- generated test string is not on that list.
71
+ -- Cast to text[] on purpose. array_agg over enumlabel produces
72
+ -- name[], which node-postgres has no parser for, so it arrives as
73
+ -- the raw string "{new,paid,shipped}" - and the first "label" is
74
+ -- then the character "{". Exactly what pg_policies.roles did.
75
+ (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
76
+ FROM pg_enum e WHERE e.enumtypid = t.oid) AS enum_labels,
77
+ -- A domain is a type with a rule attached. The rule cannot be
78
+ -- guessed at, but the type underneath it can be used.
79
+ CASE WHEN t.typtype = 'd' THEN format_type(t.typbasetype, a.atttypmod) END AS base_type,
80
+ t.typcategory = 'A' AS is_array
81
+ FROM pg_attribute a
82
+ JOIN pg_type t ON t.oid = a.atttypid
83
+ LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
84
+ WHERE a.attrelid = format('%I.%I', $1::text, $2::text)::regclass
85
+ AND a.attnum > 0
86
+ AND NOT a.attisdropped
87
+ ORDER BY a.attnum`,
88
+ [schema, table],
89
+ );
90
+ return rows;
91
+ }
92
+
93
+ /** Primary keys, uniques and checks, in Postgres's own words. */
94
+ async function readConstraints(client, schema, table) {
95
+ const { rows } = await client.query(
96
+ `SELECT con.conname AS name,
97
+ pg_get_constraintdef(con.oid) AS definition,
98
+ con.contype AS kind
99
+ FROM pg_constraint con
100
+ WHERE con.conrelid = format('%I.%I', $1::text, $2::text)::regclass
101
+ ORDER BY con.conname`,
102
+ [schema, table],
103
+ );
104
+ return rows;
105
+ }
106
+
107
+ /**
108
+ * Who was granted what on the sequences a table depends on.
109
+ *
110
+ * Without these the copy is not the app, in the one way that matters most.
111
+ * Supabase grants anon USAGE on every sequence in public, so a stranger can
112
+ * insert into a table whose key is a serial. The copy replayed the table
113
+ * grants and not the sequence ones, so every insert the tampering attack
114
+ * tried came back 'permission denied for sequence' - which reads as the
115
+ * attack being beaten. "A stranger can add rows to your table" was never
116
+ * reported on any table with a serial key, which is most tables, and it was
117
+ * silent: it looked exactly like an app that had got it right.
118
+ *
119
+ * Only sequences a column owns. Those are the ones the copy has - measured,
120
+ * not assumed: serial, bigserial and both kinds of identity all come out
121
+ * with the same name in the copy. A sequence standing on its own does not,
122
+ * and nothing inserts into one, so a grant on it changes no verdict.
123
+ */
124
+ async function readSequenceGrants(client, schema) {
125
+ const { rows } = await client.query(
126
+ `SELECT DISTINCT s.relname AS sequence_name,
127
+ CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee,
128
+ a.privilege_type
129
+ FROM pg_class s
130
+ JOIN pg_namespace n ON n.oid = s.relnamespace
131
+ JOIN pg_depend d ON d.objid = s.oid
132
+ AND d.classid = 'pg_class'::regclass
133
+ AND d.deptype IN ('a', 'i')
134
+ CROSS JOIN LATERAL aclexplode(s.relacl) a
135
+ WHERE n.nspname = $1 AND s.relkind = 'S'
136
+ AND (CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END)
137
+ IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
138
+ ORDER BY 1, 2, 3`,
139
+ [schema],
140
+ );
141
+ return rows;
142
+ }
143
+
144
+ /**
145
+ * Unique indexes, which are the other half of "can this happen twice".
146
+ *
147
+ * Read separately from constraints because `CREATE UNIQUE INDEX` and
148
+ * `UNIQUE (...)` are different objects in Postgres and real apps use both. An
149
+ * app whose uniqueness lives in an index would arrive at the copy with none of
150
+ * it, and the Collision attack would then report every such app as broken - a
151
+ * false alarm on exactly the apps that got it right.
152
+ *
153
+ * Primary keys and indexes backing a constraint are left out: those come along
154
+ * with the constraint itself, and creating them again is an error.
155
+ */
156
+ async function readIndexes(client, schema) {
157
+ const { rows } = await client.query(
158
+ `SELECT c.relname AS name,
159
+ t.relname AS table_name,
160
+ pg_get_indexdef(i.indexrelid) AS definition
161
+ FROM pg_index i
162
+ JOIN pg_class c ON c.oid = i.indexrelid
163
+ JOIN pg_class t ON t.oid = i.indrelid
164
+ JOIN pg_namespace n ON n.oid = c.relnamespace
165
+ WHERE n.nspname = $1
166
+ AND i.indisunique
167
+ AND NOT i.indisprimary
168
+ AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.indexrelid)
169
+ ORDER BY t.relname, c.relname`,
170
+ [schema],
171
+ );
172
+ return rows;
173
+ }
174
+
175
+ /**
176
+ * The policies. These are the thing under test, so they are copied verbatim.
177
+ *
178
+ * pg_policies hands back `qual` and `with_check` already rendered as SQL, which
179
+ * is what makes an exact copy possible at all.
180
+ */
181
+ async function readPolicies(client, schema) {
182
+ const { rows } = await client.query(
183
+ `SELECT tablename AS table_name,
184
+ policyname AS name,
185
+ permissive,
186
+ -- Cast on purpose. pg_policies.roles is name[], which
187
+ -- node-postgres has no parser for, so it arrives as the raw
188
+ -- string "{anon,authenticated}" - and code that treats it as a
189
+ -- list gets the characters of that string instead of the roles.
190
+ -- roleList() was written to undo that; casting here means there is
191
+ -- nothing to undo. The third time this exact shape has cost a bug,
192
+ -- and the one the two engines disagreed about on their first run.
193
+ roles::text[] AS roles,
194
+ cmd,
195
+ qual,
196
+ with_check
197
+ FROM pg_policies
198
+ WHERE schemaname = $1
199
+ ORDER BY tablename, policyname`,
200
+ [schema],
201
+ );
202
+ return rows;
203
+ }
204
+
205
+ /**
206
+ * Who was granted what. A policy is irrelevant if the grant is not there.
207
+ *
208
+ * Restricted to ordinary tables on purpose. role_table_grants also lists views
209
+ * and materialised views, and replaying a grant on a view the copy does not
210
+ * contain fails outright - which took down the scan of any app with a view in
211
+ * it, and almost every app has one.
212
+ */
213
+ async function readGrants(client, schema) {
214
+ const { rows } = await client.query(
215
+ `SELECT g.table_name, g.grantee, g.privilege_type
216
+ FROM information_schema.role_table_grants g
217
+ JOIN pg_class c ON c.relname = g.table_name
218
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
219
+ WHERE g.table_schema = $1
220
+ AND c.relkind = 'r'
221
+ AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
222
+ ORDER BY g.table_name, g.grantee, g.privilege_type`,
223
+ [schema],
224
+ );
225
+ return rows;
226
+ }
227
+
228
+ /**
229
+ * Views, which are a door of their own.
230
+ *
231
+ * A view runs with its creator's rights unless it says otherwise, so a view
232
+ * over a table that row level security protects hands out every row in that
233
+ * table to anyone allowed to read the view. The policy is intact, the
234
+ * dashboard is green, and the data is gone. `security_invoker` is what turns
235
+ * that off, and it lives in reloptions, so it is copied with the view.
236
+ */
237
+ async function readViews(client, schema) {
238
+ const { rows } = await client.query(
239
+ `SELECT c.relname AS name,
240
+ pg_get_viewdef(c.oid, true) AS definition,
241
+ c.relkind = 'm' AS materialised,
242
+ array_to_string(c.reloptions, ', ') AS options
243
+ FROM pg_class c
244
+ JOIN pg_namespace n ON n.oid = c.relnamespace
245
+ WHERE n.nspname = $1 AND c.relkind IN ('v', 'm')
246
+ ORDER BY c.relname`,
247
+ [schema],
248
+ );
249
+ return rows;
250
+ }
251
+
252
+ /** Views, with the columns they expose, so a finding can name what leaked. */
253
+ async function readViewsWithColumns(client, schema) {
254
+ const views = await readViews(client, schema);
255
+ for (const view of views) {
256
+ view.columns = await readColumns(client, schema, view.name);
257
+ }
258
+ return views;
259
+ }
260
+
261
+ /** Who was granted what on a view. Separate, because views are created later. */
262
+ async function readViewGrants(client, schema) {
263
+ const { rows } = await client.query(
264
+ `SELECT g.table_name, g.grantee, g.privilege_type
265
+ FROM information_schema.role_table_grants g
266
+ JOIN pg_class c ON c.relname = g.table_name
267
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
268
+ WHERE g.table_schema = $1
269
+ AND c.relkind IN ('v', 'm')
270
+ AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
271
+ ORDER BY g.table_name, g.grantee, g.privilege_type`,
272
+ [schema],
273
+ );
274
+ return rows;
275
+ }
276
+
277
+ /**
278
+ * The types the app made for itself: enums, and domains.
279
+ *
280
+ * Copied, so that the copy stands on its own. It used to borrow them from the
281
+ * schema it was copied from, which worked right up until a view mentioned one:
282
+ * pg_get_viewdef writes a literal as 'paid'::app.order_status, the rewrite
283
+ * turned app into the copy, and the copy had no such type. Any app with a view
284
+ * over an enum column - which is a great many of them - crashed the scan
285
+ * outright, with a stack trace where the report should have been.
286
+ *
287
+ * Borrowing was quietly wrong anyway. A copy that reaches back into the
288
+ * original for anything is a copy that can change under the attack.
289
+ */
290
+ async function readTypes(client, schema) {
291
+ const { rows } = await client.query(
292
+ `SELECT t.typname AS name,
293
+ t.typtype AS kind,
294
+ CASE WHEN t.typtype = 'e' THEN (
295
+ SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
296
+ FROM pg_enum e WHERE e.enumtypid = t.oid
297
+ ) END AS labels,
298
+ CASE WHEN t.typtype = 'd'
299
+ THEN format_type(t.typbasetype, t.typtypmod) END AS base_type,
300
+ CASE WHEN t.typtype = 'd' THEN (
301
+ SELECT array_agg(pg_get_constraintdef(c.oid) ORDER BY c.conname)
302
+ FROM pg_constraint c WHERE c.contypid = t.oid
303
+ ) END AS constraints,
304
+ t.typnotnull AS not_null,
305
+ CASE WHEN t.typtype = 'd' THEN t.typdefault END AS default_value
306
+ FROM pg_type t
307
+ JOIN pg_namespace n ON n.oid = t.typnamespace
308
+ WHERE n.nspname = $1
309
+ AND t.typtype IN ('e', 'd')
310
+ -- Enums first. A domain can be built on one, and a domain created
311
+ -- before the enum it stands on is a type that does not exist yet.
312
+ ORDER BY CASE WHEN t.typtype = 'e' THEN 0 ELSE 1 END, t.typname`,
313
+ [schema],
314
+ );
315
+ return rows;
316
+ }
317
+
318
+ /** What a foreign key points at, pulled out of Postgres's own wording. */
319
+ function referenceIn(definition) {
320
+ const match = /FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+([^\s(]+)\s*\(([^)]+)\)/i.exec(String(definition));
321
+ if (!match) return null;
322
+ const strip = (text) => text.trim().replace(/^"(.*)"$/, '$1');
323
+ const target = match[2].trim().split(/\.(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(strip);
324
+ return {
325
+ schema: target.length > 1 ? target[0] : null,
326
+ table: target[target.length - 1],
327
+ columns: match[3].split(',').map(strip),
328
+ };
329
+ }
330
+
331
+ /**
332
+ * The name a stand-in for an outside table is given inside the copy.
333
+ *
334
+ * `taken` is every name the customer already uses, and the stand-in keeps
335
+ * moving until it clashes with none of them. Identifying our own tables by
336
+ * their prefix alone meant a customer table that happened to start with the
337
+ * same letters was quietly dropped from every attack - a real hole reported as
338
+ * nothing at all, which is the failure this whole program exists to avoid.
339
+ */
340
+ function stubNameFor(refSchema, refTable, taken) {
341
+ const used = new Set(taken || []);
342
+ const base = 'kn_ext__' + refSchema + '__' + refTable;
343
+ let name = base;
344
+ let nth = 2;
345
+ while (used.has(name)) {
346
+ name = base + '__' + nth;
347
+ nth += 1;
348
+ }
349
+ return name;
350
+ }
351
+
352
+ /**
353
+ * Tables outside this schema that its foreign keys point at.
354
+ *
355
+ * Nearly every Supabase app has `references auth.users(id)`, and the copy
356
+ * cannot carry that as written: pointed at the real auth.users, every seeded
357
+ * row becomes a write into the customer's own authentication table. So the
358
+ * shape of the outside table is read - columns and types, never rows - and a
359
+ * stand-in is built inside the copy instead.
360
+ *
361
+ * A target whose shape cannot be read at all is returned with no columns, and
362
+ * the caller treats that as unsupported rather than guessing at it.
363
+ */
364
+ async function readExternalTargets(client, schema, tables) {
365
+ const wanted = new Map();
366
+ for (const table of tables) {
367
+ for (const constraint of table.constraints || []) {
368
+ if (constraint.kind !== 'f') continue;
369
+ const points = referenceIn(constraint.definition);
370
+ if (!points || !points.schema || points.schema === schema) continue;
371
+ const key = points.schema + '.' + points.table;
372
+ if (!wanted.has(key)) {
373
+ wanted.set(key, { schema: points.schema, table: points.table, columns: new Set() });
374
+ }
375
+ points.columns.forEach((column) => wanted.get(key).columns.add(column));
376
+ }
377
+ }
378
+
379
+ const targets = [];
380
+ for (const entry of wanted.values()) {
381
+ let columns = [];
382
+ try {
383
+ const { rows } = await client.query(
384
+ `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type
385
+ FROM pg_attribute a
386
+ WHERE a.attrelid = format('%I.%I', $1::text, $2::text)::regclass
387
+ AND a.attname = ANY($3) AND a.attnum > 0 AND NOT a.attisdropped
388
+ ORDER BY a.attnum`,
389
+ [entry.schema, entry.table, Array.from(entry.columns)],
390
+ );
391
+ columns = rows;
392
+ } catch (err) {
393
+ columns = [];
394
+ }
395
+ targets.push({
396
+ schema: entry.schema,
397
+ table: entry.table,
398
+ columns: columns,
399
+ stub: stubNameFor(entry.schema, entry.table, tables.map((t) => t.name)),
400
+ wanted: Array.from(entry.columns),
401
+ });
402
+ }
403
+ return targets;
404
+ }
405
+
406
+ /**
407
+ * Everything needed to rebuild a schema, and everything that could not be.
408
+ *
409
+ * `unsupported` is the important half. A caller that ignores it is attacking a
410
+ * database that is not the customer's.
411
+ */
412
+ async function readSchema(client, schema) {
413
+ const tables = await readTables(client, schema);
414
+ const unsupported = [];
415
+ const built = [];
416
+
417
+ for (const table of tables) {
418
+ const columns = await readColumns(client, schema, table.name);
419
+ const constraints = await readConstraints(client, schema, table.name);
420
+
421
+ built.push({
422
+ name: table.name,
423
+ rlsEnabled: table.rls_enabled,
424
+ rlsForced: table.rls_forced,
425
+ columns: columns,
426
+ constraints: constraints,
427
+ });
428
+ }
429
+
430
+ // A foreign key pointing outside this schema gets a stand-in inside the
431
+ // copy. Quietly dropping it instead would change what seeding is allowed to
432
+ // insert, and pointing it at the real table would make every seeded row a
433
+ // write into the customer's own data.
434
+ const external = await readExternalTargets(client, schema, built);
435
+ for (const target of external) {
436
+ if (target.columns.length !== target.wanted.length) {
437
+ unsupported.push(
438
+ 'a foreign key points at ' + target.schema + '.' + target.table +
439
+ ', and I could not read its shape to stand in for it',
440
+ );
441
+ }
442
+ }
443
+
444
+ return {
445
+ schema: schema,
446
+ tables: built,
447
+ policies: await readPolicies(client, schema),
448
+ grants: await readGrants(client, schema),
449
+ sequenceGrants: await readSequenceGrants(client, schema),
450
+ types: await readTypes(client, schema),
451
+ indexes: await readIndexes(client, schema),
452
+ views: await readViewsWithColumns(client, schema),
453
+ viewGrants: await readViewGrants(client, schema),
454
+ external: external,
455
+ unsupported: unsupported,
456
+ };
457
+ }
458
+
459
+ /* --------------------------------------------------------------------------
460
+ Rebuilding.
461
+ -------------------------------------------------------------------------- */
462
+
463
+ function quote(name) {
464
+ return '"' + String(name).split('"').join('""') + '"';
465
+ }
466
+
467
+ /**
468
+ * The roles a policy applies to, as a list.
469
+ *
470
+ * pg_policies hands this back as the raw Postgres array literal - the string
471
+ * "{anon,authenticated}", not an array - because node-postgres has no parser
472
+ * registered for name[]. Measured, not assumed. Treating it as an array gives
473
+ * either a crash or, worse, a policy created for the wrong roles.
474
+ */
475
+ function roleList(roles) {
476
+ if (Array.isArray(roles)) return roles;
477
+ const raw = String(roles === null || roles === undefined ? '' : roles).trim();
478
+ if (!raw || raw === '{}') return [];
479
+ const inner = raw.startsWith('{') && raw.endsWith('}') ? raw.slice(1, -1) : raw;
480
+ return inner
481
+ .split(',')
482
+ .map((part) => part.trim().replace(/^"(.*)"$/, '$1'))
483
+ .filter(Boolean);
484
+ }
485
+
486
+ /**
487
+ * Points anything schema-qualified at the copy instead of the original.
488
+ *
489
+ * Both spellings, and that is the whole point of this existing. Postgres
490
+ * writes a name unquoted when it does not need quoting, so a foreign key came
491
+ * back as "REFERENCES app.profiles(id)" while only the quoted form was being
492
+ * rewritten - and the copy was created holding a live reference into the
493
+ * customer's real schema. Every insert into the copy would then have been
494
+ * checked against the customer's real table, which is the one thing this is
495
+ * built never to touch.
496
+ */
497
+ function rewriteSchemaRefs(expr, fromSchema, toSchema) {
498
+ if (!expr) return null;
499
+ return String(expr).split(quote(fromSchema) + '.').join(quote(toSchema) + '.').split(fromSchema + '.').join(toSchema + '.');
500
+ }
501
+
502
+ /**
503
+ * Points a rule's references to the app's own tables and views at the copy -
504
+ * and leaves everything else alone.
505
+ *
506
+ * A rule is copied as Postgres printed it. For an app outside the search_path
507
+ * that includes the schema - "EXISTS (SELECT 1 FROM kn_app.members m ...)" -
508
+ * and replayed verbatim into the copy, the copy's rule read the customer's
509
+ * members table. Caught by the pg_depend guard in writeSchema the day it
510
+ * learned to look at rules; the text comparison had passed it for months,
511
+ * since source and copy printed the same words.
512
+ *
513
+ * Not rewriteSchemaRefs, which moves every "kn_app." it finds: a rule calling
514
+ * one of the app's functions (kn_app.whoami()) has to keep calling it,
515
+ * because functions are not copied, and pointing it at the copy makes the
516
+ * CREATE POLICY fail. Only names that are tables or views of this app move.
517
+ * A name followed by more identifier characters is a different name -
518
+ * members_log is not members - so the match stops at a word boundary.
519
+ */
520
+ function rewriteOwnTableRefs(expr, plan, target) {
521
+ if (expr === null || expr === undefined) return expr;
522
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
523
+ const simple = (s) => /^[a-z_][a-z0-9_$]*$/.test(s);
524
+ const schemaForms = [quote(plan.schema)].concat(simple(plan.schema) ? [plan.schema] : []);
525
+ const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name));
526
+ let out = String(expr);
527
+ for (const name of own) {
528
+ const nameForms = [quote(name)].concat(simple(name) ? [name] : []);
529
+ for (const s of schemaForms) {
530
+ for (const n of nameForms) {
531
+ const tail = n.charAt(0) === '"' ? '' : '(?![A-Za-z0-9_$])';
532
+ out = out.replace(new RegExp(escape(s + '.' + n) + tail, 'g'), quote(target) + '.' + quote(name));
533
+ }
534
+ }
535
+ }
536
+ return out;
537
+ }
538
+
539
+ /**
540
+ * Points a reference with no schema on it at the copy.
541
+ *
542
+ * The one the other two rewrites could not see. `pg_get_constraintdef` writes
543
+ * the schema only when the referenced table is *not* reachable through
544
+ * `search_path` - so an app living in `app_something` comes back as
545
+ * "REFERENCES app_something.profiles(id)" and is rewritten, while the same app
546
+ * living in `public` comes back as "REFERENCES profiles(id)" and there is
547
+ * nothing to rewrite. Replayed into the copy, that bare name resolves through
548
+ * `search_path` all over again, and binds to the customer's real table.
549
+ *
550
+ * Found on the first real Supabase project this was ever pointed at, which is
551
+ * the first app that was not in a schema of its own:
552
+ *
553
+ * notes_owner_fkey -> public.profiles (the customer's own table)
554
+ * orders_user_id_fkey -> public.profiles (the customer's own table)
555
+ *
556
+ * Nothing was written to those tables - the copy only pointed at them - but
557
+ * the copy was not the app, which is the same failure three other bugs in this
558
+ * file were. Here it meant every insert into the copy was checked against a
559
+ * table the seeder had put nothing in, so two tables out of four were never
560
+ * attacked and were reported, honestly but uselessly, as not checked.
561
+ *
562
+ * Only names the plan itself owns are touched. A reference to something
563
+ * outside the schema is the stand-in's business, not this one's.
564
+ */
565
+ function qualifyOwnRefs(definition, plan, target) {
566
+ const own = new Set((plan.tables || []).map((table) => table.name));
567
+ // A replacer function, never a replacement string: `$&` and friends are read
568
+ // as instructions inside one, and that has already cost this project an
569
+ // engine that would not install.
570
+ // A quoted identifier can hold anything, including spaces and quotes of its
571
+ // own doubled up, so it is matched as a whole rather than as a run of safe
572
+ // characters. The first attempt used [^".\s(]+ inside the quotes and
573
+ // silently did not match `REFERENCES "Group Table"(...)` - which the twin
574
+ // fixture has, because it was built out of every shape that has ever been
575
+ // read wrong, and it caught this the first time it ran.
576
+ //
577
+ // The unquoted alternative excludes a dot, so a name that is already
578
+ // schema-qualified is left alone: that is the other rewrite's work.
579
+ return String(definition).replace(
580
+ /(\bREFERENCES\s+)("(?:[^"]|"")*"|[^\s(".]+)(\s*\()/gi,
581
+ (whole, before, written, after) => {
582
+ const name = written.charAt(0) === '"'
583
+ ? written.slice(1, -1).split('""').join('"')
584
+ : written;
585
+ return own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole;
586
+ },
587
+ );
588
+ }
589
+
590
+ /**
591
+ * Points a foreign key at the stand-in instead of at the real outside table.
592
+ *
593
+ * Both spellings again, for the same reason the schema rewrite handles both:
594
+ * Postgres writes a name unquoted whenever it does not have to quote it, and
595
+ * missing one spelling leaves the copy holding a live reference into the
596
+ * customer's database.
597
+ */
598
+ function rewriteExternalRefs(expr, external, toSchema) {
599
+ let text = String(expr);
600
+ for (const target of external || []) {
601
+ const stub = quote(toSchema) + '.' + quote(target.stub);
602
+ text = text
603
+ .split(quote(target.schema) + '.' + quote(target.table))
604
+ .join(stub)
605
+ .split(target.schema + '.' + target.table)
606
+ .join(stub);
607
+ }
608
+ return text;
609
+ }
610
+
611
+ /** Two people who do not exist, used wherever a stand-in row is needed. */
612
+ // Everyone the attacks ever act as: the seeded pair (A, B) and the two who
613
+ // own nothing (C, D, see attack.js) - the same values, kept literal here
614
+ // because attack.js requires this file. All four go into every stand-in.
615
+ // With only A and B there, a row an attack added under C was refused by the
616
+ // foreign key to auth.users before any rule was consulted, and "anyone can
617
+ // place an order" or "two accounts share one email" came back as not tested.
618
+ // Measured on a test app shaped like a Supabase shop, where nearly every
619
+ // table's user_id points at auth.users - so on real apps, most of them.
620
+ const IDENTITIES = [
621
+ '11111111-1111-4111-8111-111111111111',
622
+ '22222222-2222-4222-8222-222222222222',
623
+ '55555555-5555-4555-8555-555555555555',
624
+ '66666666-6666-4666-8666-666666666666',
625
+ ];
626
+
627
+ /** Something of the right type to put in a stand-in row. */
628
+ function stubValue(type, nth) {
629
+ const kind = String(type).toLowerCase();
630
+ if (kind === 'uuid') return "'" + IDENTITIES[nth] + "'";
631
+ if (/^(integer|bigint|smallint|numeric|decimal|real|double)/.test(kind)) return String(nth + 1);
632
+ if (/^bool/.test(kind)) return nth === 0 ? 'true' : 'false';
633
+ if (/^(timestamp|date)/.test(kind)) return 'now()';
634
+ return "'kryptheon-" + (nth + 1) + "'";
635
+ }
636
+
637
+ /** Which of these roles this database actually has. */
638
+ async function existingRoles(client, wanted) {
639
+ const { rows } = await client.query('SELECT rolname FROM pg_roles WHERE rolname = ANY($1)', [wanted]);
640
+ return rows.map((row) => row.rolname);
641
+ }
642
+
643
+ /**
644
+ * Nothing may be written outside the copy. Ever.
645
+ *
646
+ * The product is sold on one sentence - we never touch your live app - and
647
+ * this is the line that keeps it true. It is a structural guard rather than a
648
+ * careful habit, because the two statements that broke the promise were
649
+ * written carefully and sat there for weeks: every check built its own
650
+ * auth.uid() first, so replacing the customer's looked exactly like doing
651
+ * nothing.
652
+ *
653
+ * Every statement has to name the copy schema, quoted or not. A statement that
654
+ * does not is not run, and the scan stops rather than guessing.
655
+ */
656
+ function mustStayInside(statements, target) {
657
+ const bare = new RegExp('(^|[^A-Za-z0-9_"])' + target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.');
658
+ for (const statement of statements) {
659
+ if (statement.includes(quote(target)) || bare.test(statement)) continue;
660
+ throw new Error(
661
+ 'refusing to run a statement that does not stay inside the copy ' + target + ': ' + statement,
662
+ );
663
+ }
664
+ return statements;
665
+ }
666
+
667
+ /**
668
+ * Builds the schema again, in a database we own.
669
+ *
670
+ * Order matters: tables, then constraints, then grants, then policies. A policy
671
+ * cannot be created before the table it guards, and a grant given after a
672
+ * policy would widen access the original did not have.
673
+ */
674
+ async function writeSchema(client, plan, target) {
675
+ const statements = ['CREATE SCHEMA ' + quote(target)];
676
+
677
+ // The app's own types first: a column, a check, a view or a cast can all
678
+ // name one, and nothing that mentions a type can be created before it.
679
+ for (const made of plan.types || []) {
680
+ if (made.kind === 'e') {
681
+ statements.push(
682
+ 'CREATE TYPE ' + quote(target) + '.' + quote(made.name) + ' AS ENUM (' +
683
+ (made.labels || []).map((label) => "'" + String(label).split("'").join("''") + "'").join(', ') + ')',
684
+ );
685
+ } else {
686
+ const here = (text) => rewriteSchemaRefs(text, plan.schema, target);
687
+ // A domain can stand on another of the app's own types, and its rule can
688
+ // name one too, so both go through the same rewrite a column does.
689
+ const parts = ['CREATE DOMAIN ' + quote(target) + '.' + quote(made.name) + ' AS ' + here(made.base_type)];
690
+ if (made.default_value) parts.push('DEFAULT ' + here(made.default_value));
691
+ if (made.not_null) parts.push('NOT NULL');
692
+ for (const rule of made.constraints || []) parts.push(here(rule));
693
+ statements.push(parts.join(' '));
694
+ }
695
+ }
696
+
697
+ for (const table of plan.tables) {
698
+ const columns = table.columns.map((column) => {
699
+ // The type is rewritten like everything else now that the copy has its
700
+ // own. Left alone, the copy leaned on the original for them.
701
+ const parts = [quote(column.name), rewriteSchemaRefs(column.type, plan.schema, target)];
702
+ const fallback = rewriteSchemaRefs(column.default_expr, plan.schema, target);
703
+ if (column.generated) {
704
+ // A stored generated column carries its expression in default_expr, and
705
+ // replaying that as a DEFAULT is a syntax error - "cannot use column
706
+ // reference in DEFAULT expression" - which took the whole scan down.
707
+ parts.push('GENERATED ALWAYS AS (' + fallback + ') STORED');
708
+ } else if (column.identity) {
709
+ parts.push('GENERATED ALWAYS AS IDENTITY');
710
+ } else if (fallback) {
711
+ parts.push('DEFAULT ' + fallback);
712
+ }
713
+ if (column.not_null && !column.generated && !column.identity) parts.push('NOT NULL');
714
+ return parts.join(' ');
715
+ });
716
+
717
+ // Sequences first, or a serial column's default has nothing to point at.
718
+ for (const column of table.columns) {
719
+ const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
720
+ if (!match) continue;
721
+ const bare = match[1].split('.').pop().split('"').join('');
722
+ statements.push('CREATE SEQUENCE IF NOT EXISTS ' + quote(target) + '.' + quote(bare));
723
+ }
724
+
725
+ statements.push(
726
+ 'CREATE TABLE ' + quote(target) + '.' + quote(table.name) + ' (' + columns.join(', ') + ')',
727
+ );
728
+
729
+ // And the sequence belongs to its column, the way serial makes it.
730
+ //
731
+ // Not tidiness. A sequence a column owns is linked to it in pg_depend,
732
+ // and that link is how the grants on it are found again - so a copy
733
+ // whose sequences stand loose reads back as having no sequence grants
734
+ // at all, however many were replayed onto it. It also means the
735
+ // sequence goes when the copy's table goes, which is what the original
736
+ // does.
737
+ for (const column of table.columns) {
738
+ const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
739
+ if (!match) continue;
740
+ const bare = match[1].split('.').pop().split('"').join('');
741
+ statements.push(
742
+ 'ALTER SEQUENCE ' + quote(target) + '.' + quote(bare) + ' OWNED BY ' +
743
+ quote(target) + '.' + quote(table.name) + '.' + quote(column.name),
744
+ );
745
+ }
746
+ }
747
+
748
+ // Stand-ins for the tables outside this schema that its foreign keys point
749
+ // at, with two rows already in them so seeding has something to reference.
750
+ // Built before the constraints, because a foreign key cannot be added to a
751
+ // table that is not there yet.
752
+ for (const outside of plan.external || []) {
753
+ const stub = quote(target) + '.' + quote(outside.stub);
754
+ const columns = outside.columns.map((column) => quote(column.name) + ' ' + column.type + ' NOT NULL');
755
+ const keyed = outside.columns.map((column) => quote(column.name)).join(', ');
756
+ statements.push('CREATE TABLE ' + stub + ' (' + columns.join(', ') + ', PRIMARY KEY (' + keyed + '))');
757
+ for (let nth = 0; nth < IDENTITIES.length; nth++) {
758
+ const values = outside.columns.map((column) => stubValue(column.type, nth));
759
+ statements.push('INSERT INTO ' + stub + ' VALUES (' + values.join(', ') + ')');
760
+ }
761
+ }
762
+
763
+ // Keys and uniques across every table first, then the foreign keys.
764
+ // Constraints used to be added table by table, so a foreign key on `orders`
765
+ // was created before the primary key on `profiles` existed and Postgres
766
+ // refused it: "no unique constraint matching given keys". A foreign key can
767
+ // only be added once the thing it points at is already unique.
768
+ for (const wantForeign of [false, true]) {
769
+ for (const table of plan.tables) {
770
+ for (const constraint of table.constraints) {
771
+ const isForeign = constraint.kind === 'f';
772
+ if (isForeign !== wantForeign) continue;
773
+ const definition = qualifyOwnRefs(
774
+ rewriteExternalRefs(
775
+ rewriteSchemaRefs(constraint.definition, plan.schema, target),
776
+ plan.external,
777
+ target,
778
+ ),
779
+ plan,
780
+ target,
781
+ );
782
+ statements.push(
783
+ 'ALTER TABLE ' + quote(target) + '.' + quote(table.name) +
784
+ ' ADD CONSTRAINT ' + quote(constraint.name) + ' ' + definition,
785
+ );
786
+ }
787
+ }
788
+ }
789
+
790
+ // Unique indexes, once every table exists. The schema name inside the
791
+ // definition is rewritten for the same reason a foreign key's was: Postgres
792
+ // writes it unquoted, and every single index definition carries it. Replayed
793
+ // as-is, the copy would build its indexes on the customer's real tables.
794
+ for (const index of plan.indexes || []) {
795
+ statements.push(rewriteSchemaRefs(index.definition, plan.schema, target));
796
+ }
797
+
798
+ // Views last, because they read from the tables above. Copied rather than
799
+ // skipped because a view is a way into a table: it runs with its creator
800
+ // rights unless it says security_invoker, so a view over a protected table
801
+ // hands out every row in it. Leaving views out meant never looking at that
802
+ // door at all.
803
+ for (const view of plan.views || []) {
804
+ const body = rewriteExternalRefs(rewriteSchemaRefs(view.definition, plan.schema, target), plan.external, target);
805
+ const options = view.options ? ' WITH (' + view.options + ')' : '';
806
+ statements.push(
807
+ 'CREATE ' + (view.materialised ? 'MATERIALIZED VIEW ' : 'VIEW ') +
808
+ quote(target) + '.' + quote(view.name) + options + ' AS ' + body,
809
+ );
810
+ }
811
+
812
+ for (const grant of plan.viewGrants || []) {
813
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
814
+ statements.push(
815
+ 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
816
+ );
817
+ }
818
+
819
+ // The copy needs the roles PostgREST switches into to be able to reach it.
820
+ // Granted on the copy's own schema and nowhere else.
821
+ //
822
+ // What used to be here, and must never come back: CREATE SCHEMA auth,
823
+ // CREATE OR REPLACE FUNCTION auth.uid(), and GRANT USAGE ON SCHEMA auth.
824
+ // Those ran against the customer's live database. The first overwrote their
825
+ // own authentication function; the third opened a schema they may have
826
+ // deliberately closed. The policies reference auth.uid() and that is fine -
827
+ // calling their function is a read, and a read is all we are ever allowed.
828
+ // If it is missing, their app does not work either, and the copy failing to
829
+ // build says so honestly instead of papering over it.
830
+ for (const role of await existingRoles(client, ['anon', 'authenticated'])) {
831
+ statements.push('GRANT USAGE ON SCHEMA ' + quote(target) + ' TO ' + quote(role));
832
+ }
833
+
834
+ for (const grant of plan.grants) {
835
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
836
+ statements.push(
837
+ 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
838
+ );
839
+ }
840
+
841
+ // The sequences, on the same terms as the tables. A table grant without
842
+ // the sequence grant that goes with it is a copy nobody can insert into.
843
+ for (const grant of plan.sequenceGrants || []) {
844
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
845
+ statements.push(
846
+ 'GRANT ' + grant.privilege_type + ' ON SEQUENCE ' + quote(target) + '.' +
847
+ quote(grant.sequence_name) + ' TO ' + who,
848
+ );
849
+ }
850
+
851
+ for (const table of plan.tables) {
852
+ if (table.rlsEnabled) {
853
+ statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' ENABLE ROW LEVEL SECURITY');
854
+ }
855
+ if (table.rlsForced) {
856
+ statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' FORCE ROW LEVEL SECURITY');
857
+ }
858
+ }
859
+
860
+ for (const policy of plan.policies) {
861
+ const roles = roleList(policy.roles).join(', ') || 'PUBLIC';
862
+ const parts = [
863
+ 'CREATE POLICY ' + quote(policy.name),
864
+ 'ON ' + quote(target) + '.' + quote(policy.table_name),
865
+ 'AS ' + (policy.permissive === 'PERMISSIVE' ? 'PERMISSIVE' : 'RESTRICTIVE'),
866
+ 'FOR ' + policy.cmd,
867
+ 'TO ' + roles,
868
+ ];
869
+ if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target) + ')');
870
+ if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target) + ')');
871
+ statements.push(parts.join(' '));
872
+ }
873
+
874
+ mustStayInside(statements, target);
875
+ // Built with the copy first on the search_path, so a bare name binds to the
876
+ // copy and not to whatever the customer happens to have.
877
+ //
878
+ // This is the structural half, and it went in after chasing the same bug
879
+ // through three different kinds of expression. Postgres writes a name
880
+ // without its schema whenever that name is already reachable - so a real app
881
+ // in `public` hands back "REFERENCES profiles(id)" and
882
+ // "nextval('orders_id_seq'::regclass)", and a rewrite that goes looking for
883
+ // a schema name finds nothing to change in either. Replayed into the copy,
884
+ // both bound to the customer's own objects: the foreign keys pointed at
885
+ // their tables, and the copy drew its keys from their sequences, which
886
+ // advanced them.
887
+ //
888
+ // Rewriting each kind of expression in turn is a game with no end - index
889
+ // predicates, checks that call a function, view bodies. Naming the copy
890
+ // first on the path ends all of them at once, because it changes what a
891
+ // bare name means rather than trying to find every place one can appear.
892
+ //
893
+ // `public` stays on the path, after the copy, because the copy legitimately
894
+ // needs what lives there - gen_random_uuid() and the like. `extensions` is
895
+ // where Supabase keeps them; a schema on the path that does not exist is
896
+ // ignored rather than an error.
897
+ const { rows: pathRows } = await client.query('SHOW search_path');
898
+ const restoreTo = pathRows[0].search_path;
899
+ await client.query('SET search_path TO ' + quote(target) + ', public, extensions');
900
+ try {
901
+ for (const statement of statements) {
902
+ await client.query(statement);
903
+ }
904
+ } finally {
905
+ await client.query('SET search_path TO ' + restoreTo).catch(() => {});
906
+ }
907
+
908
+ // And then look at what was actually built, rather than at what was meant.
909
+ //
910
+ // Everything above this line reasons about strings. The guard at the top of
911
+ // this file reads the statements before they run; this one asks Postgres
912
+ // where the copy ended up pointing, which is the only account of it that
913
+ // cannot be fooled by a spelling nobody anticipated - and one was not. A
914
+ // bare "REFERENCES profiles(id)" replayed into the copy bound to the
915
+ // customer's own table, silently, on every app that lives in `public`.
916
+ //
917
+ // Nothing is written to a table outside the copy either way. The damage is
918
+ // subtler than that: a copy tied to the customer's rows is not the app being
919
+ // attacked, and every verdict taken from it is about something else.
920
+ // Foreign keys AND column defaults, because the second one is where this
921
+ // hid after the first was closed: a serial column's default is
922
+ // nextval('<sequence>'), and the copy was calling the customer's. Nothing
923
+ // was written to their tables, but nextval advances a sequence, so their
924
+ // database changed - and "we never touch your live app" says at all.
925
+ //
926
+ // Asked of pg_depend rather than of the text, so it holds for any
927
+ // expression that ends up pointing at a relation, not only the ones anybody
928
+ // thought to look at. pg_catalog is excluded because everything depends on
929
+ // it; anything else outside the copy is the bug.
930
+ const { rows: strays } = await client.query(
931
+ `SELECT con.conname AS what, rn.nspname AS points_at
932
+ FROM pg_constraint con
933
+ JOIN pg_class cl ON cl.oid = con.conrelid
934
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
935
+ JOIN pg_class rc ON rc.oid = con.confrelid
936
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
937
+ WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1
938
+ UNION ALL
939
+ SELECT cl.relname || '.' || a.attname || ' default' AS what,
940
+ rn.nspname || '.' || rc.relname AS points_at
941
+ FROM pg_depend d
942
+ JOIN pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_attrdef'::regclass
943
+ JOIN pg_class cl ON cl.oid = ad.adrelid
944
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
945
+ JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
946
+ JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
947
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
948
+ WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'
949
+ UNION ALL
950
+ -- And the rules. A policy that reads another table - "items of my
951
+ -- orders" - has to read the copy's orders; one reading the customer's
952
+ -- would evaluate the attack against their real rows. The text comparison
953
+ -- further down never caught this for an app in public: a reference to
954
+ -- public.orders prints as plain "orders" either way. Postgres records
955
+ -- every relation a policy expression uses, so ask it.
956
+ SELECT pol.polname || ' rule on ' || cl.relname AS what,
957
+ rn.nspname || '.' || rc.relname AS points_at
958
+ FROM pg_depend d
959
+ JOIN pg_policy pol ON pol.oid = d.objid AND d.classid = 'pg_policy'::regclass
960
+ JOIN pg_class cl ON cl.oid = pol.polrelid
961
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
962
+ JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
963
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
964
+ WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'`,
965
+ [target],
966
+ );
967
+ if (strays.length) {
968
+ throw new Error(
969
+ 'the copy points outside itself, so it is not the app: ' +
970
+ strays.map((row) => row.what + ' -> ' + row.points_at).join(', '),
971
+ );
972
+ }
973
+
974
+ return statements;
975
+ }
976
+
977
+ /* --------------------------------------------------------------------------
978
+ Checking the copy is the original.
979
+ -------------------------------------------------------------------------- */
980
+
981
+ /** A piece of SQL with its own schema name taken off, quoted or not. */
982
+ function withoutSchema(text, plan) {
983
+ return String(text == null ? '' : text)
984
+ .split(quote(plan.schema) + '.').join('')
985
+ .split(plan.schema + '.').join('');
986
+ }
987
+
988
+ /**
989
+ * Where a copy differs from what it was copied from.
990
+ *
991
+ * Anything here means the verdicts that follow are about the wrong database,
992
+ * so this returning empty is a precondition for attacking, not a nicety.
993
+ */
994
+ function diffSchemas(source, copy) {
995
+ const differences = [];
996
+
997
+ // The stand-ins exist only in the copy, by design. Comparing them against
998
+ // an original that never had them would report every Supabase app as a
999
+ // copy that came out wrong.
1000
+ const standIns = new Set((source.external || []).map((e) => e.stub));
1001
+ const mine = (list) => list.filter((t) => !standIns.has(t.name));
1002
+ const named = (list) => mine(list).map((t) => t.name).sort().join(', ');
1003
+ if (named(source.tables) !== named(copy.tables)) {
1004
+ differences.push('tables differ: ' + named(source.tables) + ' vs ' + named(copy.tables));
1005
+ }
1006
+
1007
+ for (const table of source.tables) {
1008
+ const mirror = copy.tables.find((t) => t.name === table.name);
1009
+ if (!mirror) continue;
1010
+
1011
+ if (table.rlsEnabled !== mirror.rlsEnabled) {
1012
+ differences.push(
1013
+ table.name + ': row level security is ' + (table.rlsEnabled ? 'on' : 'off') +
1014
+ ' but the copy has it ' + (mirror.rlsEnabled ? 'on' : 'off'),
1015
+ );
1016
+ }
1017
+ if (table.rlsForced !== mirror.rlsForced) {
1018
+ differences.push(table.name + ': forced row level security does not match');
1019
+ }
1020
+
1021
+ // The schema name comes off the type first. A column of the app's own enum
1022
+ // reads as app.order_status in the original and as <copy>.order_status in
1023
+ // the copy - the same type, built twice, and calling that a difference
1024
+ // would stop every scan of an app that has one.
1025
+ const shape = (plan, t) =>
1026
+ t.columns.map((c) => c.name + ' ' + withoutSchema(c.type, plan) + (c.not_null ? ' NOT NULL' : '')).join(' | ');
1027
+ if (shape(source, table) !== shape(copy, mirror)) {
1028
+ differences.push(table.name + ' columns differ:\n ' + shape(source, table) + '\n ' + shape(copy, mirror));
1029
+ }
1030
+ }
1031
+
1032
+ // The app's own types. An enum that arrived with a label missing narrows
1033
+ // what the attacks are able to insert, and nothing else here would notice.
1034
+ const typeText = (plan) =>
1035
+ (plan.types || [])
1036
+ .map((made) =>
1037
+ made.name + ' ' + made.kind + ' ' + (made.labels || []).join(',') + ' ' +
1038
+ withoutSchema(made.base_type, plan) + ' ' +
1039
+ (made.constraints || []).map((rule) => withoutSchema(rule, plan)).join(' '))
1040
+ .sort();
1041
+ const sourceTypes = typeText(source);
1042
+ const copyTypes = typeText(copy);
1043
+ for (const made of sourceTypes) {
1044
+ if (!copyTypes.includes(made)) differences.push('a type did not come across whole: ' + made);
1045
+ }
1046
+
1047
+ // The sequence grants, for the same reason they are copied at all: without
1048
+ // them nothing can insert, and every write attack reads as the app
1049
+ // defending itself.
1050
+ const sequenceText = (plan) =>
1051
+ (plan.sequenceGrants || [])
1052
+ .map((g) => g.sequence_name + ' ' + g.grantee + ' ' + g.privilege_type)
1053
+ .sort();
1054
+ const sourceSequences = sequenceText(source);
1055
+ const copySequences = sequenceText(copy);
1056
+ for (const one of sourceSequences) {
1057
+ if (!copySequences.includes(one)) {
1058
+ differences.push('a sequence grant did not come across: ' + one);
1059
+ }
1060
+ }
1061
+
1062
+ // Uniqueness decides whether the Collision attack has anything to report, so
1063
+ // a unique index that failed to come across has to be caught here rather
1064
+ // than turn into a confident finding about a table that was actually fine.
1065
+ // The schema name is stripped before comparing, since it differs by design.
1066
+ const indexText = (plan) =>
1067
+ (plan.indexes || [])
1068
+ .map((index) => withoutSchema(index.definition, plan))
1069
+ .sort();
1070
+ const sourceIndexes = indexText(source);
1071
+ const copyIndexes = indexText(copy);
1072
+ if (sourceIndexes.length !== copyIndexes.length) {
1073
+ differences.push(
1074
+ 'the copy has ' + copyIndexes.length + ' unique indexes, the original has ' + sourceIndexes.length,
1075
+ );
1076
+ }
1077
+ for (let i = 0; i < Math.max(sourceIndexes.length, copyIndexes.length); i++) {
1078
+ if (sourceIndexes[i] !== copyIndexes[i]) {
1079
+ differences.push('a unique index came across changed:\n ' + sourceIndexes[i] + '\n ' + copyIndexes[i]);
1080
+ }
1081
+ }
1082
+
1083
+ // The policies matter most, so they are compared word for word - except for
1084
+ // the schema names, which differ by design. A rule that looks at another of
1085
+ // the app's own tables ("items of my orders": EXISTS (SELECT 1 FROM orders
1086
+ // ...)) comes back from the copy naming the copy's orders, kn_xxx.orders,
1087
+ // which is exactly right. Compared raw, that read as "the copy came out
1088
+ // changed" and the whole app went unscanned - found on a test app built to
1089
+ // look like a Lovable shop, where this rule is ordinary. And a rule calling
1090
+ // one of the app's functions keeps naming the original schema in both,
1091
+ // because functions are not copied - so both names are taken out of both
1092
+ // sides. Where a rule actually points - the copy's table or the customer's -
1093
+ // is not left to this text: writeSchema asks pg_depend, and refuses a copy
1094
+ // whose rules read anything outside it.
1095
+ const bothSchemas = (text) => withoutSchema(withoutSchema(text, source), copy);
1096
+ const asText = (list) =>
1097
+ list
1098
+ .map((p) =>
1099
+ [p.table_name, p.name, p.permissive, roleList(p.roles).join('+'), p.cmd,
1100
+ bothSchemas(p.qual), bothSchemas(p.with_check)]
1101
+ .map((x) => String(x === null || x === undefined ? '' : x))
1102
+ .join(' :: '),
1103
+ )
1104
+ .sort();
1105
+
1106
+ const sourcePolicies = asText(source.policies);
1107
+ const copyPolicies = asText(copy.policies);
1108
+ if (sourcePolicies.length !== copyPolicies.length) {
1109
+ differences.push('the copy has ' + copyPolicies.length + ' policies, the original has ' + sourcePolicies.length);
1110
+ }
1111
+ for (let i = 0; i < Math.max(sourcePolicies.length, copyPolicies.length); i++) {
1112
+ if (sourcePolicies[i] !== copyPolicies[i]) {
1113
+ differences.push('a policy came across changed:\n ' + sourcePolicies[i] + '\n ' + copyPolicies[i]);
1114
+ }
1115
+ }
1116
+
1117
+ return differences;
1118
+ }
1119
+
1120
+ module.exports = {
1121
+ readSchema: readSchema,
1122
+ writeSchema: writeSchema,
1123
+ diffSchemas: diffSchemas,
1124
+ readPolicies: readPolicies,
1125
+ readTypes: readTypes,
1126
+ readSequenceGrants: readSequenceGrants,
1127
+ readIndexes: readIndexes,
1128
+ readViews: readViews,
1129
+ readExternalTargets: readExternalTargets,
1130
+ referenceIn: referenceIn,
1131
+ rewriteSchemaRefs: rewriteSchemaRefs,
1132
+ qualifyOwnRefs: qualifyOwnRefs,
1133
+ stubNameFor: stubNameFor,
1134
+ IDENTITIES: IDENTITIES,
1135
+ quote: quote,
1136
+ roleList: roleList,
1137
+ };