kryptheon-night 0.1.39 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (9) hide show
  1. package/attack.js +1028 -1025
  2. package/bin/kryptheon-night.js +736 -725
  3. package/engine.sql +4657 -4430
  4. package/finding.js +1715 -1612
  5. package/intro.js +311 -303
  6. package/package.json +98 -98
  7. package/scan.js +1330 -1311
  8. package/schema.js +1588 -1555
  9. package/tamper.js +949 -772
package/schema.js CHANGED
@@ -1,1555 +1,1588 @@
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
- /**
210
- * Whether a function turns the caller away before it does anything.
211
- *
212
- * Found on a blind test: create_admin_export opens with
213
- * if not public.is_org_admin(p_org_id) then raise exception ...
214
- * and was reported as "anyone can call it, with full rights". A visitor with
215
- * no account is not an admin of anything, so the first line refuses them and
216
- * nothing else runs. That is a guard, and the function is examined, not
217
- * reported.
218
- *
219
- * Only a refusal counts - IF NOT <check> THEN RAISE - and only one that comes
220
- * before the first write and the first RETURN, because a check made after the
221
- * work is done guards nothing. The check has to ask about the caller: a call
222
- * to one of the schema's yes/no helpers that ask auth.uid(), or a condition
223
- * of its own that asks auth.uid() whether they are an admin, owner or member.
224
- * "Is anybody signed in at all" is not enough, and neither is a check on an
225
- * argument the caller chose.
226
- */
227
- function guardedAtTheDoor(body, helpers) {
228
- return doorOf(body, helpers).guarded;
229
- }
230
-
231
- /**
232
- * The same reading, and one more answer: whether the only check at the door
233
- * is one a caller with no role at all slips past.
234
- *
235
- * Found on a blind test (HarborLine): complete_automation_run opened with
236
- * if public.org_role(v_org) not in ('owner', 'admin') then raise ...
237
- * org_role answers with the caller's role there - and with NULL for somebody
238
- * who has none. NULL NOT IN (...) is not true, so the IF never raises, and a
239
- * stranger completed another organization's runs. A role helper's check is a
240
- * guard only when it is written so NULL cannot pass - coalesce(...), or
241
- * IS NULL OR ... - and otherwise it is said, by name, why it is not one.
242
- *
243
- * `helpers` are names (yes/no helpers about the caller) or { name, nullable }
244
- * (a role helper, which answers NULL for a stranger).
245
- *
246
- * "Is anybody signed in at all" - IF auth.uid() IS NULL THEN RAISE - counts
247
- * only when the function takes nothing that could point at somebody else's
248
- * row (`args`, as pg_get_function_identity_arguments says them). Found on a
249
- * blind test (Meridian): bootstrap_organization(p_name text) opened with it,
250
- * made a new organization owned by the caller, and was reported as "anyone
251
- * on the internet can call it". A visitor with no account is turned away, and
252
- * a signed-in one can only make their own. Given an organization's id, the
253
- * same line would let any stranger who signed up act on that organization,
254
- * so there it is still no guard.
255
- */
256
- function namesSomebody(args) {
257
- if (args === undefined || args === null) return true;
258
- return String(args).split(',').map((a) => a.trim()).filter(Boolean).some((a) => {
259
- const words = a.replace(/^(in|inout|variadic)\s+/i, '').split(/\s+/);
260
- const name = words.length > 1 ? words[0].replace(/"/g, '') : '';
261
- const type = words.slice(words.length > 1 ? 1 : 0).join(' ');
262
- return /\b(uuid|oid|int[248]?|integer|bigint|smallint|numeric|serial|bigserial)\b/i.test(type) ||
263
- /(^|_)(ids?|uids?|orgs?|organizations?|tenants?|teams?|workspaces?|accounts?|users?|owners?|members?|projects?|customers?|compan(y|ies))$/i.test(name);
264
- });
265
- }
266
-
267
- function doorOf(body, helpers, args) {
268
- const text = String(body || '');
269
- const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
270
- const returns = /\breturn\b/i.exec(text);
271
- const firstAct = Math.min(writes ? writes.index : Infinity, returns ? returns.index : Infinity);
272
- // The condition may not run past a THEN, or two IFs would be read as one;
273
- // and END IF is not the start of one.
274
- const guard = /(?<!\bend\s+)\bif\b((?:(?!\bthen\b)[\s\S])*?)\bthen\s+raise\b(?!\s+(?:notice|warning|info|log|debug)\b)/gi;
275
- // The name as a call, bare or schema-qualified (public.is_org_admin), and
276
- // never as the tail of a longer name.
277
- const asks = (cond, name) => new RegExp('(^|[^\\w$"])"?' +
278
- name.replace(/[$]/g, '\\$') + '"?\\s*\\(', 'i').test(cond);
279
- const list = (helpers || []).map((h) => (typeof h === 'string' ? { name: h, nullable: false } : h));
280
- let slips = false;
281
- let found;
282
- while ((found = guard.exec(text)) !== null) {
283
- if (found.index > firstAct) break;
284
- const cond = found[1].trim();
285
- // Refusing when the check fails, not when it passes.
286
- const refusesOnNo = /^not\b/i.test(cond) || /\bis\s+(not\s+true|false)\s*$/i.test(cond) || /=\s*false\s*$/i.test(cond);
287
- if (refusesOnNo && list.some((h) => !h.nullable && asks(cond, h.name))) return { guarded: true, nullSlips: false };
288
- if (refusesOnNo && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) {
289
- return { guarded: true, nullSlips: false };
290
- }
291
- const signedIn = /^\(?\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+null\s*\)?$/i.test(cond) ||
292
- /^not\s*\(\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+not\s+null\s*\)$/i.test(cond);
293
- if (signedIn && !namesSomebody(args)) return { guarded: true, nullSlips: false };
294
- if (list.some((h) => h.nullable && asks(cond, h.name))) {
295
- const refuses = refusesOnNo || /\bnot\s+in\s*\(/i.test(cond) || /<>|!=/.test(cond);
296
- const nullSafe = /\bcoalesce\s*\(/i.test(cond) || /\bis\s+null\b/i.test(cond) || /\bis\s+not\s+true\s*$/i.test(cond);
297
- if (refuses && nullSafe) return { guarded: true, nullSlips: false };
298
- if (refuses) slips = true;
299
- }
300
- }
301
- return { guarded: false, nullSlips: slips };
302
- }
303
-
304
- async function readAnonDefinerFunctions(client, schema, role) {
305
- const caller = role || 'anon';
306
- // A plain Postgres with no anon role has no anonymous caller at all, so
307
- // nothing is callable by it - and has_function_privilege() on a role that
308
- // does not exist throws, which took the whole scan down with it before this
309
- // was checked first.
310
- const exists = await client.query('SELECT 1 FROM pg_roles WHERE rolname = $1', [caller]);
311
- const { rows } = await client.query(
312
- `SELECT p.proname AS name,
313
- pg_get_function_identity_arguments(p.oid) AS args,
314
- p.proconfig::text AS config,
315
- pg_get_functiondef(p.oid) AS def,
316
- p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
317
- (SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
318
- ${exists.rows.length ? "has_function_privilege($2, p.oid, 'EXECUTE')" : 'false'} AS callable,
319
- CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
320
- THEN has_function_privilege('authenticated', p.oid, 'EXECUTE') ELSE false END AS callable_signed_in
321
- FROM pg_proc p
322
- JOIN pg_namespace n ON n.oid = p.pronamespace
323
- WHERE n.nspname = $1
324
- AND p.prosecdef
325
- AND p.prorettype <> 'pg_catalog.trigger'::regtype
326
- ORDER BY p.proname`,
327
- exists.rows.length ? [schema, caller] : [schema],
328
- );
329
- // The schema's helpers about the caller - is_org_admin(org) asking whether
330
- // auth.uid() is an admin of it, org_role(org) answering with their role -
331
- // definer or not, since a guard can call either. Read once, for doorOf. A
332
- // role helper answers NULL for somebody with no role, so it is marked.
333
- const { rows: yesNo } = await client.query(
334
- `SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def,
335
- p.prorettype <> 'pg_catalog.bool'::regtype AS nullable
336
- FROM pg_proc p
337
- JOIN pg_namespace n ON n.oid = p.pronamespace
338
- JOIN pg_type t ON t.oid = p.prorettype
339
- WHERE n.nspname = $1 AND p.prokind = 'f' AND (p.prorettype = 'pg_catalog.bool'::regtype OR t.typtype = 'e')
340
- ORDER BY p.proname`,
341
- [schema],
342
- );
343
- const helpers = yesNo.filter((row) => {
344
- const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
345
- return /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body) &&
346
- !/\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
347
- }).map((row) => ({ name: row.name, nullable: row.nullable === true }));
348
- return rows.map((row) => {
349
- // A definer function with no search_path pinned is a second, separate
350
- // hazard: the caller can set their own search_path and make the function
351
- // resolve to their objects. Said as part of the same finding.
352
- const config = row.config || '';
353
- const hasFixedSearchPath = /(^|,)search_path=/i.test(config.replace(/[{}"]/g, ''));
354
- // The body is only used to say, in the report, whether it writes - which
355
- // decides how the finding is worded, not whether it is reported. A comment
356
- // could fool this, and that is acceptable: it never turns a non-finding
357
- // into a finding, only "can read" into "can change".
358
- const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
359
- // `update\s+\w` followed by \b matched only a one-letter table name, so
360
- // UPDATE was never seen - archive_notebook was worded as reading. The
361
- // whole name, quoted or not, is matched now.
362
- const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
363
- // A yes/no about the caller alone - is_org_member(org) asking whether
364
- // auth.uid() belongs to it. Found on a blind test, where two such helpers
365
- // were reported as "anyone can call it, with full rights": they are how
366
- // row level security avoids checking a table against itself, and to a
367
- // visitor with no account they answer no. Reading nothing about anybody
368
- // else and writing nothing, they are not a reach. A yes/no that does not
369
- // ask auth.uid() - is_admin(user_id) - answers about other people, and
370
- // stays a finding.
371
- //
372
- // A role about the caller alone is the same: org_role(org), the caller's
373
- // own role there, from a fixed list of labels (an enum) that can hold
374
- // nothing about anybody else. Found on a blind test (HarborLine), where
375
- // org_role was reported beside the functions it guards.
376
- const aboutCaller = (row.returns_bool === true || row.returns_enum === true) && !writes &&
377
- /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
378
- const door = doorOf(body, helpers, row.args || '');
379
- return {
380
- name: row.name,
381
- args: row.args || '',
382
- writes: writes,
383
- aboutCaller: aboutCaller,
384
- guarded: door.guarded,
385
- nullSlips: door.nullSlips,
386
- hasFixedSearchPath: hasFixedSearchPath,
387
- callable: row.callable === true,
388
- // Signed-in users too. A definer function only they can call is the
389
- // ordinary Supabase RPC and is not a privileged finding - but one that
390
- // leaves its search_path open is still worth a look (scan.js searchPathsOf).
391
- callableSignedIn: row.callable_signed_in === true,
392
- };
393
- });
394
- }
395
-
396
- /**
397
- * The app's own SQL and PL/pgSQL functions, with their full definitions.
398
- *
399
- * Read so the helpers a rule calls can be built inside the copy. Found on a
400
- * blind test: a rule called has_org_role(org_id, ARRAY[...]::org_role[]). The
401
- * copy has its own org_role, the helper stayed in the original schema taking
402
- * the original's, and no rule could be created - the scan stopped before it
403
- * began. Left in the original, a helper also answers from the original's
404
- * tables rather than from the copy's.
405
- */
406
- async function readFunctions(client, schema) {
407
- const { rows } = await client.query(
408
- `SELECT p.proname AS name,
409
- l.lanname AS language,
410
- p.prosrc AS src,
411
- pg_get_functiondef(p.oid) AS def,
412
- pg_get_function_identity_arguments(p.oid) AS args,
413
- p.proacl IS NULL AS default_acl,
414
- -- Installed by an extension (pgcrypto's crypt(), digest() ...):
415
- -- not something the app wrote, so never listed as "your app's
416
- -- functions" (blind round 13 listed 23 of pgcrypto's).
417
- EXISTS (SELECT 1 FROM pg_depend d
418
- WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e') AS from_extension,
419
- -- Whether the app can call it at all, as a visitor or signed in.
420
- -- Such a function is something the app does that was read and
421
- -- never run - said under "What I did not test" (scan.js notCalledOf).
422
- CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')
423
- AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
424
- THEN has_function_privilege('anon', p.oid, 'EXECUTE')
425
- OR has_function_privilege('authenticated', p.oid, 'EXECUTE')
426
- ELSE false END AS client_callable,
427
- (SELECT coalesce(array_agg(g ORDER BY g), '{}'::text[])
428
- FROM (SELECT DISTINCT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee)::text END AS g
429
- FROM aclexplode(p.proacl) a
430
- WHERE a.privilege_type = 'EXECUTE' AND a.grantee <> p.proowner) x) AS executors
431
- FROM pg_proc p
432
- JOIN pg_namespace n ON n.oid = p.pronamespace
433
- JOIN pg_language l ON l.oid = p.prolang
434
- WHERE n.nspname = $1
435
- AND p.prokind = 'f'
436
- AND p.prorettype <> 'pg_catalog.trigger'::regtype
437
- ORDER BY p.proname, p.oid`,
438
- [schema],
439
- );
440
- return rows;
441
- }
442
-
443
- /**
444
- * Supabase Storage's buckets: whether each is public, and the rules on
445
- * storage.objects that say who may read it (a rule naming the bucket's id).
446
- *
447
- * The settings and the rules only - never a file, never a file's name. A
448
- * database without Supabase Storage, or one this connection cannot read it in,
449
- * has none. Mirrors read_buckets in engine.sql, query for query.
450
- *
451
- * Asked first rather than left to fail: a failed query inside a caller's
452
- * transaction would abort everything after it.
453
- */
454
- async function readBuckets(client) {
455
- try {
456
- const here = await client.query(
457
- `SELECT to_regclass('storage.buckets') IS NOT NULL
458
- AND EXISTS (SELECT 1 FROM information_schema.columns
459
- WHERE table_schema = 'storage' AND table_name = 'buckets' AND column_name = 'public') AS found`,
460
- );
461
- if (!here.rows[0].found) return [];
462
- const { rows } = await client.query(BUCKETS_QUERY);
463
- return rows[0].buckets;
464
- } catch (err) {
465
- return [];
466
- }
467
- }
468
-
469
- const BUCKETS_QUERY =
470
- `SELECT coalesce(jsonb_agg(jsonb_build_object(
471
- 'id', b.id::text,
472
- 'name', b.name::text,
473
- 'public', coalesce(b.public, false),
474
- 'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
475
- 'name', p.policyname::text,
476
- 'roles', to_jsonb(p.roles::text[]),
477
- 'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
478
- FROM pg_policies p
479
- WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
480
- AND p.cmd IN ('SELECT', 'ALL')
481
- AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
482
- -- The rules for putting files in and taking them out: who the app
483
- -- lets write a bucket says who its files are for (scan.js bucketsOf).
484
- 'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
485
- 'name', p.policyname::text,
486
- 'roles', to_jsonb(p.roles::text[]),
487
- 'qual', p.qual,
488
- 'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
489
- FROM pg_policies p
490
- WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
491
- AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
492
- AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
493
- ) ORDER BY b.id), '[]'::jsonb) AS buckets
494
- FROM storage.buckets b`;
495
-
496
- async function readPolicies(client, schema) {
497
- const { rows } = await client.query(
498
- `SELECT tablename AS table_name,
499
- policyname AS name,
500
- permissive,
501
- -- Cast on purpose. pg_policies.roles is name[], which
502
- -- node-postgres has no parser for, so it arrives as the raw
503
- -- string "{anon,authenticated}" - and code that treats it as a
504
- -- list gets the characters of that string instead of the roles.
505
- -- roleList() was written to undo that; casting here means there is
506
- -- nothing to undo. The third time this exact shape has cost a bug,
507
- -- and the one the two engines disagreed about on their first run.
508
- roles::text[] AS roles,
509
- cmd,
510
- qual,
511
- with_check
512
- FROM pg_policies
513
- WHERE schemaname = $1
514
- ORDER BY tablename, policyname`,
515
- [schema],
516
- );
517
- return rows;
518
- }
519
-
520
- /**
521
- * Who was granted what. A policy is irrelevant if the grant is not there.
522
- *
523
- * Restricted to ordinary tables on purpose. role_table_grants also lists views
524
- * and materialised views, and replaying a grant on a view the copy does not
525
- * contain fails outright - which took down the scan of any app with a view in
526
- * it, and almost every app has one.
527
- */
528
- async function readGrants(client, schema) {
529
- const { rows } = await client.query(
530
- `SELECT g.table_name, g.grantee, g.privilege_type
531
- FROM information_schema.role_table_grants g
532
- JOIN pg_class c ON c.relname = g.table_name
533
- JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
534
- WHERE g.table_schema = $1
535
- AND c.relkind = 'r'
536
- AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
537
- ORDER BY g.table_name, g.grantee, g.privilege_type`,
538
- [schema],
539
- );
540
- return rows;
541
- }
542
-
543
- /**
544
- * Views, which are a door of their own.
545
- *
546
- * A view runs with its creator's rights unless it says otherwise, so a view
547
- * over a table that row level security protects hands out every row in that
548
- * table to anyone allowed to read the view. The policy is intact, the
549
- * dashboard is green, and the data is gone. `security_invoker` is what turns
550
- * that off, and it lives in reloptions, so it is copied with the view.
551
- */
552
- async function readViews(client, schema) {
553
- const { rows } = await client.query(
554
- `SELECT c.relname AS name,
555
- pg_get_viewdef(c.oid, true) AS definition,
556
- c.relkind = 'm' AS materialised,
557
- array_to_string(c.reloptions, ', ') AS options
558
- FROM pg_class c
559
- JOIN pg_namespace n ON n.oid = c.relnamespace
560
- WHERE n.nspname = $1 AND c.relkind IN ('v', 'm')
561
- ORDER BY c.relname`,
562
- [schema],
563
- );
564
- return rows;
565
- }
566
-
567
- /** Views, with the columns they expose, so a finding can name what leaked. */
568
- async function readViewsWithColumns(client, schema) {
569
- const views = await readViews(client, schema);
570
- for (const view of views) {
571
- view.columns = await readColumns(client, schema, view.name);
572
- }
573
- return views;
574
- }
575
-
576
- /** Who was granted what on a view. Separate, because views are created later. */
577
- async function readViewGrants(client, schema) {
578
- const { rows } = await client.query(
579
- `SELECT g.table_name, g.grantee, g.privilege_type
580
- FROM information_schema.role_table_grants g
581
- JOIN pg_class c ON c.relname = g.table_name
582
- JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
583
- WHERE g.table_schema = $1
584
- AND c.relkind IN ('v', 'm')
585
- AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
586
- ORDER BY g.table_name, g.grantee, g.privilege_type`,
587
- [schema],
588
- );
589
- return rows;
590
- }
591
-
592
- /**
593
- * The types the app made for itself: enums, and domains.
594
- *
595
- * Copied, so that the copy stands on its own. It used to borrow them from the
596
- * schema it was copied from, which worked right up until a view mentioned one:
597
- * pg_get_viewdef writes a literal as 'paid'::app.order_status, the rewrite
598
- * turned app into the copy, and the copy had no such type. Any app with a view
599
- * over an enum column - which is a great many of them - crashed the scan
600
- * outright, with a stack trace where the report should have been.
601
- *
602
- * Borrowing was quietly wrong anyway. A copy that reaches back into the
603
- * original for anything is a copy that can change under the attack.
604
- */
605
- async function readTypes(client, schema) {
606
- const { rows } = await client.query(
607
- `SELECT t.typname AS name,
608
- t.typtype AS kind,
609
- CASE WHEN t.typtype = 'e' THEN (
610
- SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
611
- FROM pg_enum e WHERE e.enumtypid = t.oid
612
- ) END AS labels,
613
- CASE WHEN t.typtype = 'd'
614
- THEN format_type(t.typbasetype, t.typtypmod) END AS base_type,
615
- CASE WHEN t.typtype = 'd' THEN (
616
- SELECT array_agg(pg_get_constraintdef(c.oid) ORDER BY c.conname)
617
- FROM pg_constraint c WHERE c.contypid = t.oid
618
- ) END AS constraints,
619
- t.typnotnull AS not_null,
620
- CASE WHEN t.typtype = 'd' THEN t.typdefault END AS default_value
621
- FROM pg_type t
622
- JOIN pg_namespace n ON n.oid = t.typnamespace
623
- WHERE n.nspname = $1
624
- AND t.typtype IN ('e', 'd')
625
- -- Enums first. A domain can be built on one, and a domain created
626
- -- before the enum it stands on is a type that does not exist yet.
627
- ORDER BY CASE WHEN t.typtype = 'e' THEN 0 ELSE 1 END, t.typname`,
628
- [schema],
629
- );
630
- return rows;
631
- }
632
-
633
- /** What a foreign key points at, pulled out of Postgres's own wording. */
634
- function referenceIn(definition) {
635
- const match = /FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+([^\s(]+)\s*\(([^)]+)\)/i.exec(String(definition));
636
- if (!match) return null;
637
- const strip = (text) => text.trim().replace(/^"(.*)"$/, '$1');
638
- const target = match[2].trim().split(/\.(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(strip);
639
- return {
640
- schema: target.length > 1 ? target[0] : null,
641
- table: target[target.length - 1],
642
- columns: match[3].split(',').map(strip),
643
- };
644
- }
645
-
646
- /**
647
- * The name a stand-in for an outside table is given inside the copy.
648
- *
649
- * `taken` is every name the customer already uses, and the stand-in keeps
650
- * moving until it clashes with none of them. Identifying our own tables by
651
- * their prefix alone meant a customer table that happened to start with the
652
- * same letters was quietly dropped from every attack - a real hole reported as
653
- * nothing at all, which is the failure this whole program exists to avoid.
654
- */
655
- function stubNameFor(refSchema, refTable, taken) {
656
- const used = new Set(taken || []);
657
- const base = 'kn_ext__' + refSchema + '__' + refTable;
658
- let name = base;
659
- let nth = 2;
660
- while (used.has(name)) {
661
- name = base + '__' + nth;
662
- nth += 1;
663
- }
664
- return name;
665
- }
666
-
667
- /**
668
- * Tables outside this schema that its foreign keys point at.
669
- *
670
- * Nearly every Supabase app has `references auth.users(id)`, and the copy
671
- * cannot carry that as written: pointed at the real auth.users, every seeded
672
- * row becomes a write into the customer's own authentication table. So the
673
- * shape of the outside table is read - columns and types, never rows - and a
674
- * stand-in is built inside the copy instead.
675
- *
676
- * A target whose shape cannot be read at all is returned with no columns, and
677
- * the caller treats that as unsupported rather than guessing at it.
678
- */
679
- async function readExternalTargets(client, schema, tables) {
680
- const wanted = new Map();
681
- for (const table of tables) {
682
- for (const constraint of table.constraints || []) {
683
- if (constraint.kind !== 'f') continue;
684
- const points = referenceIn(constraint.definition);
685
- if (!points || !points.schema || points.schema === schema) continue;
686
- const key = points.schema + '.' + points.table;
687
- if (!wanted.has(key)) {
688
- wanted.set(key, { schema: points.schema, table: points.table, columns: new Set() });
689
- }
690
- points.columns.forEach((column) => wanted.get(key).columns.add(column));
691
- }
692
- }
693
-
694
- const targets = [];
695
- for (const entry of wanted.values()) {
696
- let columns = [];
697
- try {
698
- const { rows } = await client.query(
699
- `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type
700
- FROM pg_attribute a
701
- WHERE a.attrelid = format('%I.%I', $1::text, $2::text)::regclass
702
- AND a.attname = ANY($3) AND a.attnum > 0 AND NOT a.attisdropped
703
- ORDER BY a.attnum`,
704
- [entry.schema, entry.table, Array.from(entry.columns)],
705
- );
706
- columns = rows;
707
- } catch (err) {
708
- columns = [];
709
- }
710
- targets.push({
711
- schema: entry.schema,
712
- table: entry.table,
713
- columns: columns,
714
- stub: stubNameFor(entry.schema, entry.table, tables.map((t) => t.name)),
715
- wanted: Array.from(entry.columns),
716
- });
717
- }
718
- return targets;
719
- }
720
-
721
- /**
722
- * Everything needed to rebuild a schema, and everything that could not be.
723
- *
724
- * `unsupported` is the important half. A caller that ignores it is attacking a
725
- * database that is not the customer's.
726
- */
727
- async function readSchema(client, schema) {
728
- const tables = await readTables(client, schema);
729
- const unsupported = [];
730
- const built = [];
731
-
732
- for (const table of tables) {
733
- const columns = await readColumns(client, schema, table.name);
734
- const constraints = await readConstraints(client, schema, table.name);
735
-
736
- built.push({
737
- name: table.name,
738
- rlsEnabled: table.rls_enabled,
739
- rlsForced: table.rls_forced,
740
- columns: columns,
741
- constraints: constraints,
742
- });
743
- }
744
-
745
- // A foreign key pointing outside this schema gets a stand-in inside the
746
- // copy. Quietly dropping it instead would change what seeding is allowed to
747
- // insert, and pointing it at the real table would make every seeded row a
748
- // write into the customer's own data.
749
- const external = await readExternalTargets(client, schema, built);
750
- for (const target of external) {
751
- if (target.columns.length !== target.wanted.length) {
752
- unsupported.push(
753
- 'a foreign key points at ' + target.schema + '.' + target.table +
754
- ', and I could not read its shape to stand in for it',
755
- );
756
- }
757
- }
758
-
759
- return {
760
- schema: schema,
761
- tables: built,
762
- policies: await readPolicies(client, schema),
763
- grants: await readGrants(client, schema),
764
- sequenceGrants: await readSequenceGrants(client, schema),
765
- types: await readTypes(client, schema),
766
- indexes: await readIndexes(client, schema),
767
- views: await readViewsWithColumns(client, schema),
768
- viewGrants: await readViewGrants(client, schema),
769
- external: external,
770
- anonFunctions: await readAnonDefinerFunctions(client, schema),
771
- functions: await readFunctions(client, schema),
772
- buckets: await readBuckets(client),
773
- unsupported: unsupported,
774
- };
775
- }
776
-
777
- /* --------------------------------------------------------------------------
778
- Rebuilding.
779
- -------------------------------------------------------------------------- */
780
-
781
- function quote(name) {
782
- return '"' + String(name).split('"').join('""') + '"';
783
- }
784
-
785
- /**
786
- * The roles a policy applies to, as a list.
787
- *
788
- * pg_policies hands this back as the raw Postgres array literal - the string
789
- * "{anon,authenticated}", not an array - because node-postgres has no parser
790
- * registered for name[]. Measured, not assumed. Treating it as an array gives
791
- * either a crash or, worse, a policy created for the wrong roles.
792
- */
793
- function roleList(roles) {
794
- if (Array.isArray(roles)) return roles;
795
- const raw = String(roles === null || roles === undefined ? '' : roles).trim();
796
- if (!raw || raw === '{}') return [];
797
- const inner = raw.startsWith('{') && raw.endsWith('}') ? raw.slice(1, -1) : raw;
798
- return inner
799
- .split(',')
800
- .map((part) => part.trim().replace(/^"(.*)"$/, '$1'))
801
- .filter(Boolean);
802
- }
803
-
804
- /**
805
- * Points anything schema-qualified at the copy instead of the original.
806
- *
807
- * Both spellings, and that is the whole point of this existing. Postgres
808
- * writes a name unquoted when it does not need quoting, so a foreign key came
809
- * back as "REFERENCES app.profiles(id)" while only the quoted form was being
810
- * rewritten - and the copy was created holding a live reference into the
811
- * customer's real schema. Every insert into the copy would then have been
812
- * checked against the customer's real table, which is the one thing this is
813
- * built never to touch.
814
- */
815
- function rewriteSchemaRefs(expr, fromSchema, toSchema) {
816
- if (!expr) return null;
817
- return String(expr).split(quote(fromSchema) + '.').join(quote(toSchema) + '.').split(fromSchema + '.').join(toSchema + '.');
818
- }
819
-
820
- /**
821
- * Points a rule's references to the app's own tables and views at the copy -
822
- * and leaves everything else alone.
823
- *
824
- * A rule is copied as Postgres printed it. For an app outside the search_path
825
- * that includes the schema - "EXISTS (SELECT 1 FROM kn_app.members m ...)" -
826
- * and replayed verbatim into the copy, the copy's rule read the customer's
827
- * members table. Caught by the pg_depend guard in writeSchema the day it
828
- * learned to look at rules; the text comparison had passed it for months,
829
- * since source and copy printed the same words.
830
- *
831
- * Not rewriteSchemaRefs, which moves every "kn_app." it finds: a rule calling
832
- * one of the app's functions that is not copied (kn_app.whoami() in another
833
- * language) has to keep calling it, and pointing it at the copy makes the
834
- * CREATE POLICY fail. Only tables, views, and the functions the copy was
835
- * given (copiedFunctions) move. A name followed by more identifier characters
836
- * is a different name - members_log is not members - so the match stops at a
837
- * word boundary.
838
- */
839
- function rewriteOwnTableRefs(expr, plan, target, copiedFunctions) {
840
- if (expr === null || expr === undefined) return expr;
841
- const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
842
- const simple = (s) => /^[a-z_][a-z0-9_$]*$/.test(s);
843
- const schemaForms = [quote(plan.schema)].concat(simple(plan.schema) ? [plan.schema] : []);
844
- const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name), copiedFunctions || []);
845
- let out = String(expr);
846
- for (const name of own) {
847
- const nameForms = [quote(name)].concat(simple(name) ? [name] : []);
848
- for (const s of schemaForms) {
849
- for (const n of nameForms) {
850
- const tail = n.charAt(0) === '"' ? '' : '(?![A-Za-z0-9_$])';
851
- out = out.replace(new RegExp(escape(s + '.' + n) + tail, 'g'), quote(target) + '.' + quote(name));
852
- }
853
- }
854
- }
855
- return out;
856
- }
857
-
858
- /**
859
- * Which of the app's functions the copy needs: every SQL or PL/pgSQL function
860
- * a rule or a view calls, and every one those call in turn. Matched by name
861
- * followed by "(", bare or schema-qualified; every overload of a name comes
862
- * along. Functions in other languages stay where they are.
863
- */
864
- function functionsToCopy(plan) {
865
- const copyable = (plan.functions || []).filter((f) => f.language === 'sql' || f.language === 'plpgsql');
866
- const names = Array.from(new Set(copyable.map((f) => f.name)));
867
- const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
868
- const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
869
- const seeds = []
870
- .concat((plan.policies || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')))
871
- .concat((plan.views || []).map((v) => v.definition));
872
- const wanted = new Set(names.filter((name) => seeds.some((text) => calls(text, name))));
873
- // And whatever those call, until nothing new turns up.
874
- let grew = true;
875
- while (grew) {
876
- grew = false;
877
- for (const f of copyable) {
878
- if (!wanted.has(f.name)) continue;
879
- for (const name of names) {
880
- if (!wanted.has(name) && calls(f.src, name)) {
881
- wanted.add(name);
882
- grew = true;
883
- }
884
- }
885
- }
886
- }
887
- return copyable.filter((f) => wanted.has(f.name));
888
- }
889
-
890
- /**
891
- * One of the app's functions, rebuilt in the copy: the same body and settings,
892
- * its name in the copy, its references to the app's own tables, views and
893
- * copied functions pointed at the copy, and a pinned search_path that looks in
894
- * the copy first and the original second (for what the copy does not have,
895
- * such as an extension's functions).
896
- */
897
- function copyFunctionStatement(fn, plan, target, copiedNames) {
898
- const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
899
- const schemaForms = [quote(plan.schema), plan.schema].map(escape).join('|');
900
- let def = String(fn.def).replace(
901
- new RegExp('^CREATE OR REPLACE FUNCTION\\s+(?:' + schemaForms + ')\\.'),
902
- () => 'CREATE FUNCTION ' + quote(target) + '.',
903
- );
904
- // The settings line, e.g. SET search_path TO 'public', 'storage'
905
- const pinned = /^\s*SET search_path (?:TO|=) /m.test(def);
906
- def = def.replace(/^(\s*SET search_path (?:TO|=) )(.*)$/m, (all, head, list) =>
907
- head + list.split(',').map((part) => {
908
- const bare = part.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
909
- return bare === plan.schema ? "'" + target + "', " + part.trim() : part.trim();
910
- }).join(', '));
911
- // None pinned, so a bare name in the body is looked up on the caller's path
912
- // when it runs - and the caller's path has never heard of the copy. Found on
913
- // a blind test (OrbitDesk): member_of and has_role read `workspace_members`
914
- // bare, so the copy's helpers read the original's members, with the
915
- // original's workspace_role, against the copy's. Every rule calling has_role
916
- // failed with "operator does not exist", 13 attacks never ran, and a loop
917
- // through member_of never fired because the original's table was empty.
918
- // So the copy's helper is pinned the way the copy itself was built: the copy
919
- // first, then where the original's bare names would have gone. A BEGIN
920
- // ATOMIC body has no AS line and needs none - it was bound when it was made.
921
- if (!pinned) {
922
- const path = Array.from(new Set([target, plan.schema, 'public', 'extensions']));
923
- def = def.replace(/\nAS /, '\n SET search_path TO ' + path.map((s) => "'" + s + "'").join(', ') + '\nAS ');
924
- }
925
- return rewriteOwnTableRefs(def, plan, target, copiedNames);
926
- }
927
-
928
- /**
929
- * Points a reference with no schema on it at the copy.
930
- *
931
- * The one the other two rewrites could not see. `pg_get_constraintdef` writes
932
- * the schema only when the referenced table is *not* reachable through
933
- * `search_path` - so an app living in `app_something` comes back as
934
- * "REFERENCES app_something.profiles(id)" and is rewritten, while the same app
935
- * living in `public` comes back as "REFERENCES profiles(id)" and there is
936
- * nothing to rewrite. Replayed into the copy, that bare name resolves through
937
- * `search_path` all over again, and binds to the customer's real table.
938
- *
939
- * Found on the first real Supabase project this was ever pointed at, which is
940
- * the first app that was not in a schema of its own:
941
- *
942
- * notes_owner_fkey -> public.profiles (the customer's own table)
943
- * orders_user_id_fkey -> public.profiles (the customer's own table)
944
- *
945
- * Nothing was written to those tables - the copy only pointed at them - but
946
- * the copy was not the app, which is the same failure three other bugs in this
947
- * file were. Here it meant every insert into the copy was checked against a
948
- * table the seeder had put nothing in, so two tables out of four were never
949
- * attacked and were reported, honestly but uselessly, as not checked.
950
- *
951
- * Only names the plan itself owns are touched. A reference to something
952
- * outside the schema is the stand-in's business, not this one's.
953
- */
954
- function qualifyOwnRefs(definition, plan, target) {
955
- const own = new Set((plan.tables || []).map((table) => table.name));
956
- // A replacer function, never a replacement string: `$&` and friends are read
957
- // as instructions inside one, and that has already cost this project an
958
- // engine that would not install.
959
- // A quoted identifier can hold anything, including spaces and quotes of its
960
- // own doubled up, so it is matched as a whole rather than as a run of safe
961
- // characters. The first attempt used [^".\s(]+ inside the quotes and
962
- // silently did not match `REFERENCES "Group Table"(...)` - which the twin
963
- // fixture has, because it was built out of every shape that has ever been
964
- // read wrong, and it caught this the first time it ran.
965
- //
966
- // The unquoted alternative excludes a dot, so a name that is already
967
- // schema-qualified is left alone: that is the other rewrite's work.
968
- return String(definition).replace(
969
- /(\bREFERENCES\s+)("(?:[^"]|"")*"|[^\s(".]+)(\s*\()/gi,
970
- (whole, before, written, after) => {
971
- const name = written.charAt(0) === '"'
972
- ? written.slice(1, -1).split('""').join('"')
973
- : written;
974
- return own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole;
975
- },
976
- );
977
- }
978
-
979
- /**
980
- * Points a foreign key at the stand-in instead of at the real outside table.
981
- *
982
- * Both spellings again, for the same reason the schema rewrite handles both:
983
- * Postgres writes a name unquoted whenever it does not have to quote it, and
984
- * missing one spelling leaves the copy holding a live reference into the
985
- * customer's database.
986
- */
987
- function rewriteExternalRefs(expr, external, toSchema) {
988
- let text = String(expr);
989
- for (const target of external || []) {
990
- const stub = quote(toSchema) + '.' + quote(target.stub);
991
- text = text
992
- .split(quote(target.schema) + '.' + quote(target.table))
993
- .join(stub)
994
- .split(target.schema + '.' + target.table)
995
- .join(stub);
996
- }
997
- return text;
998
- }
999
-
1000
- /** Two people who do not exist, used wherever a stand-in row is needed. */
1001
- // Everyone the attacks ever act as: the seeded pair (A, B) and the two who
1002
- // own nothing (C, D, see attack.js) - the same values, kept literal here
1003
- // because attack.js requires this file. All four go into every stand-in.
1004
- // With only A and B there, a row an attack added under C was refused by the
1005
- // foreign key to auth.users before any rule was consulted, and "anyone can
1006
- // place an order" or "two accounts share one email" came back as not tested.
1007
- // Measured on a test app shaped like a Supabase shop, where nearly every
1008
- // table's user_id points at auth.users - so on real apps, most of them.
1009
- const IDENTITIES = [
1010
- '11111111-1111-4111-8111-111111111111',
1011
- '22222222-2222-4222-8222-222222222222',
1012
- '55555555-5555-4555-8555-555555555555',
1013
- '66666666-6666-4666-8666-666666666666',
1014
- ];
1015
-
1016
- /** Something of the right type to put in a stand-in row. */
1017
- function stubValue(type, nth) {
1018
- const kind = String(type).toLowerCase();
1019
- if (kind === 'uuid') return "'" + IDENTITIES[nth] + "'";
1020
- if (/^(integer|bigint|smallint|numeric|decimal|real|double)/.test(kind)) return String(nth + 1);
1021
- if (/^bool/.test(kind)) return nth === 0 ? 'true' : 'false';
1022
- if (/^(timestamp|date)/.test(kind)) return 'now()';
1023
- return "'kryptheon-" + (nth + 1) + "'";
1024
- }
1025
-
1026
- /** Which of these roles this database actually has. */
1027
- async function existingRoles(client, wanted) {
1028
- const { rows } = await client.query('SELECT rolname FROM pg_roles WHERE rolname = ANY($1)', [wanted]);
1029
- return rows.map((row) => row.rolname);
1030
- }
1031
-
1032
- /**
1033
- * Nothing may be written outside the copy. Ever.
1034
- *
1035
- * The product is sold on one sentence - we never touch your live app - and
1036
- * this is the line that keeps it true. It is a structural guard rather than a
1037
- * careful habit, because the two statements that broke the promise were
1038
- * written carefully and sat there for weeks: every check built its own
1039
- * auth.uid() first, so replacing the customer's looked exactly like doing
1040
- * nothing.
1041
- *
1042
- * Every statement has to name the copy schema, quoted or not. A statement that
1043
- * does not is not run, and the scan stops rather than guessing.
1044
- */
1045
- function mustStayInside(statements, target) {
1046
- const bare = new RegExp('(^|[^A-Za-z0-9_"])' + target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.');
1047
- for (const statement of statements) {
1048
- if (statement.includes(quote(target)) || bare.test(statement)) continue;
1049
- throw new Error(
1050
- 'refusing to run a statement that does not stay inside the copy ' + target + ': ' + statement,
1051
- );
1052
- }
1053
- return statements;
1054
- }
1055
-
1056
- /**
1057
- * Builds the schema again, in a database we own.
1058
- *
1059
- * Order matters: tables, then constraints, then grants, then policies. A policy
1060
- * cannot be created before the table it guards, and a grant given after a
1061
- * policy would widen access the original did not have.
1062
- */
1063
- async function writeSchema(client, plan, target) {
1064
- const statements = ['CREATE SCHEMA ' + quote(target)];
1065
-
1066
- // The app's own types first: a column, a check, a view or a cast can all
1067
- // name one, and nothing that mentions a type can be created before it.
1068
- for (const made of plan.types || []) {
1069
- if (made.kind === 'e') {
1070
- statements.push(
1071
- 'CREATE TYPE ' + quote(target) + '.' + quote(made.name) + ' AS ENUM (' +
1072
- (made.labels || []).map((label) => "'" + String(label).split("'").join("''") + "'").join(', ') + ')',
1073
- );
1074
- } else {
1075
- const here = (text) => rewriteSchemaRefs(text, plan.schema, target);
1076
- // A domain can stand on another of the app's own types, and its rule can
1077
- // name one too, so both go through the same rewrite a column does.
1078
- const parts = ['CREATE DOMAIN ' + quote(target) + '.' + quote(made.name) + ' AS ' + here(made.base_type)];
1079
- if (made.default_value) parts.push('DEFAULT ' + here(made.default_value));
1080
- if (made.not_null) parts.push('NOT NULL');
1081
- for (const rule of made.constraints || []) parts.push(here(rule));
1082
- statements.push(parts.join(' '));
1083
- }
1084
- }
1085
-
1086
- for (const table of plan.tables) {
1087
- const columns = table.columns.map((column) => {
1088
- // The type is rewritten like everything else now that the copy has its
1089
- // own. Left alone, the copy leaned on the original for them.
1090
- const parts = [quote(column.name), rewriteSchemaRefs(column.type, plan.schema, target)];
1091
- const fallback = rewriteSchemaRefs(column.default_expr, plan.schema, target);
1092
- if (column.generated) {
1093
- // A stored generated column carries its expression in default_expr, and
1094
- // replaying that as a DEFAULT is a syntax error - "cannot use column
1095
- // reference in DEFAULT expression" - which took the whole scan down.
1096
- parts.push('GENERATED ALWAYS AS (' + fallback + ') STORED');
1097
- } else if (column.identity) {
1098
- parts.push('GENERATED ALWAYS AS IDENTITY');
1099
- } else if (fallback) {
1100
- parts.push('DEFAULT ' + fallback);
1101
- }
1102
- if (column.not_null && !column.generated && !column.identity) parts.push('NOT NULL');
1103
- return parts.join(' ');
1104
- });
1105
-
1106
- // Sequences first, or a serial column's default has nothing to point at.
1107
- for (const column of table.columns) {
1108
- const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
1109
- if (!match) continue;
1110
- const bare = match[1].split('.').pop().split('"').join('');
1111
- statements.push('CREATE SEQUENCE IF NOT EXISTS ' + quote(target) + '.' + quote(bare));
1112
- }
1113
-
1114
- statements.push(
1115
- 'CREATE TABLE ' + quote(target) + '.' + quote(table.name) + ' (' + columns.join(', ') + ')',
1116
- );
1117
-
1118
- // And the sequence belongs to its column, the way serial makes it.
1119
- //
1120
- // Not tidiness. A sequence a column owns is linked to it in pg_depend,
1121
- // and that link is how the grants on it are found again - so a copy
1122
- // whose sequences stand loose reads back as having no sequence grants
1123
- // at all, however many were replayed onto it. It also means the
1124
- // sequence goes when the copy's table goes, which is what the original
1125
- // does.
1126
- for (const column of table.columns) {
1127
- const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
1128
- if (!match) continue;
1129
- const bare = match[1].split('.').pop().split('"').join('');
1130
- statements.push(
1131
- 'ALTER SEQUENCE ' + quote(target) + '.' + quote(bare) + ' OWNED BY ' +
1132
- quote(target) + '.' + quote(table.name) + '.' + quote(column.name),
1133
- );
1134
- }
1135
- }
1136
-
1137
- // Stand-ins for the tables outside this schema that its foreign keys point
1138
- // at, with two rows already in them so seeding has something to reference.
1139
- // Built before the constraints, because a foreign key cannot be added to a
1140
- // table that is not there yet.
1141
- for (const outside of plan.external || []) {
1142
- const stub = quote(target) + '.' + quote(outside.stub);
1143
- const columns = outside.columns.map((column) => quote(column.name) + ' ' + column.type + ' NOT NULL');
1144
- const keyed = outside.columns.map((column) => quote(column.name)).join(', ');
1145
- statements.push('CREATE TABLE ' + stub + ' (' + columns.join(', ') + ', PRIMARY KEY (' + keyed + '))');
1146
- for (let nth = 0; nth < IDENTITIES.length; nth++) {
1147
- const values = outside.columns.map((column) => stubValue(column.type, nth));
1148
- statements.push('INSERT INTO ' + stub + ' VALUES (' + values.join(', ') + ')');
1149
- }
1150
- }
1151
-
1152
- // Keys and uniques across every table first, then the foreign keys.
1153
- // Constraints used to be added table by table, so a foreign key on `orders`
1154
- // was created before the primary key on `profiles` existed and Postgres
1155
- // refused it: "no unique constraint matching given keys". A foreign key can
1156
- // only be added once the thing it points at is already unique.
1157
- for (const wantForeign of [false, true]) {
1158
- for (const table of plan.tables) {
1159
- for (const constraint of table.constraints) {
1160
- const isForeign = constraint.kind === 'f';
1161
- if (isForeign !== wantForeign) continue;
1162
- const definition = qualifyOwnRefs(
1163
- rewriteExternalRefs(
1164
- rewriteSchemaRefs(constraint.definition, plan.schema, target),
1165
- plan.external,
1166
- target,
1167
- ),
1168
- plan,
1169
- target,
1170
- );
1171
- statements.push(
1172
- 'ALTER TABLE ' + quote(target) + '.' + quote(table.name) +
1173
- ' ADD CONSTRAINT ' + quote(constraint.name) + ' ' + definition,
1174
- );
1175
- }
1176
- }
1177
- }
1178
-
1179
- // Unique indexes, once every table exists. The schema name inside the
1180
- // definition is rewritten for the same reason a foreign key's was: Postgres
1181
- // writes it unquoted, and every single index definition carries it. Replayed
1182
- // as-is, the copy would build its indexes on the customer's real tables.
1183
- for (const index of plan.indexes || []) {
1184
- statements.push(rewriteSchemaRefs(index.definition, plan.schema, target));
1185
- }
1186
-
1187
- // The helpers the rules and views call, after the tables their bodies read
1188
- // and before anything that calls them.
1189
- const copied = functionsToCopy(plan);
1190
- const copiedNames = Array.from(new Set(copied.map((f) => f.name)));
1191
- // The app's own types move with them: a copied helper taking app.role_t[]
1192
- // has to take the copy's role_t, or the copy's columns cannot be compared
1193
- // with its arguments.
1194
- const movedNames = copiedNames.concat((plan.types || []).map((t) => t.name));
1195
- for (const fn of copied) {
1196
- statements.push(copyFunctionStatement(fn, plan, target, movedNames));
1197
- // Who may call it, as in the original. Found by the blocked-read check: a
1198
- // helper the original had closed to anon and authenticated came out of
1199
- // the copy open to everyone, and a rule that cannot be evaluated was
1200
- // reported as evaluated.
1201
- if (!fn.default_acl) {
1202
- const signature = quote(target) + '.' + quote(fn.name) + '(' + rewriteOwnTableRefs(fn.args, plan, target, movedNames) + ')';
1203
- statements.push('REVOKE ALL ON FUNCTION ' + signature + ' FROM PUBLIC');
1204
- for (const who of fn.executors || []) {
1205
- statements.push('GRANT EXECUTE ON FUNCTION ' + signature + ' TO ' + (who === 'PUBLIC' ? 'PUBLIC' : quote(who)));
1206
- }
1207
- }
1208
- }
1209
-
1210
- // Views last, because they read from the tables above. Copied rather than
1211
- // skipped because a view is a way into a table: it runs with its creator
1212
- // rights unless it says security_invoker, so a view over a protected table
1213
- // hands out every row in it. Leaving views out meant never looking at that
1214
- // door at all.
1215
- for (const view of plan.views || []) {
1216
- const body = rewriteExternalRefs(rewriteSchemaRefs(view.definition, plan.schema, target), plan.external, target);
1217
- const options = view.options ? ' WITH (' + view.options + ')' : '';
1218
- statements.push(
1219
- 'CREATE ' + (view.materialised ? 'MATERIALIZED VIEW ' : 'VIEW ') +
1220
- quote(target) + '.' + quote(view.name) + options + ' AS ' + body,
1221
- );
1222
- }
1223
-
1224
- for (const grant of plan.viewGrants || []) {
1225
- const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
1226
- statements.push(
1227
- 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
1228
- );
1229
- }
1230
-
1231
- // The copy needs the roles PostgREST switches into to be able to reach it.
1232
- // Granted on the copy's own schema and nowhere else.
1233
- //
1234
- // What used to be here, and must never come back: CREATE SCHEMA auth,
1235
- // CREATE OR REPLACE FUNCTION auth.uid(), and GRANT USAGE ON SCHEMA auth.
1236
- // Those ran against the customer's live database. The first overwrote their
1237
- // own authentication function; the third opened a schema they may have
1238
- // deliberately closed. The policies reference auth.uid() and that is fine -
1239
- // calling their function is a read, and a read is all we are ever allowed.
1240
- // If it is missing, their app does not work either, and the copy failing to
1241
- // build says so honestly instead of papering over it.
1242
- for (const role of await existingRoles(client, ['anon', 'authenticated'])) {
1243
- statements.push('GRANT USAGE ON SCHEMA ' + quote(target) + ' TO ' + quote(role));
1244
- }
1245
-
1246
- for (const grant of plan.grants) {
1247
- const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
1248
- statements.push(
1249
- 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
1250
- );
1251
- }
1252
-
1253
- // The sequences, on the same terms as the tables. A table grant without
1254
- // the sequence grant that goes with it is a copy nobody can insert into.
1255
- for (const grant of plan.sequenceGrants || []) {
1256
- const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
1257
- statements.push(
1258
- 'GRANT ' + grant.privilege_type + ' ON SEQUENCE ' + quote(target) + '.' +
1259
- quote(grant.sequence_name) + ' TO ' + who,
1260
- );
1261
- }
1262
-
1263
- for (const table of plan.tables) {
1264
- if (table.rlsEnabled) {
1265
- statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' ENABLE ROW LEVEL SECURITY');
1266
- }
1267
- if (table.rlsForced) {
1268
- statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' FORCE ROW LEVEL SECURITY');
1269
- }
1270
- }
1271
-
1272
- for (const policy of plan.policies) {
1273
- const roles = roleList(policy.roles).join(', ') || 'PUBLIC';
1274
- const parts = [
1275
- 'CREATE POLICY ' + quote(policy.name),
1276
- 'ON ' + quote(target) + '.' + quote(policy.table_name),
1277
- 'AS ' + (policy.permissive === 'PERMISSIVE' ? 'PERMISSIVE' : 'RESTRICTIVE'),
1278
- 'FOR ' + policy.cmd,
1279
- 'TO ' + roles,
1280
- ];
1281
- if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target, movedNames) + ')');
1282
- if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target, movedNames) + ')');
1283
- statements.push(parts.join(' '));
1284
- }
1285
-
1286
- mustStayInside(statements, target);
1287
- // Built with the copy first on the search_path, so a bare name binds to the
1288
- // copy and not to whatever the customer happens to have.
1289
- //
1290
- // This is the structural half, and it went in after chasing the same bug
1291
- // through three different kinds of expression. Postgres writes a name
1292
- // without its schema whenever that name is already reachable - so a real app
1293
- // in `public` hands back "REFERENCES profiles(id)" and
1294
- // "nextval('orders_id_seq'::regclass)", and a rewrite that goes looking for
1295
- // a schema name finds nothing to change in either. Replayed into the copy,
1296
- // both bound to the customer's own objects: the foreign keys pointed at
1297
- // their tables, and the copy drew its keys from their sequences, which
1298
- // advanced them.
1299
- //
1300
- // Rewriting each kind of expression in turn is a game with no end - index
1301
- // predicates, checks that call a function, view bodies. Naming the copy
1302
- // first on the path ends all of them at once, because it changes what a
1303
- // bare name means rather than trying to find every place one can appear.
1304
- //
1305
- // `public` stays on the path, after the copy, because the copy legitimately
1306
- // needs what lives there - gen_random_uuid() and the like. `extensions` is
1307
- // where Supabase keeps them; a schema on the path that does not exist is
1308
- // ignored rather than an error.
1309
- const { rows: pathRows } = await client.query('SHOW search_path');
1310
- const restoreTo = pathRows[0].search_path;
1311
- await client.query('SET search_path TO ' + quote(target) + ', public, extensions');
1312
- try {
1313
- for (const statement of statements) {
1314
- await client.query(statement);
1315
- }
1316
- } finally {
1317
- await client.query('SET search_path TO ' + restoreTo).catch(() => {});
1318
- }
1319
-
1320
- // And then look at what was actually built, rather than at what was meant.
1321
- //
1322
- // Everything above this line reasons about strings. The guard at the top of
1323
- // this file reads the statements before they run; this one asks Postgres
1324
- // where the copy ended up pointing, which is the only account of it that
1325
- // cannot be fooled by a spelling nobody anticipated - and one was not. A
1326
- // bare "REFERENCES profiles(id)" replayed into the copy bound to the
1327
- // customer's own table, silently, on every app that lives in `public`.
1328
- //
1329
- // Nothing is written to a table outside the copy either way. The damage is
1330
- // subtler than that: a copy tied to the customer's rows is not the app being
1331
- // attacked, and every verdict taken from it is about something else.
1332
- // Foreign keys AND column defaults, because the second one is where this
1333
- // hid after the first was closed: a serial column's default is
1334
- // nextval('<sequence>'), and the copy was calling the customer's. Nothing
1335
- // was written to their tables, but nextval advances a sequence, so their
1336
- // database changed - and "we never touch your live app" says at all.
1337
- //
1338
- // Asked of pg_depend rather than of the text, so it holds for any
1339
- // expression that ends up pointing at a relation, not only the ones anybody
1340
- // thought to look at. pg_catalog is excluded because everything depends on
1341
- // it; anything else outside the copy is the bug.
1342
- const { rows: strays } = await client.query(
1343
- `SELECT con.conname AS what, rn.nspname AS points_at
1344
- FROM pg_constraint con
1345
- JOIN pg_class cl ON cl.oid = con.conrelid
1346
- JOIN pg_namespace cn ON cn.oid = cl.relnamespace
1347
- JOIN pg_class rc ON rc.oid = con.confrelid
1348
- JOIN pg_namespace rn ON rn.oid = rc.relnamespace
1349
- WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1
1350
- UNION ALL
1351
- SELECT cl.relname || '.' || a.attname || ' default' AS what,
1352
- rn.nspname || '.' || rc.relname AS points_at
1353
- FROM pg_depend d
1354
- JOIN pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_attrdef'::regclass
1355
- JOIN pg_class cl ON cl.oid = ad.adrelid
1356
- JOIN pg_namespace cn ON cn.oid = cl.relnamespace
1357
- JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
1358
- JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
1359
- JOIN pg_namespace rn ON rn.oid = rc.relnamespace
1360
- WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'
1361
- UNION ALL
1362
- -- And the rules. A policy that reads another table - "items of my
1363
- -- orders" - has to read the copy's orders; one reading the customer's
1364
- -- would evaluate the attack against their real rows. The text comparison
1365
- -- further down never caught this for an app in public: a reference to
1366
- -- public.orders prints as plain "orders" either way. Postgres records
1367
- -- every relation a policy expression uses, so ask it.
1368
- SELECT pol.polname || ' rule on ' || cl.relname AS what,
1369
- rn.nspname || '.' || rc.relname AS points_at
1370
- FROM pg_depend d
1371
- JOIN pg_policy pol ON pol.oid = d.objid AND d.classid = 'pg_policy'::regclass
1372
- JOIN pg_class cl ON cl.oid = pol.polrelid
1373
- JOIN pg_namespace cn ON cn.oid = cl.relnamespace
1374
- JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
1375
- JOIN pg_namespace rn ON rn.oid = rc.relnamespace
1376
- WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'`,
1377
- [target],
1378
- );
1379
- if (strays.length) {
1380
- throw new Error(
1381
- 'the copy points outside itself, so it is not the app: ' +
1382
- strays.map((row) => row.what + ' -> ' + row.points_at).join(', '),
1383
- );
1384
- }
1385
-
1386
- return statements;
1387
- }
1388
-
1389
- /* --------------------------------------------------------------------------
1390
- Checking the copy is the original.
1391
- -------------------------------------------------------------------------- */
1392
-
1393
- /** A piece of SQL with its own schema name taken off, quoted or not. */
1394
- function withoutSchema(text, plan) {
1395
- return String(text == null ? '' : text)
1396
- .split(quote(plan.schema) + '.').join('')
1397
- .split(plan.schema + '.').join('');
1398
- }
1399
-
1400
- /**
1401
- * Where a copy differs from what it was copied from.
1402
- *
1403
- * Anything here means the verdicts that follow are about the wrong database,
1404
- * so this returning empty is a precondition for attacking, not a nicety.
1405
- */
1406
- function diffSchemas(source, copy) {
1407
- const differences = [];
1408
-
1409
- // The stand-ins exist only in the copy, by design. Comparing them against
1410
- // an original that never had them would report every Supabase app as a
1411
- // copy that came out wrong.
1412
- const standIns = new Set((source.external || []).map((e) => e.stub));
1413
- const mine = (list) => list.filter((t) => !standIns.has(t.name));
1414
- const named = (list) => mine(list).map((t) => t.name).sort().join(', ');
1415
- if (named(source.tables) !== named(copy.tables)) {
1416
- differences.push('tables differ: ' + named(source.tables) + ' vs ' + named(copy.tables));
1417
- }
1418
-
1419
- for (const table of source.tables) {
1420
- const mirror = copy.tables.find((t) => t.name === table.name);
1421
- if (!mirror) continue;
1422
-
1423
- if (table.rlsEnabled !== mirror.rlsEnabled) {
1424
- differences.push(
1425
- table.name + ': row level security is ' + (table.rlsEnabled ? 'on' : 'off') +
1426
- ' but the copy has it ' + (mirror.rlsEnabled ? 'on' : 'off'),
1427
- );
1428
- }
1429
- if (table.rlsForced !== mirror.rlsForced) {
1430
- differences.push(table.name + ': forced row level security does not match');
1431
- }
1432
-
1433
- // The schema name comes off the type first. A column of the app's own enum
1434
- // reads as app.order_status in the original and as <copy>.order_status in
1435
- // the copy - the same type, built twice, and calling that a difference
1436
- // would stop every scan of an app that has one.
1437
- const shape = (plan, t) =>
1438
- t.columns.map((c) => c.name + ' ' + withoutSchema(c.type, plan) + (c.not_null ? ' NOT NULL' : '')).join(' | ');
1439
- if (shape(source, table) !== shape(copy, mirror)) {
1440
- differences.push(table.name + ' columns differ:\n ' + shape(source, table) + '\n ' + shape(copy, mirror));
1441
- }
1442
- }
1443
-
1444
- // The app's own types. An enum that arrived with a label missing narrows
1445
- // what the attacks are able to insert, and nothing else here would notice.
1446
- const typeText = (plan) =>
1447
- (plan.types || [])
1448
- .map((made) =>
1449
- made.name + ' ' + made.kind + ' ' + (made.labels || []).join(',') + ' ' +
1450
- withoutSchema(made.base_type, plan) + ' ' +
1451
- (made.constraints || []).map((rule) => withoutSchema(rule, plan)).join(' '))
1452
- .sort();
1453
- const sourceTypes = typeText(source);
1454
- const copyTypes = typeText(copy);
1455
- for (const made of sourceTypes) {
1456
- if (!copyTypes.includes(made)) differences.push('a type did not come across whole: ' + made);
1457
- }
1458
-
1459
- // The sequence grants, for the same reason they are copied at all: without
1460
- // them nothing can insert, and every write attack reads as the app
1461
- // defending itself.
1462
- const sequenceText = (plan) =>
1463
- (plan.sequenceGrants || [])
1464
- .map((g) => g.sequence_name + ' ' + g.grantee + ' ' + g.privilege_type)
1465
- .sort();
1466
- const sourceSequences = sequenceText(source);
1467
- const copySequences = sequenceText(copy);
1468
- for (const one of sourceSequences) {
1469
- if (!copySequences.includes(one)) {
1470
- differences.push('a sequence grant did not come across: ' + one);
1471
- }
1472
- }
1473
-
1474
- // Uniqueness decides whether the Collision attack has anything to report, so
1475
- // a unique index that failed to come across has to be caught here rather
1476
- // than turn into a confident finding about a table that was actually fine.
1477
- // The schema name is stripped before comparing, since it differs by design.
1478
- const indexText = (plan) =>
1479
- (plan.indexes || [])
1480
- .map((index) => withoutSchema(index.definition, plan))
1481
- .sort();
1482
- const sourceIndexes = indexText(source);
1483
- const copyIndexes = indexText(copy);
1484
- if (sourceIndexes.length !== copyIndexes.length) {
1485
- differences.push(
1486
- 'the copy has ' + copyIndexes.length + ' unique indexes, the original has ' + sourceIndexes.length,
1487
- );
1488
- }
1489
- for (let i = 0; i < Math.max(sourceIndexes.length, copyIndexes.length); i++) {
1490
- if (sourceIndexes[i] !== copyIndexes[i]) {
1491
- differences.push('a unique index came across changed:\n ' + sourceIndexes[i] + '\n ' + copyIndexes[i]);
1492
- }
1493
- }
1494
-
1495
- // The policies matter most, so they are compared word for word - except for
1496
- // the schema names, which differ by design. A rule that looks at another of
1497
- // the app's own tables ("items of my orders": EXISTS (SELECT 1 FROM orders
1498
- // ...)) comes back from the copy naming the copy's orders, kn_xxx.orders,
1499
- // which is exactly right. Compared raw, that read as "the copy came out
1500
- // changed" and the whole app went unscanned - found on a test app built to
1501
- // look like a Lovable shop, where this rule is ordinary. And a rule calling
1502
- // one of the app's functions keeps naming the original schema in both,
1503
- // because functions are not copied - so both names are taken out of both
1504
- // sides. Where a rule actually points - the copy's table or the customer's -
1505
- // is not left to this text: writeSchema asks pg_depend, and refuses a copy
1506
- // whose rules read anything outside it.
1507
- const bothSchemas = (text) => withoutSchema(withoutSchema(text, source), copy);
1508
- const asText = (list) =>
1509
- list
1510
- .map((p) =>
1511
- [p.table_name, p.name, p.permissive, roleList(p.roles).join('+'), p.cmd,
1512
- bothSchemas(p.qual), bothSchemas(p.with_check)]
1513
- .map((x) => String(x === null || x === undefined ? '' : x))
1514
- .join(' :: '),
1515
- )
1516
- .sort();
1517
-
1518
- const sourcePolicies = asText(source.policies);
1519
- const copyPolicies = asText(copy.policies);
1520
- if (sourcePolicies.length !== copyPolicies.length) {
1521
- differences.push('the copy has ' + copyPolicies.length + ' policies, the original has ' + sourcePolicies.length);
1522
- }
1523
- for (let i = 0; i < Math.max(sourcePolicies.length, copyPolicies.length); i++) {
1524
- if (sourcePolicies[i] !== copyPolicies[i]) {
1525
- differences.push('a policy came across changed:\n ' + sourcePolicies[i] + '\n ' + copyPolicies[i]);
1526
- }
1527
- }
1528
-
1529
- return differences;
1530
- }
1531
-
1532
- module.exports = {
1533
- readSchema: readSchema,
1534
- readAnonDefinerFunctions: readAnonDefinerFunctions,
1535
- guardedAtTheDoor: guardedAtTheDoor,
1536
- doorOf: doorOf,
1537
- writeSchema: writeSchema,
1538
- diffSchemas: diffSchemas,
1539
- readPolicies: readPolicies,
1540
- readBuckets: readBuckets,
1541
- readTypes: readTypes,
1542
- readSequenceGrants: readSequenceGrants,
1543
- readIndexes: readIndexes,
1544
- readViews: readViews,
1545
- readExternalTargets: readExternalTargets,
1546
- referenceIn: referenceIn,
1547
- rewriteSchemaRefs: rewriteSchemaRefs,
1548
- qualifyOwnRefs: qualifyOwnRefs,
1549
- functionsToCopy: functionsToCopy,
1550
- copyFunctionStatement: copyFunctionStatement,
1551
- stubNameFor: stubNameFor,
1552
- IDENTITIES: IDENTITIES,
1553
- quote: quote,
1554
- roleList: roleList,
1555
- };
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
+ /**
210
+ * Whether a function turns the caller away before it does anything.
211
+ *
212
+ * Found on a blind test: create_admin_export opens with
213
+ * if not public.is_org_admin(p_org_id) then raise exception ...
214
+ * and was reported as "anyone can call it, with full rights". A visitor with
215
+ * no account is not an admin of anything, so the first line refuses them and
216
+ * nothing else runs. That is a guard, and the function is examined, not
217
+ * reported.
218
+ *
219
+ * Only a refusal counts - IF NOT <check> THEN RAISE - and only one that comes
220
+ * before the first write and the first RETURN, because a check made after the
221
+ * work is done guards nothing. The check has to ask about the caller: a call
222
+ * to one of the schema's yes/no helpers that ask auth.uid(), or a condition
223
+ * of its own that asks auth.uid() whether they are an admin, owner or member.
224
+ * "Is anybody signed in at all" is not enough, and neither is a check on an
225
+ * argument the caller chose.
226
+ */
227
+ function guardedAtTheDoor(body, helpers) {
228
+ return doorOf(body, helpers).guarded;
229
+ }
230
+
231
+ /**
232
+ * The same reading, and one more answer: whether the only check at the door
233
+ * is one a caller with no role at all slips past.
234
+ *
235
+ * Found on a blind test (HarborLine): complete_automation_run opened with
236
+ * if public.org_role(v_org) not in ('owner', 'admin') then raise ...
237
+ * org_role answers with the caller's role there - and with NULL for somebody
238
+ * who has none. NULL NOT IN (...) is not true, so the IF never raises, and a
239
+ * stranger completed another organization's runs. A role helper's check is a
240
+ * guard only when it is written so NULL cannot pass - coalesce(...), or
241
+ * IS NULL OR ... - and otherwise it is said, by name, why it is not one.
242
+ *
243
+ * `helpers` are names (yes/no helpers about the caller) or { name, nullable }
244
+ * (a role helper, which answers NULL for a stranger).
245
+ *
246
+ * "Is anybody signed in at all" - IF auth.uid() IS NULL THEN RAISE - counts
247
+ * only when the function takes nothing that could point at somebody else's
248
+ * row (`args`, as pg_get_function_identity_arguments says them). Found on a
249
+ * blind test (Meridian): bootstrap_organization(p_name text) opened with it,
250
+ * made a new organization owned by the caller, and was reported as "anyone
251
+ * on the internet can call it". A visitor with no account is turned away, and
252
+ * a signed-in one can only make their own. Given an organization's id, the
253
+ * same line would let any stranger who signed up act on that organization,
254
+ * so there it is still no guard.
255
+ */
256
+ function namesSomebody(args) {
257
+ if (args === undefined || args === null) return true;
258
+ return String(args).split(',').map((a) => a.trim()).filter(Boolean).some((a) => {
259
+ const words = a.replace(/^(in|inout|variadic)\s+/i, '').split(/\s+/);
260
+ const name = words.length > 1 ? words[0].replace(/"/g, '') : '';
261
+ const type = words.slice(words.length > 1 ? 1 : 0).join(' ');
262
+ return /\b(uuid|oid|int[248]?|integer|bigint|smallint|numeric|serial|bigserial)\b/i.test(type) ||
263
+ /(^|_)(ids?|uids?|orgs?|organizations?|tenants?|teams?|workspaces?|accounts?|users?|owners?|members?|projects?|customers?|compan(y|ies))$/i.test(name);
264
+ });
265
+ }
266
+
267
+ function doorOf(body, helpers, args) {
268
+ const text = String(body || '');
269
+ const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
270
+ const returns = /\breturn\b/i.exec(text);
271
+ const firstAct = Math.min(writes ? writes.index : Infinity, returns ? returns.index : Infinity);
272
+ // The condition may not run past a THEN, or two IFs would be read as one;
273
+ // and END IF is not the start of one.
274
+ const guard = /(?<!\bend\s+)\bif\b((?:(?!\bthen\b)[\s\S])*?)\bthen\s+raise\b(?!\s+(?:notice|warning|info|log|debug)\b)/gi;
275
+ // The name as a call, bare or schema-qualified (public.is_org_admin), and
276
+ // never as the tail of a longer name.
277
+ const asks = (cond, name) => new RegExp('(^|[^\\w$"])"?' +
278
+ name.replace(/[$]/g, '\\$') + '"?\\s*\\(', 'i').test(cond);
279
+ const list = (helpers || []).map((h) => (typeof h === 'string' ? { name: h, nullable: false } : h));
280
+ let slips = false;
281
+ let found;
282
+ while ((found = guard.exec(text)) !== null) {
283
+ if (found.index > firstAct) break;
284
+ const cond = found[1].trim();
285
+ // Refusing when the check fails, not when it passes.
286
+ const refusesOnNo = /^not\b/i.test(cond) || /\bis\s+(not\s+true|false)\s*$/i.test(cond) || /=\s*false\s*$/i.test(cond);
287
+ if (refusesOnNo && list.some((h) => !h.nullable && asks(cond, h.name))) return { guarded: true, nullSlips: false };
288
+ if (refusesOnNo && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) {
289
+ return { guarded: true, nullSlips: false };
290
+ }
291
+ const signedIn = /^\(?\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+null\s*\)?$/i.test(cond) ||
292
+ /^not\s*\(\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+not\s+null\s*\)$/i.test(cond);
293
+ if (signedIn && !namesSomebody(args)) return { guarded: true, nullSlips: false };
294
+ if (list.some((h) => h.nullable && asks(cond, h.name))) {
295
+ const refuses = refusesOnNo || /\bnot\s+in\s*\(/i.test(cond) || /<>|!=/.test(cond);
296
+ const nullSafe = /\bcoalesce\s*\(/i.test(cond) || /\bis\s+null\b/i.test(cond) || /\bis\s+not\s+true\s*$/i.test(cond);
297
+ if (refuses && nullSafe) return { guarded: true, nullSlips: false };
298
+ if (refuses) slips = true;
299
+ }
300
+ }
301
+ return { guarded: false, nullSlips: slips };
302
+ }
303
+
304
+ // Schemas that are the platform's, not the app's: Postgres's own and
305
+ // Supabase's. A definer function in any other schema is one the app wrote, and
306
+ // is read with the target's (blind round 17, DB-04: one sat in a second
307
+ // schema of the app's and was never looked at). Mirrored in engine.sql
308
+ // read_anon_definer_functions - the two lists are one list.
309
+ const PLATFORM_SCHEMAS = ['information_schema', 'auth', 'storage', 'realtime', '_realtime', 'graphql', 'graphql_public',
310
+ 'extensions', 'supabase_functions', 'supabase_migrations', 'vault', 'pgsodium', 'pgsodium_masks', 'net', 'cron',
311
+ 'pgbouncer', '_analytics', 'pgtle', 'topology', 'tiger', 'tiger_data'];
312
+ // This tool's own schemas: a copy (kn_<moment>), an engine, a stand-in.
313
+ const OWN_SCHEMA = '^kn_([0-9a-z]+|engine_[0-9a-z]+|ext__.*)$';
314
+
315
+ // "auth" is Supabase's only on Supabase. A plain Postgres app that copies its
316
+ // shape writes its own auth schema, and a definer function there is the app's
317
+ // (blind round 17, DB-04: auth.find_user_for_login, callable by anon, handed
318
+ // out password hashes and was never read). Supabase is known by its own role,
319
+ // supabase_auth_admin, which every project has and no plain app does.
320
+ // Mirrored in engine.sql read_anon_definer_functions.
321
+ async function definerPlatformSchemas(client) {
322
+ const { rows } = await client.query("SELECT 1 FROM pg_roles WHERE rolname = 'supabase_auth_admin'");
323
+ return rows.length ? PLATFORM_SCHEMAS : PLATFORM_SCHEMAS.filter((name) => name !== 'auth');
324
+ }
325
+
326
+ async function readAnonDefinerFunctions(client, schema, role) {
327
+ const caller = role || 'anon';
328
+ // A plain Postgres with no anon role has no anonymous caller at all, so
329
+ // nothing is callable by it - and has_function_privilege() on a role that
330
+ // does not exist throws, which took the whole scan down with it before this
331
+ // was checked first.
332
+ const exists = await client.query('SELECT 1 FROM pg_roles WHERE rolname = $1', [caller]);
333
+ const platform = await definerPlatformSchemas(client);
334
+ // The target's own functions, and every definer function the app wrote in
335
+ // any other schema - named with that schema (other.fn), so the report and
336
+ // the fix point at the right one. Not an extension's, never the platform's
337
+ // or this tool's own.
338
+ const appSchema = `(n.nspname = $1 OR (n.nspname NOT LIKE 'pg\\_%' AND n.nspname <> ALL ($2::text[])
339
+ AND n.nspname !~ $3
340
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d
341
+ WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e')))`;
342
+ const { rows } = await client.query(
343
+ `SELECT CASE WHEN n.nspname = $1 THEN p.proname ELSE n.nspname || '.' || p.proname END AS name,
344
+ pg_get_function_identity_arguments(p.oid) AS args,
345
+ p.proconfig::text AS config,
346
+ pg_get_functiondef(p.oid) AS def,
347
+ p.prorettype = 'pg_catalog.bool'::regtype AS returns_bool,
348
+ (SELECT t.typtype = 'e' FROM pg_type t WHERE t.oid = p.prorettype) AS returns_enum,
349
+ ${exists.rows.length ? "has_function_privilege($4, p.oid, 'EXECUTE')" : 'false'} AS callable,
350
+ CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
351
+ THEN has_function_privilege('authenticated', p.oid, 'EXECUTE') ELSE false END AS callable_signed_in
352
+ FROM pg_proc p
353
+ JOIN pg_namespace n ON n.oid = p.pronamespace
354
+ WHERE ${appSchema}
355
+ AND p.prosecdef
356
+ AND p.prorettype <> 'pg_catalog.trigger'::regtype
357
+ ORDER BY n.nspname <> $1, n.nspname, p.proname`,
358
+ exists.rows.length ? [schema, platform, OWN_SCHEMA, caller] : [schema, platform, OWN_SCHEMA],
359
+ );
360
+ // The schema's helpers about the caller - is_org_admin(org) asking whether
361
+ // auth.uid() is an admin of it, org_role(org) answering with their role -
362
+ // definer or not, since a guard can call either. Read once, for doorOf. A
363
+ // role helper answers NULL for somebody with no role, so it is marked.
364
+ const { rows: yesNo } = await client.query(
365
+ `SELECT p.proname AS name, pg_get_functiondef(p.oid) AS def,
366
+ p.prorettype <> 'pg_catalog.bool'::regtype AS nullable
367
+ FROM pg_proc p
368
+ JOIN pg_namespace n ON n.oid = p.pronamespace
369
+ JOIN pg_type t ON t.oid = p.prorettype
370
+ WHERE ${appSchema} AND p.prokind = 'f' AND (p.prorettype = 'pg_catalog.bool'::regtype OR t.typtype = 'e')
371
+ ORDER BY p.proname`,
372
+ [schema, platform, OWN_SCHEMA],
373
+ );
374
+ const helpers = yesNo.filter((row) => {
375
+ const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
376
+ return /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body) &&
377
+ !/\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
378
+ }).map((row) => ({ name: row.name, nullable: row.nullable === true }));
379
+ return rows.map((row) => {
380
+ // A definer function with no search_path pinned is a second, separate
381
+ // hazard: the caller can set their own search_path and make the function
382
+ // resolve to their objects. Said as part of the same finding.
383
+ const config = row.config || '';
384
+ const hasFixedSearchPath = /(^|,)search_path=/i.test(config.replace(/[{}"]/g, ''));
385
+ // The body is only used to say, in the report, whether it writes - which
386
+ // decides how the finding is worded, not whether it is reported. A comment
387
+ // could fool this, and that is acceptable: it never turns a non-finding
388
+ // into a finding, only "can read" into "can change".
389
+ const body = String(row.def || '').replace(/--[^\n]*/g, ' ');
390
+ // `update\s+\w` followed by \b matched only a one-letter table name, so
391
+ // UPDATE was never seen - archive_notebook was worded as reading. The
392
+ // whole name, quoted or not, is matched now.
393
+ const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.test(body);
394
+ // A yes/no about the caller alone - is_org_member(org) asking whether
395
+ // auth.uid() belongs to it. Found on a blind test, where two such helpers
396
+ // were reported as "anyone can call it, with full rights": they are how
397
+ // row level security avoids checking a table against itself, and to a
398
+ // visitor with no account they answer no. Reading nothing about anybody
399
+ // else and writing nothing, they are not a reach. A yes/no that does not
400
+ // ask auth.uid() - is_admin(user_id) - answers about other people, and
401
+ // stays a finding.
402
+ //
403
+ // A role about the caller alone is the same: org_role(org), the caller's
404
+ // own role there, from a fixed list of labels (an enum) that can hold
405
+ // nothing about anybody else. Found on a blind test (HarborLine), where
406
+ // org_role was reported beside the functions it guards.
407
+ const aboutCaller = (row.returns_bool === true || row.returns_enum === true) && !writes &&
408
+ /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
409
+ const door = doorOf(body, helpers, row.args || '');
410
+ return {
411
+ name: row.name,
412
+ args: row.args || '',
413
+ writes: writes,
414
+ aboutCaller: aboutCaller,
415
+ guarded: door.guarded,
416
+ nullSlips: door.nullSlips,
417
+ hasFixedSearchPath: hasFixedSearchPath,
418
+ callable: row.callable === true,
419
+ // Signed-in users too. A definer function only they can call is the
420
+ // ordinary Supabase RPC and is not a privileged finding - but one that
421
+ // leaves its search_path open is still worth a look (scan.js searchPathsOf).
422
+ callableSignedIn: row.callable_signed_in === true,
423
+ };
424
+ });
425
+ }
426
+
427
+ /**
428
+ * The app's own SQL and PL/pgSQL functions, with their full definitions.
429
+ *
430
+ * Read so the helpers a rule calls can be built inside the copy. Found on a
431
+ * blind test: a rule called has_org_role(org_id, ARRAY[...]::org_role[]). The
432
+ * copy has its own org_role, the helper stayed in the original schema taking
433
+ * the original's, and no rule could be created - the scan stopped before it
434
+ * began. Left in the original, a helper also answers from the original's
435
+ * tables rather than from the copy's.
436
+ */
437
+ async function readFunctions(client, schema) {
438
+ const { rows } = await client.query(
439
+ `SELECT p.proname AS name,
440
+ l.lanname AS language,
441
+ p.prosrc AS src,
442
+ pg_get_functiondef(p.oid) AS def,
443
+ pg_get_function_identity_arguments(p.oid) AS args,
444
+ p.proacl IS NULL AS default_acl,
445
+ -- Installed by an extension (pgcrypto's crypt(), digest() ...):
446
+ -- not something the app wrote, so never listed as "your app's
447
+ -- functions" (blind round 13 listed 23 of pgcrypto's).
448
+ EXISTS (SELECT 1 FROM pg_depend d
449
+ WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e') AS from_extension,
450
+ -- Whether the app can call it at all, as a visitor or signed in.
451
+ -- Such a function is something the app does that was read and
452
+ -- never run - said under "What I did not test" (scan.js notCalledOf).
453
+ CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')
454
+ AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated')
455
+ THEN has_function_privilege('anon', p.oid, 'EXECUTE')
456
+ OR has_function_privilege('authenticated', p.oid, 'EXECUTE')
457
+ ELSE false END AS client_callable,
458
+ (SELECT coalesce(array_agg(g ORDER BY g), '{}'::text[])
459
+ FROM (SELECT DISTINCT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee)::text END AS g
460
+ FROM aclexplode(p.proacl) a
461
+ WHERE a.privilege_type = 'EXECUTE' AND a.grantee <> p.proowner) x) AS executors
462
+ FROM pg_proc p
463
+ JOIN pg_namespace n ON n.oid = p.pronamespace
464
+ JOIN pg_language l ON l.oid = p.prolang
465
+ WHERE n.nspname = $1
466
+ AND p.prokind = 'f'
467
+ AND p.prorettype <> 'pg_catalog.trigger'::regtype
468
+ ORDER BY p.proname, p.oid`,
469
+ [schema],
470
+ );
471
+ return rows;
472
+ }
473
+
474
+ /**
475
+ * Supabase Storage's buckets: whether each is public, and the rules on
476
+ * storage.objects that say who may read it (a rule naming the bucket's id).
477
+ *
478
+ * The settings and the rules only - never a file, never a file's name. A
479
+ * database without Supabase Storage, or one this connection cannot read it in,
480
+ * has none. Mirrors read_buckets in engine.sql, query for query.
481
+ *
482
+ * Asked first rather than left to fail: a failed query inside a caller's
483
+ * transaction would abort everything after it.
484
+ */
485
+ async function readBuckets(client) {
486
+ try {
487
+ const here = await client.query(
488
+ `SELECT to_regclass('storage.buckets') IS NOT NULL
489
+ AND EXISTS (SELECT 1 FROM information_schema.columns
490
+ WHERE table_schema = 'storage' AND table_name = 'buckets' AND column_name = 'public') AS found`,
491
+ );
492
+ if (!here.rows[0].found) return [];
493
+ const { rows } = await client.query(BUCKETS_QUERY);
494
+ return rows[0].buckets;
495
+ } catch (err) {
496
+ return [];
497
+ }
498
+ }
499
+
500
+ const BUCKETS_QUERY =
501
+ `SELECT coalesce(jsonb_agg(jsonb_build_object(
502
+ 'id', b.id::text,
503
+ 'name', b.name::text,
504
+ 'public', coalesce(b.public, false),
505
+ 'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
506
+ 'name', p.policyname::text,
507
+ 'roles', to_jsonb(p.roles::text[]),
508
+ 'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
509
+ FROM pg_policies p
510
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
511
+ AND p.cmd IN ('SELECT', 'ALL')
512
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
513
+ -- The rules for putting files in and taking them out: who the app
514
+ -- lets write a bucket says who its files are for (scan.js bucketsOf).
515
+ 'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
516
+ 'name', p.policyname::text,
517
+ 'roles', to_jsonb(p.roles::text[]),
518
+ 'qual', p.qual,
519
+ 'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
520
+ FROM pg_policies p
521
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
522
+ AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
523
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
524
+ ) ORDER BY b.id), '[]'::jsonb) AS buckets
525
+ FROM storage.buckets b`;
526
+
527
+ async function readPolicies(client, schema) {
528
+ const { rows } = await client.query(
529
+ `SELECT tablename AS table_name,
530
+ policyname AS name,
531
+ permissive,
532
+ -- Cast on purpose. pg_policies.roles is name[], which
533
+ -- node-postgres has no parser for, so it arrives as the raw
534
+ -- string "{anon,authenticated}" - and code that treats it as a
535
+ -- list gets the characters of that string instead of the roles.
536
+ -- roleList() was written to undo that; casting here means there is
537
+ -- nothing to undo. The third time this exact shape has cost a bug,
538
+ -- and the one the two engines disagreed about on their first run.
539
+ roles::text[] AS roles,
540
+ cmd,
541
+ qual,
542
+ with_check
543
+ FROM pg_policies
544
+ WHERE schemaname = $1
545
+ ORDER BY tablename, policyname`,
546
+ [schema],
547
+ );
548
+ return rows;
549
+ }
550
+
551
+ /**
552
+ * Who was granted what. A policy is irrelevant if the grant is not there.
553
+ *
554
+ * Restricted to ordinary tables on purpose. role_table_grants also lists views
555
+ * and materialised views, and replaying a grant on a view the copy does not
556
+ * contain fails outright - which took down the scan of any app with a view in
557
+ * it, and almost every app has one.
558
+ */
559
+ async function readGrants(client, schema) {
560
+ const { rows } = await client.query(
561
+ `SELECT g.table_name, g.grantee, g.privilege_type
562
+ FROM information_schema.role_table_grants g
563
+ JOIN pg_class c ON c.relname = g.table_name
564
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
565
+ WHERE g.table_schema = $1
566
+ AND c.relkind = 'r'
567
+ AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
568
+ ORDER BY g.table_name, g.grantee, g.privilege_type`,
569
+ [schema],
570
+ );
571
+ return rows;
572
+ }
573
+
574
+ /**
575
+ * Views, which are a door of their own.
576
+ *
577
+ * A view runs with its creator's rights unless it says otherwise, so a view
578
+ * over a table that row level security protects hands out every row in that
579
+ * table to anyone allowed to read the view. The policy is intact, the
580
+ * dashboard is green, and the data is gone. `security_invoker` is what turns
581
+ * that off, and it lives in reloptions, so it is copied with the view.
582
+ */
583
+ async function readViews(client, schema) {
584
+ const { rows } = await client.query(
585
+ `SELECT c.relname AS name,
586
+ pg_get_viewdef(c.oid, true) AS definition,
587
+ c.relkind = 'm' AS materialised,
588
+ array_to_string(c.reloptions, ', ') AS options
589
+ FROM pg_class c
590
+ JOIN pg_namespace n ON n.oid = c.relnamespace
591
+ WHERE n.nspname = $1 AND c.relkind IN ('v', 'm')
592
+ ORDER BY c.relname`,
593
+ [schema],
594
+ );
595
+ return rows;
596
+ }
597
+
598
+ /** Views, with the columns they expose, so a finding can name what leaked. */
599
+ async function readViewsWithColumns(client, schema) {
600
+ const views = await readViews(client, schema);
601
+ for (const view of views) {
602
+ view.columns = await readColumns(client, schema, view.name);
603
+ }
604
+ return views;
605
+ }
606
+
607
+ /** Who was granted what on a view. Separate, because views are created later. */
608
+ async function readViewGrants(client, schema) {
609
+ const { rows } = await client.query(
610
+ `SELECT g.table_name, g.grantee, g.privilege_type
611
+ FROM information_schema.role_table_grants g
612
+ JOIN pg_class c ON c.relname = g.table_name
613
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = g.table_schema
614
+ WHERE g.table_schema = $1
615
+ AND c.relkind IN ('v', 'm')
616
+ AND g.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
617
+ ORDER BY g.table_name, g.grantee, g.privilege_type`,
618
+ [schema],
619
+ );
620
+ return rows;
621
+ }
622
+
623
+ /**
624
+ * The types the app made for itself: enums, and domains.
625
+ *
626
+ * Copied, so that the copy stands on its own. It used to borrow them from the
627
+ * schema it was copied from, which worked right up until a view mentioned one:
628
+ * pg_get_viewdef writes a literal as 'paid'::app.order_status, the rewrite
629
+ * turned app into the copy, and the copy had no such type. Any app with a view
630
+ * over an enum column - which is a great many of them - crashed the scan
631
+ * outright, with a stack trace where the report should have been.
632
+ *
633
+ * Borrowing was quietly wrong anyway. A copy that reaches back into the
634
+ * original for anything is a copy that can change under the attack.
635
+ */
636
+ async function readTypes(client, schema) {
637
+ const { rows } = await client.query(
638
+ `SELECT t.typname AS name,
639
+ t.typtype AS kind,
640
+ CASE WHEN t.typtype = 'e' THEN (
641
+ SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
642
+ FROM pg_enum e WHERE e.enumtypid = t.oid
643
+ ) END AS labels,
644
+ CASE WHEN t.typtype = 'd'
645
+ THEN format_type(t.typbasetype, t.typtypmod) END AS base_type,
646
+ CASE WHEN t.typtype = 'd' THEN (
647
+ SELECT array_agg(pg_get_constraintdef(c.oid) ORDER BY c.conname)
648
+ FROM pg_constraint c WHERE c.contypid = t.oid
649
+ ) END AS constraints,
650
+ t.typnotnull AS not_null,
651
+ CASE WHEN t.typtype = 'd' THEN t.typdefault END AS default_value
652
+ FROM pg_type t
653
+ JOIN pg_namespace n ON n.oid = t.typnamespace
654
+ WHERE n.nspname = $1
655
+ AND t.typtype IN ('e', 'd')
656
+ -- Enums first. A domain can be built on one, and a domain created
657
+ -- before the enum it stands on is a type that does not exist yet.
658
+ ORDER BY CASE WHEN t.typtype = 'e' THEN 0 ELSE 1 END, t.typname`,
659
+ [schema],
660
+ );
661
+ return rows;
662
+ }
663
+
664
+ /** What a foreign key points at, pulled out of Postgres's own wording. */
665
+ function referenceIn(definition) {
666
+ const match = /FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+([^\s(]+)\s*\(([^)]+)\)/i.exec(String(definition));
667
+ if (!match) return null;
668
+ const strip = (text) => text.trim().replace(/^"(.*)"$/, '$1');
669
+ const target = match[2].trim().split(/\.(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(strip);
670
+ return {
671
+ schema: target.length > 1 ? target[0] : null,
672
+ table: target[target.length - 1],
673
+ columns: match[3].split(',').map(strip),
674
+ };
675
+ }
676
+
677
+ /**
678
+ * The name a stand-in for an outside table is given inside the copy.
679
+ *
680
+ * `taken` is every name the customer already uses, and the stand-in keeps
681
+ * moving until it clashes with none of them. Identifying our own tables by
682
+ * their prefix alone meant a customer table that happened to start with the
683
+ * same letters was quietly dropped from every attack - a real hole reported as
684
+ * nothing at all, which is the failure this whole program exists to avoid.
685
+ */
686
+ function stubNameFor(refSchema, refTable, taken) {
687
+ const used = new Set(taken || []);
688
+ const base = 'kn_ext__' + refSchema + '__' + refTable;
689
+ let name = base;
690
+ let nth = 2;
691
+ while (used.has(name)) {
692
+ name = base + '__' + nth;
693
+ nth += 1;
694
+ }
695
+ return name;
696
+ }
697
+
698
+ /**
699
+ * Tables outside this schema that its foreign keys point at.
700
+ *
701
+ * Nearly every Supabase app has `references auth.users(id)`, and the copy
702
+ * cannot carry that as written: pointed at the real auth.users, every seeded
703
+ * row becomes a write into the customer's own authentication table. So the
704
+ * shape of the outside table is read - columns and types, never rows - and a
705
+ * stand-in is built inside the copy instead.
706
+ *
707
+ * A target whose shape cannot be read at all is returned with no columns, and
708
+ * the caller treats that as unsupported rather than guessing at it.
709
+ */
710
+ async function readExternalTargets(client, schema, tables) {
711
+ const wanted = new Map();
712
+ for (const table of tables) {
713
+ for (const constraint of table.constraints || []) {
714
+ if (constraint.kind !== 'f') continue;
715
+ const points = referenceIn(constraint.definition);
716
+ if (!points || !points.schema || points.schema === schema) continue;
717
+ const key = points.schema + '.' + points.table;
718
+ if (!wanted.has(key)) {
719
+ wanted.set(key, { schema: points.schema, table: points.table, columns: new Set() });
720
+ }
721
+ points.columns.forEach((column) => wanted.get(key).columns.add(column));
722
+ }
723
+ }
724
+
725
+ const targets = [];
726
+ for (const entry of wanted.values()) {
727
+ let columns = [];
728
+ try {
729
+ const { rows } = await client.query(
730
+ `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type
731
+ FROM pg_attribute a
732
+ WHERE a.attrelid = format('%I.%I', $1::text, $2::text)::regclass
733
+ AND a.attname = ANY($3) AND a.attnum > 0 AND NOT a.attisdropped
734
+ ORDER BY a.attnum`,
735
+ [entry.schema, entry.table, Array.from(entry.columns)],
736
+ );
737
+ columns = rows;
738
+ } catch (err) {
739
+ columns = [];
740
+ }
741
+ targets.push({
742
+ schema: entry.schema,
743
+ table: entry.table,
744
+ columns: columns,
745
+ stub: stubNameFor(entry.schema, entry.table, tables.map((t) => t.name)),
746
+ wanted: Array.from(entry.columns),
747
+ });
748
+ }
749
+ return targets;
750
+ }
751
+
752
+ /**
753
+ * Everything needed to rebuild a schema, and everything that could not be.
754
+ *
755
+ * `unsupported` is the important half. A caller that ignores it is attacking a
756
+ * database that is not the customer's.
757
+ */
758
+ async function readSchema(client, schema) {
759
+ const tables = await readTables(client, schema);
760
+ const unsupported = [];
761
+ const built = [];
762
+
763
+ for (const table of tables) {
764
+ const columns = await readColumns(client, schema, table.name);
765
+ const constraints = await readConstraints(client, schema, table.name);
766
+
767
+ built.push({
768
+ name: table.name,
769
+ rlsEnabled: table.rls_enabled,
770
+ rlsForced: table.rls_forced,
771
+ columns: columns,
772
+ constraints: constraints,
773
+ });
774
+ }
775
+
776
+ // A foreign key pointing outside this schema gets a stand-in inside the
777
+ // copy. Quietly dropping it instead would change what seeding is allowed to
778
+ // insert, and pointing it at the real table would make every seeded row a
779
+ // write into the customer's own data.
780
+ const external = await readExternalTargets(client, schema, built);
781
+ for (const target of external) {
782
+ if (target.columns.length !== target.wanted.length) {
783
+ unsupported.push(
784
+ 'a foreign key points at ' + target.schema + '.' + target.table +
785
+ ', and I could not read its shape to stand in for it',
786
+ );
787
+ }
788
+ }
789
+
790
+ return {
791
+ schema: schema,
792
+ tables: built,
793
+ policies: await readPolicies(client, schema),
794
+ grants: await readGrants(client, schema),
795
+ sequenceGrants: await readSequenceGrants(client, schema),
796
+ types: await readTypes(client, schema),
797
+ indexes: await readIndexes(client, schema),
798
+ views: await readViewsWithColumns(client, schema),
799
+ viewGrants: await readViewGrants(client, schema),
800
+ external: external,
801
+ anonFunctions: await readAnonDefinerFunctions(client, schema),
802
+ functions: await readFunctions(client, schema),
803
+ buckets: await readBuckets(client),
804
+ unsupported: unsupported,
805
+ };
806
+ }
807
+
808
+ /* --------------------------------------------------------------------------
809
+ Rebuilding.
810
+ -------------------------------------------------------------------------- */
811
+
812
+ function quote(name) {
813
+ return '"' + String(name).split('"').join('""') + '"';
814
+ }
815
+
816
+ /**
817
+ * The roles a policy applies to, as a list.
818
+ *
819
+ * pg_policies hands this back as the raw Postgres array literal - the string
820
+ * "{anon,authenticated}", not an array - because node-postgres has no parser
821
+ * registered for name[]. Measured, not assumed. Treating it as an array gives
822
+ * either a crash or, worse, a policy created for the wrong roles.
823
+ */
824
+ function roleList(roles) {
825
+ if (Array.isArray(roles)) return roles;
826
+ const raw = String(roles === null || roles === undefined ? '' : roles).trim();
827
+ if (!raw || raw === '{}') return [];
828
+ const inner = raw.startsWith('{') && raw.endsWith('}') ? raw.slice(1, -1) : raw;
829
+ return inner
830
+ .split(',')
831
+ .map((part) => part.trim().replace(/^"(.*)"$/, '$1'))
832
+ .filter(Boolean);
833
+ }
834
+
835
+ /**
836
+ * Points anything schema-qualified at the copy instead of the original.
837
+ *
838
+ * Both spellings, and that is the whole point of this existing. Postgres
839
+ * writes a name unquoted when it does not need quoting, so a foreign key came
840
+ * back as "REFERENCES app.profiles(id)" while only the quoted form was being
841
+ * rewritten - and the copy was created holding a live reference into the
842
+ * customer's real schema. Every insert into the copy would then have been
843
+ * checked against the customer's real table, which is the one thing this is
844
+ * built never to touch.
845
+ */
846
+ function rewriteSchemaRefs(expr, fromSchema, toSchema) {
847
+ if (!expr) return null;
848
+ return String(expr).split(quote(fromSchema) + '.').join(quote(toSchema) + '.').split(fromSchema + '.').join(toSchema + '.');
849
+ }
850
+
851
+ /**
852
+ * Points a rule's references to the app's own tables and views at the copy -
853
+ * and leaves everything else alone.
854
+ *
855
+ * A rule is copied as Postgres printed it. For an app outside the search_path
856
+ * that includes the schema - "EXISTS (SELECT 1 FROM kn_app.members m ...)" -
857
+ * and replayed verbatim into the copy, the copy's rule read the customer's
858
+ * members table. Caught by the pg_depend guard in writeSchema the day it
859
+ * learned to look at rules; the text comparison had passed it for months,
860
+ * since source and copy printed the same words.
861
+ *
862
+ * Not rewriteSchemaRefs, which moves every "kn_app." it finds: a rule calling
863
+ * one of the app's functions that is not copied (kn_app.whoami() in another
864
+ * language) has to keep calling it, and pointing it at the copy makes the
865
+ * CREATE POLICY fail. Only tables, views, and the functions the copy was
866
+ * given (copiedFunctions) move. A name followed by more identifier characters
867
+ * is a different name - members_log is not members - so the match stops at a
868
+ * word boundary.
869
+ */
870
+ function rewriteOwnTableRefs(expr, plan, target, copiedFunctions) {
871
+ if (expr === null || expr === undefined) return expr;
872
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
873
+ const simple = (s) => /^[a-z_][a-z0-9_$]*$/.test(s);
874
+ const schemaForms = [quote(plan.schema)].concat(simple(plan.schema) ? [plan.schema] : []);
875
+ const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name), copiedFunctions || []);
876
+ let out = String(expr);
877
+ for (const name of own) {
878
+ const nameForms = [quote(name)].concat(simple(name) ? [name] : []);
879
+ for (const s of schemaForms) {
880
+ for (const n of nameForms) {
881
+ const tail = n.charAt(0) === '"' ? '' : '(?![A-Za-z0-9_$])';
882
+ out = out.replace(new RegExp(escape(s + '.' + n) + tail, 'g'), quote(target) + '.' + quote(name));
883
+ }
884
+ }
885
+ }
886
+ return out;
887
+ }
888
+
889
+ /**
890
+ * Which of the app's functions the copy needs: every SQL or PL/pgSQL function
891
+ * a rule or a view calls, and every one those call in turn. Matched by name
892
+ * followed by "(", bare or schema-qualified; every overload of a name comes
893
+ * along. Functions in other languages stay where they are.
894
+ */
895
+ function functionsToCopy(plan) {
896
+ const copyable = (plan.functions || []).filter((f) => f.language === 'sql' || f.language === 'plpgsql');
897
+ const names = Array.from(new Set(copyable.map((f) => f.name)));
898
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
899
+ const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
900
+ const seeds = []
901
+ .concat((plan.policies || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')))
902
+ .concat((plan.views || []).map((v) => v.definition));
903
+ const wanted = new Set(names.filter((name) => seeds.some((text) => calls(text, name))));
904
+ // And whatever those call, until nothing new turns up.
905
+ let grew = true;
906
+ while (grew) {
907
+ grew = false;
908
+ for (const f of copyable) {
909
+ if (!wanted.has(f.name)) continue;
910
+ for (const name of names) {
911
+ if (!wanted.has(name) && calls(f.src, name)) {
912
+ wanted.add(name);
913
+ grew = true;
914
+ }
915
+ }
916
+ }
917
+ }
918
+ return copyable.filter((f) => wanted.has(f.name));
919
+ }
920
+
921
+ /**
922
+ * One of the app's functions, rebuilt in the copy: the same body and settings,
923
+ * its name in the copy, its references to the app's own tables, views and
924
+ * copied functions pointed at the copy, and a pinned search_path that looks in
925
+ * the copy first and the original second (for what the copy does not have,
926
+ * such as an extension's functions).
927
+ */
928
+ function copyFunctionStatement(fn, plan, target, copiedNames) {
929
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
930
+ const schemaForms = [quote(plan.schema), plan.schema].map(escape).join('|');
931
+ let def = String(fn.def).replace(
932
+ new RegExp('^CREATE OR REPLACE FUNCTION\\s+(?:' + schemaForms + ')\\.'),
933
+ () => 'CREATE FUNCTION ' + quote(target) + '.',
934
+ );
935
+ // The settings line, e.g. SET search_path TO 'public', 'storage'
936
+ const pinned = /^\s*SET search_path (?:TO|=) /m.test(def);
937
+ def = def.replace(/^(\s*SET search_path (?:TO|=) )(.*)$/m, (all, head, list) =>
938
+ head + list.split(',').map((part) => {
939
+ const bare = part.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
940
+ return bare === plan.schema ? "'" + target + "', " + part.trim() : part.trim();
941
+ }).join(', '));
942
+ // None pinned, so a bare name in the body is looked up on the caller's path
943
+ // when it runs - and the caller's path has never heard of the copy. Found on
944
+ // a blind test (OrbitDesk): member_of and has_role read `workspace_members`
945
+ // bare, so the copy's helpers read the original's members, with the
946
+ // original's workspace_role, against the copy's. Every rule calling has_role
947
+ // failed with "operator does not exist", 13 attacks never ran, and a loop
948
+ // through member_of never fired because the original's table was empty.
949
+ // So the copy's helper is pinned the way the copy itself was built: the copy
950
+ // first, then where the original's bare names would have gone. A BEGIN
951
+ // ATOMIC body has no AS line and needs none - it was bound when it was made.
952
+ if (!pinned) {
953
+ const path = Array.from(new Set([target, plan.schema, 'public', 'extensions']));
954
+ def = def.replace(/\nAS /, '\n SET search_path TO ' + path.map((s) => "'" + s + "'").join(', ') + '\nAS ');
955
+ }
956
+ return rewriteOwnTableRefs(def, plan, target, copiedNames);
957
+ }
958
+
959
+ /**
960
+ * Points a reference with no schema on it at the copy.
961
+ *
962
+ * The one the other two rewrites could not see. `pg_get_constraintdef` writes
963
+ * the schema only when the referenced table is *not* reachable through
964
+ * `search_path` - so an app living in `app_something` comes back as
965
+ * "REFERENCES app_something.profiles(id)" and is rewritten, while the same app
966
+ * living in `public` comes back as "REFERENCES profiles(id)" and there is
967
+ * nothing to rewrite. Replayed into the copy, that bare name resolves through
968
+ * `search_path` all over again, and binds to the customer's real table.
969
+ *
970
+ * Found on the first real Supabase project this was ever pointed at, which is
971
+ * the first app that was not in a schema of its own:
972
+ *
973
+ * notes_owner_fkey -> public.profiles (the customer's own table)
974
+ * orders_user_id_fkey -> public.profiles (the customer's own table)
975
+ *
976
+ * Nothing was written to those tables - the copy only pointed at them - but
977
+ * the copy was not the app, which is the same failure three other bugs in this
978
+ * file were. Here it meant every insert into the copy was checked against a
979
+ * table the seeder had put nothing in, so two tables out of four were never
980
+ * attacked and were reported, honestly but uselessly, as not checked.
981
+ *
982
+ * Only names the plan itself owns are touched. A reference to something
983
+ * outside the schema is the stand-in's business, not this one's.
984
+ */
985
+ function qualifyOwnRefs(definition, plan, target) {
986
+ const own = new Set((plan.tables || []).map((table) => table.name));
987
+ // A replacer function, never a replacement string: `$&` and friends are read
988
+ // as instructions inside one, and that has already cost this project an
989
+ // engine that would not install.
990
+ // A quoted identifier can hold anything, including spaces and quotes of its
991
+ // own doubled up, so it is matched as a whole rather than as a run of safe
992
+ // characters. The first attempt used [^".\s(]+ inside the quotes and
993
+ // silently did not match `REFERENCES "Group Table"(...)` - which the twin
994
+ // fixture has, because it was built out of every shape that has ever been
995
+ // read wrong, and it caught this the first time it ran.
996
+ //
997
+ // The unquoted alternative excludes a dot, so a name that is already
998
+ // schema-qualified is left alone: that is the other rewrite's work.
999
+ return String(definition).replace(
1000
+ /(\bREFERENCES\s+)("(?:[^"]|"")*"|[^\s(".]+)(\s*\()/gi,
1001
+ (whole, before, written, after) => {
1002
+ const name = written.charAt(0) === '"'
1003
+ ? written.slice(1, -1).split('""').join('"')
1004
+ : written;
1005
+ return own.has(name) ? before + quote(target) + '.' + quote(name) + after : whole;
1006
+ },
1007
+ );
1008
+ }
1009
+
1010
+ /**
1011
+ * Points a foreign key at the stand-in instead of at the real outside table.
1012
+ *
1013
+ * Both spellings again, for the same reason the schema rewrite handles both:
1014
+ * Postgres writes a name unquoted whenever it does not have to quote it, and
1015
+ * missing one spelling leaves the copy holding a live reference into the
1016
+ * customer's database.
1017
+ */
1018
+ function rewriteExternalRefs(expr, external, toSchema) {
1019
+ let text = String(expr);
1020
+ for (const target of external || []) {
1021
+ const stub = quote(toSchema) + '.' + quote(target.stub);
1022
+ text = text
1023
+ .split(quote(target.schema) + '.' + quote(target.table))
1024
+ .join(stub)
1025
+ .split(target.schema + '.' + target.table)
1026
+ .join(stub);
1027
+ }
1028
+ return text;
1029
+ }
1030
+
1031
+ /** Two people who do not exist, used wherever a stand-in row is needed. */
1032
+ // Everyone the attacks ever act as: the seeded pair (A, B) and the two who
1033
+ // own nothing (C, D, see attack.js) - the same values, kept literal here
1034
+ // because attack.js requires this file. All four go into every stand-in.
1035
+ // With only A and B there, a row an attack added under C was refused by the
1036
+ // foreign key to auth.users before any rule was consulted, and "anyone can
1037
+ // place an order" or "two accounts share one email" came back as not tested.
1038
+ // Measured on a test app shaped like a Supabase shop, where nearly every
1039
+ // table's user_id points at auth.users - so on real apps, most of them.
1040
+ const IDENTITIES = [
1041
+ '11111111-1111-4111-8111-111111111111',
1042
+ '22222222-2222-4222-8222-222222222222',
1043
+ '55555555-5555-4555-8555-555555555555',
1044
+ '66666666-6666-4666-8666-666666666666',
1045
+ ];
1046
+
1047
+ /** Something of the right type to put in a stand-in row. */
1048
+ function stubValue(type, nth) {
1049
+ const kind = String(type).toLowerCase();
1050
+ if (kind === 'uuid') return "'" + IDENTITIES[nth] + "'";
1051
+ if (/^(integer|bigint|smallint|numeric|decimal|real|double)/.test(kind)) return String(nth + 1);
1052
+ if (/^bool/.test(kind)) return nth === 0 ? 'true' : 'false';
1053
+ if (/^(timestamp|date)/.test(kind)) return 'now()';
1054
+ return "'kryptheon-" + (nth + 1) + "'";
1055
+ }
1056
+
1057
+ /** Which of these roles this database actually has. */
1058
+ async function existingRoles(client, wanted) {
1059
+ const { rows } = await client.query('SELECT rolname FROM pg_roles WHERE rolname = ANY($1)', [wanted]);
1060
+ return rows.map((row) => row.rolname);
1061
+ }
1062
+
1063
+ /**
1064
+ * Nothing may be written outside the copy. Ever.
1065
+ *
1066
+ * The product is sold on one sentence - we never touch your live app - and
1067
+ * this is the line that keeps it true. It is a structural guard rather than a
1068
+ * careful habit, because the two statements that broke the promise were
1069
+ * written carefully and sat there for weeks: every check built its own
1070
+ * auth.uid() first, so replacing the customer's looked exactly like doing
1071
+ * nothing.
1072
+ *
1073
+ * Every statement has to name the copy schema, quoted or not. A statement that
1074
+ * does not is not run, and the scan stops rather than guessing.
1075
+ */
1076
+ function mustStayInside(statements, target) {
1077
+ const bare = new RegExp('(^|[^A-Za-z0-9_"])' + target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.');
1078
+ for (const statement of statements) {
1079
+ if (statement.includes(quote(target)) || bare.test(statement)) continue;
1080
+ throw new Error(
1081
+ 'refusing to run a statement that does not stay inside the copy ' + target + ': ' + statement,
1082
+ );
1083
+ }
1084
+ return statements;
1085
+ }
1086
+
1087
+ /**
1088
+ * Builds the schema again, in a database we own.
1089
+ *
1090
+ * Order matters: tables, then constraints, then grants, then policies. A policy
1091
+ * cannot be created before the table it guards, and a grant given after a
1092
+ * policy would widen access the original did not have.
1093
+ */
1094
+ async function writeSchema(client, plan, target) {
1095
+ const statements = ['CREATE SCHEMA ' + quote(target)];
1096
+
1097
+ // The app's own types first: a column, a check, a view or a cast can all
1098
+ // name one, and nothing that mentions a type can be created before it.
1099
+ for (const made of plan.types || []) {
1100
+ if (made.kind === 'e') {
1101
+ statements.push(
1102
+ 'CREATE TYPE ' + quote(target) + '.' + quote(made.name) + ' AS ENUM (' +
1103
+ (made.labels || []).map((label) => "'" + String(label).split("'").join("''") + "'").join(', ') + ')',
1104
+ );
1105
+ } else {
1106
+ const here = (text) => rewriteSchemaRefs(text, plan.schema, target);
1107
+ // A domain can stand on another of the app's own types, and its rule can
1108
+ // name one too, so both go through the same rewrite a column does.
1109
+ const parts = ['CREATE DOMAIN ' + quote(target) + '.' + quote(made.name) + ' AS ' + here(made.base_type)];
1110
+ if (made.default_value) parts.push('DEFAULT ' + here(made.default_value));
1111
+ if (made.not_null) parts.push('NOT NULL');
1112
+ for (const rule of made.constraints || []) parts.push(here(rule));
1113
+ statements.push(parts.join(' '));
1114
+ }
1115
+ }
1116
+
1117
+ for (const table of plan.tables) {
1118
+ const columns = table.columns.map((column) => {
1119
+ // The type is rewritten like everything else now that the copy has its
1120
+ // own. Left alone, the copy leaned on the original for them.
1121
+ const parts = [quote(column.name), rewriteSchemaRefs(column.type, plan.schema, target)];
1122
+ const fallback = rewriteSchemaRefs(column.default_expr, plan.schema, target);
1123
+ if (column.generated) {
1124
+ // A stored generated column carries its expression in default_expr, and
1125
+ // replaying that as a DEFAULT is a syntax error - "cannot use column
1126
+ // reference in DEFAULT expression" - which took the whole scan down.
1127
+ parts.push('GENERATED ALWAYS AS (' + fallback + ') STORED');
1128
+ } else if (column.identity) {
1129
+ parts.push('GENERATED ALWAYS AS IDENTITY');
1130
+ } else if (fallback) {
1131
+ parts.push('DEFAULT ' + fallback);
1132
+ }
1133
+ if (column.not_null && !column.generated && !column.identity) parts.push('NOT NULL');
1134
+ return parts.join(' ');
1135
+ });
1136
+
1137
+ // Sequences first, or a serial column's default has nothing to point at.
1138
+ for (const column of table.columns) {
1139
+ const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
1140
+ if (!match) continue;
1141
+ const bare = match[1].split('.').pop().split('"').join('');
1142
+ statements.push('CREATE SEQUENCE IF NOT EXISTS ' + quote(target) + '.' + quote(bare));
1143
+ }
1144
+
1145
+ statements.push(
1146
+ 'CREATE TABLE ' + quote(target) + '.' + quote(table.name) + ' (' + columns.join(', ') + ')',
1147
+ );
1148
+
1149
+ // And the sequence belongs to its column, the way serial makes it.
1150
+ //
1151
+ // Not tidiness. A sequence a column owns is linked to it in pg_depend,
1152
+ // and that link is how the grants on it are found again - so a copy
1153
+ // whose sequences stand loose reads back as having no sequence grants
1154
+ // at all, however many were replayed onto it. It also means the
1155
+ // sequence goes when the copy's table goes, which is what the original
1156
+ // does.
1157
+ for (const column of table.columns) {
1158
+ const match = /nextval\('([^']+)'/.exec(column.default_expr || '');
1159
+ if (!match) continue;
1160
+ const bare = match[1].split('.').pop().split('"').join('');
1161
+ statements.push(
1162
+ 'ALTER SEQUENCE ' + quote(target) + '.' + quote(bare) + ' OWNED BY ' +
1163
+ quote(target) + '.' + quote(table.name) + '.' + quote(column.name),
1164
+ );
1165
+ }
1166
+ }
1167
+
1168
+ // Stand-ins for the tables outside this schema that its foreign keys point
1169
+ // at, with two rows already in them so seeding has something to reference.
1170
+ // Built before the constraints, because a foreign key cannot be added to a
1171
+ // table that is not there yet.
1172
+ for (const outside of plan.external || []) {
1173
+ const stub = quote(target) + '.' + quote(outside.stub);
1174
+ const columns = outside.columns.map((column) => quote(column.name) + ' ' + column.type + ' NOT NULL');
1175
+ const keyed = outside.columns.map((column) => quote(column.name)).join(', ');
1176
+ statements.push('CREATE TABLE ' + stub + ' (' + columns.join(', ') + ', PRIMARY KEY (' + keyed + '))');
1177
+ for (let nth = 0; nth < IDENTITIES.length; nth++) {
1178
+ const values = outside.columns.map((column) => stubValue(column.type, nth));
1179
+ statements.push('INSERT INTO ' + stub + ' VALUES (' + values.join(', ') + ')');
1180
+ }
1181
+ }
1182
+
1183
+ // Keys and uniques across every table first, then the foreign keys.
1184
+ // Constraints used to be added table by table, so a foreign key on `orders`
1185
+ // was created before the primary key on `profiles` existed and Postgres
1186
+ // refused it: "no unique constraint matching given keys". A foreign key can
1187
+ // only be added once the thing it points at is already unique.
1188
+ for (const wantForeign of [false, true]) {
1189
+ for (const table of plan.tables) {
1190
+ for (const constraint of table.constraints) {
1191
+ const isForeign = constraint.kind === 'f';
1192
+ if (isForeign !== wantForeign) continue;
1193
+ const definition = qualifyOwnRefs(
1194
+ rewriteExternalRefs(
1195
+ rewriteSchemaRefs(constraint.definition, plan.schema, target),
1196
+ plan.external,
1197
+ target,
1198
+ ),
1199
+ plan,
1200
+ target,
1201
+ );
1202
+ statements.push(
1203
+ 'ALTER TABLE ' + quote(target) + '.' + quote(table.name) +
1204
+ ' ADD CONSTRAINT ' + quote(constraint.name) + ' ' + definition,
1205
+ );
1206
+ }
1207
+ }
1208
+ }
1209
+
1210
+ // Unique indexes, once every table exists. The schema name inside the
1211
+ // definition is rewritten for the same reason a foreign key's was: Postgres
1212
+ // writes it unquoted, and every single index definition carries it. Replayed
1213
+ // as-is, the copy would build its indexes on the customer's real tables.
1214
+ for (const index of plan.indexes || []) {
1215
+ statements.push(rewriteSchemaRefs(index.definition, plan.schema, target));
1216
+ }
1217
+
1218
+ // The helpers the rules and views call, after the tables their bodies read
1219
+ // and before anything that calls them.
1220
+ const copied = functionsToCopy(plan);
1221
+ const copiedNames = Array.from(new Set(copied.map((f) => f.name)));
1222
+ // The app's own types move with them: a copied helper taking app.role_t[]
1223
+ // has to take the copy's role_t, or the copy's columns cannot be compared
1224
+ // with its arguments.
1225
+ const movedNames = copiedNames.concat((plan.types || []).map((t) => t.name));
1226
+ for (const fn of copied) {
1227
+ statements.push(copyFunctionStatement(fn, plan, target, movedNames));
1228
+ // Who may call it, as in the original. Found by the blocked-read check: a
1229
+ // helper the original had closed to anon and authenticated came out of
1230
+ // the copy open to everyone, and a rule that cannot be evaluated was
1231
+ // reported as evaluated.
1232
+ if (!fn.default_acl) {
1233
+ const signature = quote(target) + '.' + quote(fn.name) + '(' + rewriteOwnTableRefs(fn.args, plan, target, movedNames) + ')';
1234
+ statements.push('REVOKE ALL ON FUNCTION ' + signature + ' FROM PUBLIC');
1235
+ for (const who of fn.executors || []) {
1236
+ statements.push('GRANT EXECUTE ON FUNCTION ' + signature + ' TO ' + (who === 'PUBLIC' ? 'PUBLIC' : quote(who)));
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ // Views last, because they read from the tables above. Copied rather than
1242
+ // skipped because a view is a way into a table: it runs with its creator
1243
+ // rights unless it says security_invoker, so a view over a protected table
1244
+ // hands out every row in it. Leaving views out meant never looking at that
1245
+ // door at all.
1246
+ for (const view of plan.views || []) {
1247
+ const body = rewriteExternalRefs(rewriteSchemaRefs(view.definition, plan.schema, target), plan.external, target);
1248
+ const options = view.options ? ' WITH (' + view.options + ')' : '';
1249
+ statements.push(
1250
+ 'CREATE ' + (view.materialised ? 'MATERIALIZED VIEW ' : 'VIEW ') +
1251
+ quote(target) + '.' + quote(view.name) + options + ' AS ' + body,
1252
+ );
1253
+ }
1254
+
1255
+ for (const grant of plan.viewGrants || []) {
1256
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
1257
+ statements.push(
1258
+ 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
1259
+ );
1260
+ }
1261
+
1262
+ // The copy needs the roles PostgREST switches into to be able to reach it.
1263
+ // Granted on the copy's own schema and nowhere else.
1264
+ //
1265
+ // What used to be here, and must never come back: CREATE SCHEMA auth,
1266
+ // CREATE OR REPLACE FUNCTION auth.uid(), and GRANT USAGE ON SCHEMA auth.
1267
+ // Those ran against the customer's live database. The first overwrote their
1268
+ // own authentication function; the third opened a schema they may have
1269
+ // deliberately closed. The policies reference auth.uid() and that is fine -
1270
+ // calling their function is a read, and a read is all we are ever allowed.
1271
+ // If it is missing, their app does not work either, and the copy failing to
1272
+ // build says so honestly instead of papering over it.
1273
+ for (const role of await existingRoles(client, ['anon', 'authenticated'])) {
1274
+ statements.push('GRANT USAGE ON SCHEMA ' + quote(target) + ' TO ' + quote(role));
1275
+ }
1276
+
1277
+ for (const grant of plan.grants) {
1278
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
1279
+ statements.push(
1280
+ 'GRANT ' + grant.privilege_type + ' ON ' + quote(target) + '.' + quote(grant.table_name) + ' TO ' + who,
1281
+ );
1282
+ }
1283
+
1284
+ // The sequences, on the same terms as the tables. A table grant without
1285
+ // the sequence grant that goes with it is a copy nobody can insert into.
1286
+ for (const grant of plan.sequenceGrants || []) {
1287
+ const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
1288
+ statements.push(
1289
+ 'GRANT ' + grant.privilege_type + ' ON SEQUENCE ' + quote(target) + '.' +
1290
+ quote(grant.sequence_name) + ' TO ' + who,
1291
+ );
1292
+ }
1293
+
1294
+ for (const table of plan.tables) {
1295
+ if (table.rlsEnabled) {
1296
+ statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' ENABLE ROW LEVEL SECURITY');
1297
+ }
1298
+ if (table.rlsForced) {
1299
+ statements.push('ALTER TABLE ' + quote(target) + '.' + quote(table.name) + ' FORCE ROW LEVEL SECURITY');
1300
+ }
1301
+ }
1302
+
1303
+ for (const policy of plan.policies) {
1304
+ const roles = roleList(policy.roles).join(', ') || 'PUBLIC';
1305
+ const parts = [
1306
+ 'CREATE POLICY ' + quote(policy.name),
1307
+ 'ON ' + quote(target) + '.' + quote(policy.table_name),
1308
+ 'AS ' + (policy.permissive === 'PERMISSIVE' ? 'PERMISSIVE' : 'RESTRICTIVE'),
1309
+ 'FOR ' + policy.cmd,
1310
+ 'TO ' + roles,
1311
+ ];
1312
+ if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target, movedNames) + ')');
1313
+ if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target, movedNames) + ')');
1314
+ statements.push(parts.join(' '));
1315
+ }
1316
+
1317
+ mustStayInside(statements, target);
1318
+ // Built with the copy first on the search_path, so a bare name binds to the
1319
+ // copy and not to whatever the customer happens to have.
1320
+ //
1321
+ // This is the structural half, and it went in after chasing the same bug
1322
+ // through three different kinds of expression. Postgres writes a name
1323
+ // without its schema whenever that name is already reachable - so a real app
1324
+ // in `public` hands back "REFERENCES profiles(id)" and
1325
+ // "nextval('orders_id_seq'::regclass)", and a rewrite that goes looking for
1326
+ // a schema name finds nothing to change in either. Replayed into the copy,
1327
+ // both bound to the customer's own objects: the foreign keys pointed at
1328
+ // their tables, and the copy drew its keys from their sequences, which
1329
+ // advanced them.
1330
+ //
1331
+ // Rewriting each kind of expression in turn is a game with no end - index
1332
+ // predicates, checks that call a function, view bodies. Naming the copy
1333
+ // first on the path ends all of them at once, because it changes what a
1334
+ // bare name means rather than trying to find every place one can appear.
1335
+ //
1336
+ // `public` stays on the path, after the copy, because the copy legitimately
1337
+ // needs what lives there - gen_random_uuid() and the like. `extensions` is
1338
+ // where Supabase keeps them; a schema on the path that does not exist is
1339
+ // ignored rather than an error.
1340
+ const { rows: pathRows } = await client.query('SHOW search_path');
1341
+ const restoreTo = pathRows[0].search_path;
1342
+ await client.query('SET search_path TO ' + quote(target) + ', public, extensions');
1343
+ try {
1344
+ for (const statement of statements) {
1345
+ await client.query(statement);
1346
+ }
1347
+ } finally {
1348
+ await client.query('SET search_path TO ' + restoreTo).catch(() => {});
1349
+ }
1350
+
1351
+ // And then look at what was actually built, rather than at what was meant.
1352
+ //
1353
+ // Everything above this line reasons about strings. The guard at the top of
1354
+ // this file reads the statements before they run; this one asks Postgres
1355
+ // where the copy ended up pointing, which is the only account of it that
1356
+ // cannot be fooled by a spelling nobody anticipated - and one was not. A
1357
+ // bare "REFERENCES profiles(id)" replayed into the copy bound to the
1358
+ // customer's own table, silently, on every app that lives in `public`.
1359
+ //
1360
+ // Nothing is written to a table outside the copy either way. The damage is
1361
+ // subtler than that: a copy tied to the customer's rows is not the app being
1362
+ // attacked, and every verdict taken from it is about something else.
1363
+ // Foreign keys AND column defaults, because the second one is where this
1364
+ // hid after the first was closed: a serial column's default is
1365
+ // nextval('<sequence>'), and the copy was calling the customer's. Nothing
1366
+ // was written to their tables, but nextval advances a sequence, so their
1367
+ // database changed - and "we never touch your live app" says at all.
1368
+ //
1369
+ // Asked of pg_depend rather than of the text, so it holds for any
1370
+ // expression that ends up pointing at a relation, not only the ones anybody
1371
+ // thought to look at. pg_catalog is excluded because everything depends on
1372
+ // it; anything else outside the copy is the bug.
1373
+ const { rows: strays } = await client.query(
1374
+ `SELECT con.conname AS what, rn.nspname AS points_at
1375
+ FROM pg_constraint con
1376
+ JOIN pg_class cl ON cl.oid = con.conrelid
1377
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
1378
+ JOIN pg_class rc ON rc.oid = con.confrelid
1379
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
1380
+ WHERE con.contype = 'f' AND cn.nspname = $1 AND rn.nspname <> $1
1381
+ UNION ALL
1382
+ SELECT cl.relname || '.' || a.attname || ' default' AS what,
1383
+ rn.nspname || '.' || rc.relname AS points_at
1384
+ FROM pg_depend d
1385
+ JOIN pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_attrdef'::regclass
1386
+ JOIN pg_class cl ON cl.oid = ad.adrelid
1387
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
1388
+ JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
1389
+ JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
1390
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
1391
+ WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'
1392
+ UNION ALL
1393
+ -- And the rules. A policy that reads another table - "items of my
1394
+ -- orders" - has to read the copy's orders; one reading the customer's
1395
+ -- would evaluate the attack against their real rows. The text comparison
1396
+ -- further down never caught this for an app in public: a reference to
1397
+ -- public.orders prints as plain "orders" either way. Postgres records
1398
+ -- every relation a policy expression uses, so ask it.
1399
+ SELECT pol.polname || ' rule on ' || cl.relname AS what,
1400
+ rn.nspname || '.' || rc.relname AS points_at
1401
+ FROM pg_depend d
1402
+ JOIN pg_policy pol ON pol.oid = d.objid AND d.classid = 'pg_policy'::regclass
1403
+ JOIN pg_class cl ON cl.oid = pol.polrelid
1404
+ JOIN pg_namespace cn ON cn.oid = cl.relnamespace
1405
+ JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
1406
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
1407
+ WHERE cn.nspname = $1 AND rn.nspname <> $1 AND rn.nspname <> 'pg_catalog'`,
1408
+ [target],
1409
+ );
1410
+ if (strays.length) {
1411
+ throw new Error(
1412
+ 'the copy points outside itself, so it is not the app: ' +
1413
+ strays.map((row) => row.what + ' -> ' + row.points_at).join(', '),
1414
+ );
1415
+ }
1416
+
1417
+ return statements;
1418
+ }
1419
+
1420
+ /* --------------------------------------------------------------------------
1421
+ Checking the copy is the original.
1422
+ -------------------------------------------------------------------------- */
1423
+
1424
+ /** A piece of SQL with its own schema name taken off, quoted or not. */
1425
+ function withoutSchema(text, plan) {
1426
+ return String(text == null ? '' : text)
1427
+ .split(quote(plan.schema) + '.').join('')
1428
+ .split(plan.schema + '.').join('');
1429
+ }
1430
+
1431
+ /**
1432
+ * Where a copy differs from what it was copied from.
1433
+ *
1434
+ * Anything here means the verdicts that follow are about the wrong database,
1435
+ * so this returning empty is a precondition for attacking, not a nicety.
1436
+ */
1437
+ function diffSchemas(source, copy) {
1438
+ const differences = [];
1439
+
1440
+ // The stand-ins exist only in the copy, by design. Comparing them against
1441
+ // an original that never had them would report every Supabase app as a
1442
+ // copy that came out wrong.
1443
+ const standIns = new Set((source.external || []).map((e) => e.stub));
1444
+ const mine = (list) => list.filter((t) => !standIns.has(t.name));
1445
+ const named = (list) => mine(list).map((t) => t.name).sort().join(', ');
1446
+ if (named(source.tables) !== named(copy.tables)) {
1447
+ differences.push('tables differ: ' + named(source.tables) + ' vs ' + named(copy.tables));
1448
+ }
1449
+
1450
+ for (const table of source.tables) {
1451
+ const mirror = copy.tables.find((t) => t.name === table.name);
1452
+ if (!mirror) continue;
1453
+
1454
+ if (table.rlsEnabled !== mirror.rlsEnabled) {
1455
+ differences.push(
1456
+ table.name + ': row level security is ' + (table.rlsEnabled ? 'on' : 'off') +
1457
+ ' but the copy has it ' + (mirror.rlsEnabled ? 'on' : 'off'),
1458
+ );
1459
+ }
1460
+ if (table.rlsForced !== mirror.rlsForced) {
1461
+ differences.push(table.name + ': forced row level security does not match');
1462
+ }
1463
+
1464
+ // The schema name comes off the type first. A column of the app's own enum
1465
+ // reads as app.order_status in the original and as <copy>.order_status in
1466
+ // the copy - the same type, built twice, and calling that a difference
1467
+ // would stop every scan of an app that has one.
1468
+ const shape = (plan, t) =>
1469
+ t.columns.map((c) => c.name + ' ' + withoutSchema(c.type, plan) + (c.not_null ? ' NOT NULL' : '')).join(' | ');
1470
+ if (shape(source, table) !== shape(copy, mirror)) {
1471
+ differences.push(table.name + ' columns differ:\n ' + shape(source, table) + '\n ' + shape(copy, mirror));
1472
+ }
1473
+ }
1474
+
1475
+ // The app's own types. An enum that arrived with a label missing narrows
1476
+ // what the attacks are able to insert, and nothing else here would notice.
1477
+ const typeText = (plan) =>
1478
+ (plan.types || [])
1479
+ .map((made) =>
1480
+ made.name + ' ' + made.kind + ' ' + (made.labels || []).join(',') + ' ' +
1481
+ withoutSchema(made.base_type, plan) + ' ' +
1482
+ (made.constraints || []).map((rule) => withoutSchema(rule, plan)).join(' '))
1483
+ .sort();
1484
+ const sourceTypes = typeText(source);
1485
+ const copyTypes = typeText(copy);
1486
+ for (const made of sourceTypes) {
1487
+ if (!copyTypes.includes(made)) differences.push('a type did not come across whole: ' + made);
1488
+ }
1489
+
1490
+ // The sequence grants, for the same reason they are copied at all: without
1491
+ // them nothing can insert, and every write attack reads as the app
1492
+ // defending itself.
1493
+ const sequenceText = (plan) =>
1494
+ (plan.sequenceGrants || [])
1495
+ .map((g) => g.sequence_name + ' ' + g.grantee + ' ' + g.privilege_type)
1496
+ .sort();
1497
+ const sourceSequences = sequenceText(source);
1498
+ const copySequences = sequenceText(copy);
1499
+ for (const one of sourceSequences) {
1500
+ if (!copySequences.includes(one)) {
1501
+ differences.push('a sequence grant did not come across: ' + one);
1502
+ }
1503
+ }
1504
+
1505
+ // Uniqueness decides whether the Collision attack has anything to report, so
1506
+ // a unique index that failed to come across has to be caught here rather
1507
+ // than turn into a confident finding about a table that was actually fine.
1508
+ // The schema name is stripped before comparing, since it differs by design.
1509
+ const indexText = (plan) =>
1510
+ (plan.indexes || [])
1511
+ .map((index) => withoutSchema(index.definition, plan))
1512
+ .sort();
1513
+ const sourceIndexes = indexText(source);
1514
+ const copyIndexes = indexText(copy);
1515
+ if (sourceIndexes.length !== copyIndexes.length) {
1516
+ differences.push(
1517
+ 'the copy has ' + copyIndexes.length + ' unique indexes, the original has ' + sourceIndexes.length,
1518
+ );
1519
+ }
1520
+ for (let i = 0; i < Math.max(sourceIndexes.length, copyIndexes.length); i++) {
1521
+ if (sourceIndexes[i] !== copyIndexes[i]) {
1522
+ differences.push('a unique index came across changed:\n ' + sourceIndexes[i] + '\n ' + copyIndexes[i]);
1523
+ }
1524
+ }
1525
+
1526
+ // The policies matter most, so they are compared word for word - except for
1527
+ // the schema names, which differ by design. A rule that looks at another of
1528
+ // the app's own tables ("items of my orders": EXISTS (SELECT 1 FROM orders
1529
+ // ...)) comes back from the copy naming the copy's orders, kn_xxx.orders,
1530
+ // which is exactly right. Compared raw, that read as "the copy came out
1531
+ // changed" and the whole app went unscanned - found on a test app built to
1532
+ // look like a Lovable shop, where this rule is ordinary. And a rule calling
1533
+ // one of the app's functions keeps naming the original schema in both,
1534
+ // because functions are not copied - so both names are taken out of both
1535
+ // sides. Where a rule actually points - the copy's table or the customer's -
1536
+ // is not left to this text: writeSchema asks pg_depend, and refuses a copy
1537
+ // whose rules read anything outside it.
1538
+ const bothSchemas = (text) => withoutSchema(withoutSchema(text, source), copy);
1539
+ const asText = (list) =>
1540
+ list
1541
+ .map((p) =>
1542
+ [p.table_name, p.name, p.permissive, roleList(p.roles).join('+'), p.cmd,
1543
+ bothSchemas(p.qual), bothSchemas(p.with_check)]
1544
+ .map((x) => String(x === null || x === undefined ? '' : x))
1545
+ .join(' :: '),
1546
+ )
1547
+ .sort();
1548
+
1549
+ const sourcePolicies = asText(source.policies);
1550
+ const copyPolicies = asText(copy.policies);
1551
+ if (sourcePolicies.length !== copyPolicies.length) {
1552
+ differences.push('the copy has ' + copyPolicies.length + ' policies, the original has ' + sourcePolicies.length);
1553
+ }
1554
+ for (let i = 0; i < Math.max(sourcePolicies.length, copyPolicies.length); i++) {
1555
+ if (sourcePolicies[i] !== copyPolicies[i]) {
1556
+ differences.push('a policy came across changed:\n ' + sourcePolicies[i] + '\n ' + copyPolicies[i]);
1557
+ }
1558
+ }
1559
+
1560
+ return differences;
1561
+ }
1562
+
1563
+ module.exports = {
1564
+ readSchema: readSchema,
1565
+ readAnonDefinerFunctions: readAnonDefinerFunctions,
1566
+ PLATFORM_SCHEMAS: PLATFORM_SCHEMAS,
1567
+ OWN_SCHEMA: OWN_SCHEMA,
1568
+ guardedAtTheDoor: guardedAtTheDoor,
1569
+ doorOf: doorOf,
1570
+ writeSchema: writeSchema,
1571
+ diffSchemas: diffSchemas,
1572
+ readPolicies: readPolicies,
1573
+ readBuckets: readBuckets,
1574
+ readTypes: readTypes,
1575
+ readSequenceGrants: readSequenceGrants,
1576
+ readIndexes: readIndexes,
1577
+ readViews: readViews,
1578
+ readExternalTargets: readExternalTargets,
1579
+ referenceIn: referenceIn,
1580
+ rewriteSchemaRefs: rewriteSchemaRefs,
1581
+ qualifyOwnRefs: qualifyOwnRefs,
1582
+ functionsToCopy: functionsToCopy,
1583
+ copyFunctionStatement: copyFunctionStatement,
1584
+ stubNameFor: stubNameFor,
1585
+ IDENTITIES: IDENTITIES,
1586
+ quote: quote,
1587
+ roleList: roleList,
1588
+ };