kryptheon-night 0.1.7 → 0.1.9

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