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.
- package/attack.js +1028 -1025
- package/bin/kryptheon-night.js +736 -725
- package/engine.sql +4657 -4430
- package/finding.js +1715 -1612
- package/intro.js +311 -303
- package/package.json +98 -98
- package/scan.js +1330 -1311
- package/schema.js +1588 -1555
- 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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
*
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
(
|
|
903
|
-
);
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
return
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
if (
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
'
|
|
1132
|
-
|
|
1133
|
-
);
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
statements.push(
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
//
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
for (const
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
for (const grant of plan.
|
|
1256
|
-
const who = grant.grantee === 'PUBLIC' ? 'PUBLIC' : quote(grant.grantee);
|
|
1257
|
-
statements.push(
|
|
1258
|
-
'GRANT ' + grant.privilege_type + ' ON
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1327
|
-
// customer's own
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
(
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
);
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
//
|
|
1506
|
-
//
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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
|
+
};
|