kryptheon-night 0.1.3 → 0.1.5
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/README.md +45 -6
- package/bin/kryptheon-night.js +310 -18
- package/engine.sql +2484 -0
- package/installer.js +224 -0
- package/intro.js +52 -0
- package/package.json +9 -4
- package/scan.js +38 -7
- package/sqlengine.js +200 -0
package/engine.sql
ADDED
|
@@ -0,0 +1,2484 @@
|
|
|
1
|
+
-- The engine, in SQL.
|
|
2
|
+
--
|
|
3
|
+
-- The attacks live here rather than in Node so that there is one
|
|
4
|
+
-- implementation and two ways to reach it:
|
|
5
|
+
--
|
|
6
|
+
-- npx install these functions in a throwaway schema, call them,
|
|
7
|
+
-- drop them. The customer's password never leaves their machine.
|
|
8
|
+
-- installer install them permanently, and let pg_cron call them nightly.
|
|
9
|
+
-- The customer's password never leaves their database.
|
|
10
|
+
--
|
|
11
|
+
-- Written once on purpose. A second implementation in another language means
|
|
12
|
+
-- every bug has to be fixed twice, and the second copy is where the one you
|
|
13
|
+
-- forgot lives. twin.check.js runs both paths over the same fixtures and fails
|
|
14
|
+
-- if their answers differ by a single character.
|
|
15
|
+
--
|
|
16
|
+
-- INSTALLING
|
|
17
|
+
--
|
|
18
|
+
-- Every occurrence of __KN__ is replaced with the schema these functions are
|
|
19
|
+
-- to live in, before this file is run. A token rather than a real name so that
|
|
20
|
+
-- the replacement cannot quietly hit a comment or a string that happened to
|
|
21
|
+
-- say `kryptheon`.
|
|
22
|
+
--
|
|
23
|
+
-- NOT SECURITY DEFINER, ANYWHERE
|
|
24
|
+
--
|
|
25
|
+
-- Measured: Postgres refuses `SET ROLE` inside a security-definer function,
|
|
26
|
+
-- and becoming `anon` is the whole attack. So these run with the rights of
|
|
27
|
+
-- whoever calls them and have no powers of their own - which is also the
|
|
28
|
+
-- honest thing to install in somebody else's database.
|
|
29
|
+
|
|
30
|
+
CREATE SCHEMA IF NOT EXISTS __KN__;
|
|
31
|
+
|
|
32
|
+
-- --------------------------------------------------------------------------
|
|
33
|
+
-- Reading the shape of an app.
|
|
34
|
+
--
|
|
35
|
+
-- Every function below mirrors one in schema.js, query for query, so that the
|
|
36
|
+
-- two engines cannot drift apart without the twin check noticing.
|
|
37
|
+
-- --------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/* Every table in a schema, with whether row level security is switched on. */
|
|
40
|
+
CREATE OR REPLACE FUNCTION __KN__.read_tables(source text)
|
|
41
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
42
|
+
SELECT coalesce(jsonb_agg(t ORDER BY t->>'name'), '[]'::jsonb)
|
|
43
|
+
FROM (
|
|
44
|
+
SELECT jsonb_build_object(
|
|
45
|
+
'name', c.relname,
|
|
46
|
+
'rlsEnabled', c.relrowsecurity,
|
|
47
|
+
'rlsForced', c.relforcerowsecurity
|
|
48
|
+
) AS t
|
|
49
|
+
FROM pg_class c
|
|
50
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
51
|
+
WHERE n.nspname = source AND c.relkind = 'r'
|
|
52
|
+
) rows;
|
|
53
|
+
$$;
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* The columns of one table, as Postgres itself would write them.
|
|
57
|
+
*
|
|
58
|
+
* format_type rather than information_schema, because it gives the type back
|
|
59
|
+
* exactly - numeric(10,2) stays numeric(10,2) - and a column that comes back
|
|
60
|
+
* the wrong width can change what a policy comparison does.
|
|
61
|
+
*
|
|
62
|
+
* enum labels are cast to text[] on purpose: array_agg over enumlabel produces
|
|
63
|
+
* name[], which some drivers hand back as the raw string "{new,paid}", whose
|
|
64
|
+
* first "label" is the character "{".
|
|
65
|
+
*/
|
|
66
|
+
CREATE OR REPLACE FUNCTION __KN__.read_columns(source text, tab text)
|
|
67
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
68
|
+
SELECT coalesce(jsonb_agg(c ORDER BY ord), '[]'::jsonb)
|
|
69
|
+
FROM (
|
|
70
|
+
SELECT a.attnum AS ord,
|
|
71
|
+
jsonb_build_object(
|
|
72
|
+
'name', a.attname,
|
|
73
|
+
'type', format_type(a.atttypid, a.atttypmod),
|
|
74
|
+
'not_null', a.attnotnull,
|
|
75
|
+
'default_expr', pg_get_expr(d.adbin, d.adrelid),
|
|
76
|
+
'generated', nullif(a.attgenerated, ''),
|
|
77
|
+
'identity', a.attidentity <> '',
|
|
78
|
+
'enum_labels', (
|
|
79
|
+
SELECT to_jsonb(array_agg(e.enumlabel::text ORDER BY e.enumsortorder))
|
|
80
|
+
FROM pg_enum e WHERE e.enumtypid = t.oid
|
|
81
|
+
),
|
|
82
|
+
'base_type', CASE WHEN t.typtype = 'd'
|
|
83
|
+
THEN format_type(t.typbasetype, a.atttypmod) END,
|
|
84
|
+
'is_array', t.typcategory = 'A'
|
|
85
|
+
) AS c
|
|
86
|
+
FROM pg_attribute a
|
|
87
|
+
JOIN pg_type t ON t.oid = a.atttypid
|
|
88
|
+
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
89
|
+
WHERE a.attrelid = format('%I.%I', source, tab)::regclass
|
|
90
|
+
AND a.attnum > 0
|
|
91
|
+
AND NOT a.attisdropped
|
|
92
|
+
) rows;
|
|
93
|
+
$$;
|
|
94
|
+
|
|
95
|
+
/* Primary keys, uniques, checks and foreign keys, in Postgres's own words. */
|
|
96
|
+
CREATE OR REPLACE FUNCTION __KN__.read_constraints(source text, tab text)
|
|
97
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
98
|
+
SELECT coalesce(jsonb_agg(c ORDER BY c->>'name'), '[]'::jsonb)
|
|
99
|
+
FROM (
|
|
100
|
+
SELECT jsonb_build_object(
|
|
101
|
+
'name', con.conname,
|
|
102
|
+
'definition', pg_get_constraintdef(con.oid),
|
|
103
|
+
'kind', con.contype
|
|
104
|
+
) AS c
|
|
105
|
+
FROM pg_constraint con
|
|
106
|
+
WHERE con.conrelid = format('%I.%I', source, tab)::regclass
|
|
107
|
+
) rows;
|
|
108
|
+
$$;
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* Unique indexes, which are the other half of "can this happen twice".
|
|
112
|
+
*
|
|
113
|
+
* Primary keys and indexes backing a constraint are left out: those come along
|
|
114
|
+
* with the constraint itself, and creating them again is an error.
|
|
115
|
+
*/
|
|
116
|
+
CREATE OR REPLACE FUNCTION __KN__.read_indexes(source text)
|
|
117
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
118
|
+
SELECT coalesce(jsonb_agg(i ORDER BY i->>'table_name', i->>'name'), '[]'::jsonb)
|
|
119
|
+
FROM (
|
|
120
|
+
SELECT jsonb_build_object(
|
|
121
|
+
'name', c.relname,
|
|
122
|
+
'table_name', t.relname,
|
|
123
|
+
'definition', pg_get_indexdef(i.indexrelid)
|
|
124
|
+
) AS i
|
|
125
|
+
FROM pg_index i
|
|
126
|
+
JOIN pg_class c ON c.oid = i.indexrelid
|
|
127
|
+
JOIN pg_class t ON t.oid = i.indrelid
|
|
128
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
129
|
+
WHERE n.nspname = source
|
|
130
|
+
AND i.indisunique
|
|
131
|
+
AND NOT i.indisprimary
|
|
132
|
+
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.indexrelid)
|
|
133
|
+
) rows;
|
|
134
|
+
$$;
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
* The policies. These are the thing under test, so they are read verbatim.
|
|
138
|
+
*
|
|
139
|
+
* pg_policies hands back qual and with_check already rendered as SQL, which is
|
|
140
|
+
* what makes an exact copy possible at all. roles comes back as name[], so it
|
|
141
|
+
* is cast to text[] before going into JSON for the same reason enum labels are.
|
|
142
|
+
*/
|
|
143
|
+
CREATE OR REPLACE FUNCTION __KN__.read_policies(source text)
|
|
144
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
145
|
+
SELECT coalesce(jsonb_agg(p ORDER BY p->>'table_name', p->>'name'), '[]'::jsonb)
|
|
146
|
+
FROM (
|
|
147
|
+
SELECT jsonb_build_object(
|
|
148
|
+
'table_name', tablename,
|
|
149
|
+
'name', policyname,
|
|
150
|
+
'permissive', permissive,
|
|
151
|
+
'roles', to_jsonb(roles::text[]),
|
|
152
|
+
'cmd', cmd,
|
|
153
|
+
'qual', qual,
|
|
154
|
+
'with_check', with_check
|
|
155
|
+
) AS p
|
|
156
|
+
FROM pg_policies
|
|
157
|
+
WHERE schemaname = source
|
|
158
|
+
) rows;
|
|
159
|
+
$$;
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* Who was granted what. A policy is irrelevant if the grant is not there.
|
|
163
|
+
*
|
|
164
|
+
* Ordinary tables only. role_table_grants also lists views, and replaying a
|
|
165
|
+
* grant on a view the copy does not contain fails outright - which took down
|
|
166
|
+
* the scan of any app with a view in it.
|
|
167
|
+
*/
|
|
168
|
+
CREATE OR REPLACE FUNCTION __KN__.read_grants(source text)
|
|
169
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
170
|
+
SELECT coalesce(jsonb_agg(g ORDER BY g->>'table_name', g->>'grantee', g->>'privilege_type'), '[]'::jsonb)
|
|
171
|
+
FROM (
|
|
172
|
+
SELECT jsonb_build_object(
|
|
173
|
+
'table_name', gr.table_name,
|
|
174
|
+
'grantee', gr.grantee,
|
|
175
|
+
'privilege_type', gr.privilege_type
|
|
176
|
+
) AS g
|
|
177
|
+
FROM information_schema.role_table_grants gr
|
|
178
|
+
JOIN pg_class c ON c.relname = gr.table_name
|
|
179
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = gr.table_schema
|
|
180
|
+
WHERE gr.table_schema = source
|
|
181
|
+
AND c.relkind = 'r'
|
|
182
|
+
AND gr.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
|
|
183
|
+
) rows;
|
|
184
|
+
$$;
|
|
185
|
+
|
|
186
|
+
/*
|
|
187
|
+
* Views, which are a door of their own.
|
|
188
|
+
*
|
|
189
|
+
* A view runs with its creator's rights unless it says otherwise, so one over
|
|
190
|
+
* a table that row level security protects hands out every row in that table.
|
|
191
|
+
* security_invoker is what turns that off and it lives in reloptions, so it
|
|
192
|
+
* travels with the view.
|
|
193
|
+
*/
|
|
194
|
+
CREATE OR REPLACE FUNCTION __KN__.read_views(source text)
|
|
195
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
196
|
+
SELECT coalesce(jsonb_agg(v ORDER BY v->>'name'), '[]'::jsonb)
|
|
197
|
+
FROM (
|
|
198
|
+
SELECT jsonb_build_object(
|
|
199
|
+
'name', c.relname,
|
|
200
|
+
'definition', pg_get_viewdef(c.oid, true),
|
|
201
|
+
'materialised', c.relkind = 'm',
|
|
202
|
+
'options', array_to_string(c.reloptions, ', '),
|
|
203
|
+
'columns', __KN__.read_columns(source, c.relname)
|
|
204
|
+
) AS v
|
|
205
|
+
FROM pg_class c
|
|
206
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
207
|
+
WHERE n.nspname = source AND c.relkind IN ('v', 'm')
|
|
208
|
+
) rows;
|
|
209
|
+
$$;
|
|
210
|
+
|
|
211
|
+
/*
|
|
212
|
+
* Who was granted what on the sequences a table depends on.
|
|
213
|
+
*
|
|
214
|
+
* Without these the copy is not the app, in the one way that matters most.
|
|
215
|
+
* Supabase grants anon USAGE on every sequence in public, so a stranger can
|
|
216
|
+
* insert into a table whose key is a serial. The copy replayed the table
|
|
217
|
+
* grants and not the sequence ones, so every insert the tampering attack
|
|
218
|
+
* tried came back 'permission denied for sequence' - which reads as the
|
|
219
|
+
* attack being beaten. "A stranger can add rows to your table" was never
|
|
220
|
+
* reported on any table with a serial key, which is most tables.
|
|
221
|
+
*
|
|
222
|
+
* Only sequences a column owns. Those are the ones the copy has - measured,
|
|
223
|
+
* not assumed. A sequence standing on its own does not come across, and
|
|
224
|
+
* nothing inserts into one, so a grant on it changes no verdict.
|
|
225
|
+
*/
|
|
226
|
+
CREATE OR REPLACE FUNCTION __KN__.read_sequence_grants(source text)
|
|
227
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
228
|
+
SELECT coalesce(jsonb_agg(g ORDER BY g->>'sequence_name', g->>'grantee', g->>'privilege_type'),
|
|
229
|
+
'[]'::jsonb)
|
|
230
|
+
FROM (
|
|
231
|
+
SELECT DISTINCT jsonb_build_object(
|
|
232
|
+
'sequence_name', s.relname,
|
|
233
|
+
'grantee', CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END,
|
|
234
|
+
'privilege_type', a.privilege_type
|
|
235
|
+
) AS g
|
|
236
|
+
FROM pg_class s
|
|
237
|
+
JOIN pg_namespace n ON n.oid = s.relnamespace
|
|
238
|
+
JOIN pg_depend d ON d.objid = s.oid
|
|
239
|
+
AND d.classid = 'pg_class'::regclass
|
|
240
|
+
AND d.deptype IN ('a', 'i')
|
|
241
|
+
CROSS JOIN LATERAL aclexplode(s.relacl) a
|
|
242
|
+
WHERE n.nspname = source AND s.relkind = 'S'
|
|
243
|
+
AND (CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END)
|
|
244
|
+
IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
|
|
245
|
+
) rows;
|
|
246
|
+
$$;
|
|
247
|
+
|
|
248
|
+
/* The grants on views. Separate, because views are created after the tables. */
|
|
249
|
+
CREATE OR REPLACE FUNCTION __KN__.read_view_grants(source text)
|
|
250
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
251
|
+
SELECT coalesce(jsonb_agg(g ORDER BY g->>'table_name', g->>'grantee', g->>'privilege_type'), '[]'::jsonb)
|
|
252
|
+
FROM (
|
|
253
|
+
SELECT jsonb_build_object(
|
|
254
|
+
'table_name', gr.table_name,
|
|
255
|
+
'grantee', gr.grantee,
|
|
256
|
+
'privilege_type', gr.privilege_type
|
|
257
|
+
) AS g
|
|
258
|
+
FROM information_schema.role_table_grants gr
|
|
259
|
+
JOIN pg_class c ON c.relname = gr.table_name
|
|
260
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = gr.table_schema
|
|
261
|
+
WHERE gr.table_schema = source
|
|
262
|
+
AND c.relkind IN ('v', 'm')
|
|
263
|
+
AND gr.grantee IN ('anon', 'authenticated', 'service_role', 'PUBLIC')
|
|
264
|
+
) rows;
|
|
265
|
+
$$;
|
|
266
|
+
|
|
267
|
+
/*
|
|
268
|
+
* The app's own enums and domains.
|
|
269
|
+
*
|
|
270
|
+
* Copied, so that the copy stands on its own. Borrowing them from the schema
|
|
271
|
+
* it was copied from worked right up until a view mentioned one:
|
|
272
|
+
* pg_get_viewdef writes a literal as 'paid'::app.order_status, the rewrite
|
|
273
|
+
* turned app into the copy, and the copy had no such type. Any app with a
|
|
274
|
+
* view over an enum column crashed the scan outright.
|
|
275
|
+
*
|
|
276
|
+
* Enums are listed first. A domain can be built on one, and a domain created
|
|
277
|
+
* before the enum it stands on is a type that does not exist yet.
|
|
278
|
+
*/
|
|
279
|
+
CREATE OR REPLACE FUNCTION __KN__.read_types(source text)
|
|
280
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
281
|
+
SELECT coalesce(jsonb_agg(entry ORDER BY sort_first, name), '[]'::jsonb)
|
|
282
|
+
FROM (
|
|
283
|
+
SELECT CASE WHEN t.typtype = 'e' THEN 0 ELSE 1 END AS sort_first,
|
|
284
|
+
t.typname AS name,
|
|
285
|
+
jsonb_build_object(
|
|
286
|
+
'name', t.typname,
|
|
287
|
+
'kind', t.typtype,
|
|
288
|
+
'labels', CASE WHEN t.typtype = 'e' THEN to_jsonb((
|
|
289
|
+
SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
|
|
290
|
+
FROM pg_enum e WHERE e.enumtypid = t.oid
|
|
291
|
+
)) END,
|
|
292
|
+
'base_type', CASE WHEN t.typtype = 'd'
|
|
293
|
+
THEN format_type(t.typbasetype, t.typtypmod) END,
|
|
294
|
+
'constraints', CASE WHEN t.typtype = 'd' THEN to_jsonb((
|
|
295
|
+
SELECT array_agg(pg_get_constraintdef(c.oid) ORDER BY c.conname)
|
|
296
|
+
FROM pg_constraint c WHERE c.contypid = t.oid
|
|
297
|
+
)) END,
|
|
298
|
+
'not_null', t.typnotnull,
|
|
299
|
+
'default_value', CASE WHEN t.typtype = 'd' THEN t.typdefault END
|
|
300
|
+
) AS entry
|
|
301
|
+
FROM pg_type t
|
|
302
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
303
|
+
WHERE n.nspname = source
|
|
304
|
+
AND t.typtype IN ('e', 'd')
|
|
305
|
+
) rows;
|
|
306
|
+
$$;
|
|
307
|
+
|
|
308
|
+
/* Everything needed to rebuild a schema, in one shape. */
|
|
309
|
+
CREATE OR REPLACE FUNCTION __KN__.read_schema(source text)
|
|
310
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
311
|
+
DECLARE
|
|
312
|
+
tables jsonb := '[]'::jsonb;
|
|
313
|
+
one jsonb;
|
|
314
|
+
BEGIN
|
|
315
|
+
FOR one IN SELECT * FROM jsonb_array_elements(__KN__.read_tables(source)) LOOP
|
|
316
|
+
tables := tables || jsonb_build_array(
|
|
317
|
+
one
|
|
318
|
+
|| jsonb_build_object('columns', __KN__.read_columns(source, one->>'name'))
|
|
319
|
+
|| jsonb_build_object('constraints', __KN__.read_constraints(source, one->>'name'))
|
|
320
|
+
);
|
|
321
|
+
END LOOP;
|
|
322
|
+
|
|
323
|
+
RETURN jsonb_build_object(
|
|
324
|
+
'schema', source,
|
|
325
|
+
'tables', tables,
|
|
326
|
+
'external', __KN__.read_external(source, tables),
|
|
327
|
+
'policies', __KN__.read_policies(source),
|
|
328
|
+
'grants', __KN__.read_grants(source),
|
|
329
|
+
'sequenceGrants', __KN__.read_sequence_grants(source),
|
|
330
|
+
'types', __KN__.read_types(source),
|
|
331
|
+
'indexes', __KN__.read_indexes(source),
|
|
332
|
+
'views', __KN__.read_views(source),
|
|
333
|
+
'viewGrants', __KN__.read_view_grants(source),
|
|
334
|
+
-- A foreign key pointing at something whose shape could not be read is
|
|
335
|
+
-- not something to guess at. The caller stops rather than attacking a
|
|
336
|
+
-- copy that is missing a piece.
|
|
337
|
+
'unsupported', (
|
|
338
|
+
SELECT coalesce(jsonb_agg(
|
|
339
|
+
'a foreign key points at ' || (e->>'schema') || '.' || (e->>'table') ||
|
|
340
|
+
', and I could not read its shape to stand in for it'
|
|
341
|
+
), '[]'::jsonb)
|
|
342
|
+
FROM jsonb_array_elements(__KN__.read_external(source, tables)) e
|
|
343
|
+
WHERE jsonb_array_length(e->'columns') <> jsonb_array_length(e->'wanted')
|
|
344
|
+
)
|
|
345
|
+
);
|
|
346
|
+
END $$;
|
|
347
|
+
-- --------------------------------------------------------------------------
|
|
348
|
+
-- Slice 2: reading what the schema points at, and building the copy.
|
|
349
|
+
--
|
|
350
|
+
-- Appended to engine.sql. Mirrors readExternalTargets and writeSchema in
|
|
351
|
+
-- schema.js, statement for statement, because twin.check.js compares the two
|
|
352
|
+
-- copies afterwards and any drift shows up as a difference nobody can explain.
|
|
353
|
+
-- --------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/* What a foreign key points at, pulled out of Postgres's own wording. */
|
|
356
|
+
CREATE OR REPLACE FUNCTION __KN__.reference_in(definition text)
|
|
357
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
358
|
+
DECLARE
|
|
359
|
+
hit text[];
|
|
360
|
+
target text;
|
|
361
|
+
parts text[];
|
|
362
|
+
BEGIN
|
|
363
|
+
hit := regexp_match(definition, 'FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s+([^\s(]+)\s*\(([^)]+)\)', 'i');
|
|
364
|
+
IF hit IS NULL THEN RETURN NULL; END IF;
|
|
365
|
+
|
|
366
|
+
target := btrim(hit[2]);
|
|
367
|
+
-- Split on a dot that is not inside quotes, the same way the Node side does.
|
|
368
|
+
parts := regexp_split_to_array(target, '\.(?=(?:[^"]*"[^"]*")*[^"]*$)');
|
|
369
|
+
|
|
370
|
+
RETURN jsonb_build_object(
|
|
371
|
+
'schema', CASE WHEN array_length(parts, 1) > 1 THEN btrim(parts[1], '"') END,
|
|
372
|
+
'table', btrim(parts[array_length(parts, 1)], '"'),
|
|
373
|
+
'columns', (SELECT to_jsonb(array_agg(btrim(btrim(x), '"')))
|
|
374
|
+
FROM unnest(string_to_array(hit[3], ',')) x)
|
|
375
|
+
);
|
|
376
|
+
END $$;
|
|
377
|
+
|
|
378
|
+
/*
|
|
379
|
+
* The name a stand-in for an outside table is given inside the copy.
|
|
380
|
+
*
|
|
381
|
+
* It keeps moving until it clashes with nothing the customer already has.
|
|
382
|
+
* Identifying our own tables by their prefix alone meant a customer table that
|
|
383
|
+
* happened to start the same way was silently dropped from every attack.
|
|
384
|
+
*/
|
|
385
|
+
CREATE OR REPLACE FUNCTION __KN__.stub_name_for(ref_schema text, ref_table text, taken jsonb)
|
|
386
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
387
|
+
DECLARE
|
|
388
|
+
base text := 'kn_ext__' || ref_schema || '__' || ref_table;
|
|
389
|
+
name text := base;
|
|
390
|
+
nth integer := 2;
|
|
391
|
+
BEGIN
|
|
392
|
+
WHILE taken ? name LOOP
|
|
393
|
+
name := base || '__' || nth;
|
|
394
|
+
nth := nth + 1;
|
|
395
|
+
END LOOP;
|
|
396
|
+
RETURN name;
|
|
397
|
+
END $$;
|
|
398
|
+
|
|
399
|
+
/*
|
|
400
|
+
* Tables outside this schema that its foreign keys point at.
|
|
401
|
+
*
|
|
402
|
+
* Nearly every Supabase app has `references auth.users(id)`, and the copy
|
|
403
|
+
* cannot carry that as written: pointed at the real auth.users, every seeded
|
|
404
|
+
* row becomes a write into the customer's own authentication table. So the
|
|
405
|
+
* shape of the outside table is read - columns and types, never rows - and a
|
|
406
|
+
* stand-in is built inside the copy instead.
|
|
407
|
+
*/
|
|
408
|
+
CREATE OR REPLACE FUNCTION __KN__.read_external(source text, tables jsonb)
|
|
409
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
410
|
+
DECLARE
|
|
411
|
+
tab jsonb;
|
|
412
|
+
con jsonb;
|
|
413
|
+
points jsonb;
|
|
414
|
+
key text;
|
|
415
|
+
wanted jsonb := '{}'::jsonb;
|
|
416
|
+
entry jsonb;
|
|
417
|
+
out jsonb := '[]'::jsonb;
|
|
418
|
+
names jsonb := '{}'::jsonb;
|
|
419
|
+
cols jsonb;
|
|
420
|
+
BEGIN
|
|
421
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
422
|
+
names := names || jsonb_build_object(tab->>'name', true);
|
|
423
|
+
FOR con IN SELECT * FROM jsonb_array_elements(tab->'constraints') LOOP
|
|
424
|
+
CONTINUE WHEN con->>'kind' <> 'f';
|
|
425
|
+
points := __KN__.reference_in(con->>'definition');
|
|
426
|
+
CONTINUE WHEN points IS NULL
|
|
427
|
+
OR points->>'schema' IS NULL
|
|
428
|
+
OR points->>'schema' = source;
|
|
429
|
+
key := (points->>'schema') || '.' || (points->>'table');
|
|
430
|
+
IF NOT (wanted ? key) THEN
|
|
431
|
+
wanted := wanted || jsonb_build_object(key, jsonb_build_object(
|
|
432
|
+
'schema', points->'schema', 'table', points->'table', 'columns', '[]'::jsonb));
|
|
433
|
+
END IF;
|
|
434
|
+
wanted := jsonb_set(wanted, ARRAY[key, 'columns'],
|
|
435
|
+
(SELECT to_jsonb(array_agg(DISTINCT c))
|
|
436
|
+
FROM (SELECT jsonb_array_elements_text(wanted->key->'columns') AS c
|
|
437
|
+
UNION SELECT jsonb_array_elements_text(points->'columns')) u));
|
|
438
|
+
END LOOP;
|
|
439
|
+
END LOOP;
|
|
440
|
+
|
|
441
|
+
FOR entry IN SELECT value FROM jsonb_each(wanted) LOOP
|
|
442
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object('name', a.attname,
|
|
443
|
+
'type', format_type(a.atttypid, a.atttypmod))
|
|
444
|
+
ORDER BY a.attnum), '[]'::jsonb)
|
|
445
|
+
INTO cols
|
|
446
|
+
FROM pg_attribute a
|
|
447
|
+
WHERE a.attrelid = format('%I.%I', entry->>'schema', entry->>'table')::regclass
|
|
448
|
+
AND a.attname = ANY (SELECT jsonb_array_elements_text(entry->'columns'))
|
|
449
|
+
AND a.attnum > 0 AND NOT a.attisdropped;
|
|
450
|
+
|
|
451
|
+
out := out || jsonb_build_array(jsonb_build_object(
|
|
452
|
+
'schema', entry->'schema',
|
|
453
|
+
'table', entry->'table',
|
|
454
|
+
'columns', cols,
|
|
455
|
+
'stub', __KN__.stub_name_for(entry->>'schema', entry->>'table', names),
|
|
456
|
+
'wanted', entry->'columns'
|
|
457
|
+
));
|
|
458
|
+
END LOOP;
|
|
459
|
+
|
|
460
|
+
RETURN out;
|
|
461
|
+
EXCEPTION WHEN OTHERS THEN
|
|
462
|
+
-- A target whose shape cannot be read at all comes back with no columns, and
|
|
463
|
+
-- the caller treats that as unsupported rather than guessing at it.
|
|
464
|
+
RETURN out;
|
|
465
|
+
END $$;
|
|
466
|
+
|
|
467
|
+
/*
|
|
468
|
+
* A name in double quotes, always.
|
|
469
|
+
*
|
|
470
|
+
* NOT quote_ident, which only quotes when it has to: quote_ident('app') is
|
|
471
|
+
* app, so a rewrite built on it went looking for app. and walked straight
|
|
472
|
+
* past "app". - and the copy kept a live reference into the customer real
|
|
473
|
+
* schema, which is the most dangerous bug this product has had. The Node
|
|
474
|
+
* side always quotes, so this does too, and the twin check is what noticed
|
|
475
|
+
* they had stopped agreeing.
|
|
476
|
+
*/
|
|
477
|
+
CREATE OR REPLACE FUNCTION __KN__.always_quote(name text)
|
|
478
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
479
|
+
SELECT '"' || replace(name, '"', '""') || '"';
|
|
480
|
+
$$;
|
|
481
|
+
|
|
482
|
+
/*
|
|
483
|
+
* Points anything schema-qualified at the copy instead of the original.
|
|
484
|
+
*
|
|
485
|
+
* Both spellings, and that is the whole point of this existing. Postgres
|
|
486
|
+
* writes a name unquoted when it does not need quoting, so a foreign key came
|
|
487
|
+
* back as "REFERENCES app.profiles(id)" while only the quoted form was being
|
|
488
|
+
* rewritten - and the copy was created holding a live reference into the
|
|
489
|
+
* customer's real schema.
|
|
490
|
+
*/
|
|
491
|
+
CREATE OR REPLACE FUNCTION __KN__.rewrite_schema_refs(expr text, from_schema text, to_schema text)
|
|
492
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
493
|
+
SELECT CASE WHEN expr IS NULL THEN NULL ELSE
|
|
494
|
+
replace(
|
|
495
|
+
replace(expr, __KN__.always_quote(from_schema) || '.', __KN__.always_quote(to_schema) || '.'),
|
|
496
|
+
from_schema || '.', to_schema || '.')
|
|
497
|
+
END;
|
|
498
|
+
$$;
|
|
499
|
+
|
|
500
|
+
/*
|
|
501
|
+
* Points a reference with no schema on it at the copy.
|
|
502
|
+
*
|
|
503
|
+
* The one the rewrite above cannot see. pg_get_constraintdef writes the schema
|
|
504
|
+
* only when the referenced table is NOT reachable through search_path, so an
|
|
505
|
+
* app in a schema of its own comes back as "REFERENCES app.profiles(id)" and
|
|
506
|
+
* is rewritten, while the same app in `public` comes back as "REFERENCES
|
|
507
|
+
* profiles(id)" and there is nothing to rewrite. Replayed into the copy, that
|
|
508
|
+
* bare name resolves through search_path again and binds to the customer's
|
|
509
|
+
* real table.
|
|
510
|
+
*
|
|
511
|
+
* Every fixture in this repo builds its app in a schema of its own, which is
|
|
512
|
+
* on nobody's path - so both engines were wrong in exactly the way no check
|
|
513
|
+
* here could see, and the twin check agreed with itself. Found in the node
|
|
514
|
+
* engine on a real Supabase project, then measured here.
|
|
515
|
+
*
|
|
516
|
+
* Only names the plan itself owns are touched: anything outside the schema is
|
|
517
|
+
* the stand-in's business. The matched text is replaced literally rather than
|
|
518
|
+
* through regexp_replace, so a table name that happens to contain a regex
|
|
519
|
+
* character cannot change what gets rewritten.
|
|
520
|
+
*/
|
|
521
|
+
CREATE OR REPLACE FUNCTION __KN__.qualify_own_refs(definition text, plan jsonb, target text)
|
|
522
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
523
|
+
DECLARE
|
|
524
|
+
hit text[];
|
|
525
|
+
name text;
|
|
526
|
+
BEGIN
|
|
527
|
+
IF definition IS NULL THEN RETURN NULL; END IF;
|
|
528
|
+
-- Group 1 is the whole reference, group 2 the name as written. A quoted
|
|
529
|
+
-- identifier is matched whole, because it can hold spaces and quotes of its
|
|
530
|
+
-- own doubled up - the first attempt read a run of safe characters instead
|
|
531
|
+
-- and silently did not match REFERENCES "Group Table"(...). The unquoted
|
|
532
|
+
-- alternative excludes a dot, so an already-qualified name is left to the
|
|
533
|
+
-- rewrite above.
|
|
534
|
+
hit := regexp_match(definition, '(REFERENCES\s+("(?:[^"]|"")*"|[^\s(".]+)\s*\()');
|
|
535
|
+
IF hit IS NULL THEN RETURN definition; END IF;
|
|
536
|
+
name := CASE WHEN left(hit[2], 1) = '"'
|
|
537
|
+
THEN replace(substring(hit[2] from 2 for length(hit[2]) - 2), '""', '"')
|
|
538
|
+
ELSE hit[2] END;
|
|
539
|
+
IF NOT EXISTS (
|
|
540
|
+
SELECT 1 FROM jsonb_array_elements(plan->'tables') t WHERE t->>'name' = name
|
|
541
|
+
) THEN
|
|
542
|
+
RETURN definition;
|
|
543
|
+
END IF;
|
|
544
|
+
RETURN replace(
|
|
545
|
+
definition,
|
|
546
|
+
hit[1],
|
|
547
|
+
'REFERENCES ' || __KN__.always_quote(target) || '.' || __KN__.always_quote(name) || '(');
|
|
548
|
+
END $$;
|
|
549
|
+
|
|
550
|
+
/* Points a foreign key at the stand-in instead of at the real outside table. */
|
|
551
|
+
CREATE OR REPLACE FUNCTION __KN__.rewrite_external_refs(expr text, external jsonb, to_schema text)
|
|
552
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
553
|
+
DECLARE
|
|
554
|
+
out text := expr;
|
|
555
|
+
target jsonb;
|
|
556
|
+
stub text;
|
|
557
|
+
BEGIN
|
|
558
|
+
IF expr IS NULL THEN RETURN NULL; END IF;
|
|
559
|
+
FOR target IN SELECT * FROM jsonb_array_elements(external) LOOP
|
|
560
|
+
stub := __KN__.always_quote(to_schema) || '.' || __KN__.always_quote(target->>'stub');
|
|
561
|
+
out := replace(out, __KN__.always_quote(target->>'schema') || '.' || __KN__.always_quote(target->>'table'), stub);
|
|
562
|
+
out := replace(out, (target->>'schema') || '.' || (target->>'table'), stub);
|
|
563
|
+
END LOOP;
|
|
564
|
+
RETURN out;
|
|
565
|
+
END $$;
|
|
566
|
+
|
|
567
|
+
/* Two people who do not exist, used wherever a stand-in row is needed. */
|
|
568
|
+
CREATE OR REPLACE FUNCTION __KN__.identities() RETURNS text[] LANGUAGE sql IMMUTABLE AS $$
|
|
569
|
+
SELECT ARRAY['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'];
|
|
570
|
+
$$;
|
|
571
|
+
|
|
572
|
+
/* Something of the right type to put in a stand-in row. */
|
|
573
|
+
CREATE OR REPLACE FUNCTION __KN__.stub_value(kind text, nth integer)
|
|
574
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
575
|
+
DECLARE k text := lower(kind);
|
|
576
|
+
BEGIN
|
|
577
|
+
IF k = 'uuid' THEN RETURN quote_literal((__KN__.identities())[nth + 1]); END IF;
|
|
578
|
+
IF k ~ '^(integer|bigint|smallint|numeric|decimal|real|double)' THEN RETURN (nth + 1)::text; END IF;
|
|
579
|
+
IF k ~ '^bool' THEN RETURN CASE WHEN nth = 0 THEN 'true' ELSE 'false' END; END IF;
|
|
580
|
+
IF k ~ '^(timestamp|date)' THEN RETURN 'now()'; END IF;
|
|
581
|
+
RETURN quote_literal('kryptheon-' || (nth + 1));
|
|
582
|
+
END $$;
|
|
583
|
+
|
|
584
|
+
/*
|
|
585
|
+
* Nothing may be written outside the copy. Ever.
|
|
586
|
+
*
|
|
587
|
+
* The product is sold on one sentence - we never touch your live app - and
|
|
588
|
+
* this is the line that keeps it true. A structural guard rather than a
|
|
589
|
+
* careful habit, because the two statements that broke the promise were
|
|
590
|
+
* written carefully and sat there for weeks.
|
|
591
|
+
*/
|
|
592
|
+
CREATE OR REPLACE FUNCTION __KN__.must_stay_inside(statements jsonb, target text)
|
|
593
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
594
|
+
DECLARE s text;
|
|
595
|
+
BEGIN
|
|
596
|
+
FOR s IN SELECT jsonb_array_elements_text(statements) LOOP
|
|
597
|
+
IF position(__KN__.always_quote(target) in s) > 0 THEN CONTINUE; END IF;
|
|
598
|
+
IF s ~ ('(^|[^A-Za-z0-9_"])' || target || '\.') THEN CONTINUE; END IF;
|
|
599
|
+
RAISE EXCEPTION 'refusing to run a statement that does not stay inside the copy %: %', target, s;
|
|
600
|
+
END LOOP;
|
|
601
|
+
RETURN statements;
|
|
602
|
+
END $$;
|
|
603
|
+
|
|
604
|
+
/* Which of these roles this database actually has. */
|
|
605
|
+
CREATE OR REPLACE FUNCTION __KN__.existing_roles(wanted text[])
|
|
606
|
+
RETURNS text[] LANGUAGE sql STABLE AS $$
|
|
607
|
+
SELECT coalesce(array_agg(rolname ORDER BY rolname), ARRAY[]::text[])
|
|
608
|
+
FROM pg_roles WHERE rolname = ANY (wanted);
|
|
609
|
+
$$;
|
|
610
|
+
|
|
611
|
+
-- --------------------------------------------------------------------------
|
|
612
|
+
-- Building the copy.
|
|
613
|
+
--
|
|
614
|
+
-- Mirrors writeSchema in schema.js statement for statement. The order is not
|
|
615
|
+
-- a preference: a policy cannot be created before the table it guards, a
|
|
616
|
+
-- foreign key cannot be added before the key it points at exists, and a grant
|
|
617
|
+
-- given after a policy would widen access the original never had.
|
|
618
|
+
-- --------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
/* One column, written the way CREATE TABLE wants it. */
|
|
621
|
+
CREATE OR REPLACE FUNCTION __KN__.column_definition(col jsonb, source text, target text, external jsonb)
|
|
622
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
623
|
+
DECLARE
|
|
624
|
+
-- The type is rewritten like everything else, now that the copy has its
|
|
625
|
+
-- own. Left alone, the copy leaned on the original for them.
|
|
626
|
+
parts text := __KN__.always_quote(col->>'name') || ' '
|
|
627
|
+
|| __KN__.rewrite_schema_refs(col->>'type', source, target);
|
|
628
|
+
fallback text := __KN__.rewrite_external_refs(
|
|
629
|
+
__KN__.rewrite_schema_refs(col->>'default_expr', source, target), external, target);
|
|
630
|
+
BEGIN
|
|
631
|
+
IF col->>'generated' IS NOT NULL THEN
|
|
632
|
+
-- A stored generated column carries its expression in default_expr, and
|
|
633
|
+
-- replaying that as a DEFAULT is a syntax error - "cannot use column
|
|
634
|
+
-- reference in DEFAULT expression" - which took the whole copy down.
|
|
635
|
+
parts := parts || ' GENERATED ALWAYS AS (' || fallback || ') STORED';
|
|
636
|
+
ELSIF (col->>'identity')::boolean THEN
|
|
637
|
+
parts := parts || ' GENERATED ALWAYS AS IDENTITY';
|
|
638
|
+
ELSIF fallback IS NOT NULL THEN
|
|
639
|
+
parts := parts || ' DEFAULT ' || fallback;
|
|
640
|
+
END IF;
|
|
641
|
+
|
|
642
|
+
IF (col->>'not_null')::boolean
|
|
643
|
+
AND col->>'generated' IS NULL
|
|
644
|
+
AND NOT (col->>'identity')::boolean THEN
|
|
645
|
+
parts := parts || ' NOT NULL';
|
|
646
|
+
END IF;
|
|
647
|
+
|
|
648
|
+
RETURN parts;
|
|
649
|
+
END $$;
|
|
650
|
+
|
|
651
|
+
/*
|
|
652
|
+
* Every statement that rebuilds one schema as another.
|
|
653
|
+
*
|
|
654
|
+
* Returned rather than run, so that the guard can look at all of them before
|
|
655
|
+
* a single one touches the database, and so that a test can read what would
|
|
656
|
+
* have happened without anything happening.
|
|
657
|
+
*/
|
|
658
|
+
CREATE OR REPLACE FUNCTION __KN__.copy_statements(plan jsonb, target text)
|
|
659
|
+
RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
|
|
660
|
+
DECLARE
|
|
661
|
+
source text := plan->>'schema';
|
|
662
|
+
external jsonb := coalesce(plan->'external', '[]'::jsonb);
|
|
663
|
+
out text[] := ARRAY[]::text[];
|
|
664
|
+
tab jsonb;
|
|
665
|
+
col jsonb;
|
|
666
|
+
con jsonb;
|
|
667
|
+
idx jsonb;
|
|
668
|
+
view_ jsonb;
|
|
669
|
+
grant_ jsonb;
|
|
670
|
+
pol jsonb;
|
|
671
|
+
stub jsonb;
|
|
672
|
+
made jsonb;
|
|
673
|
+
cols text[];
|
|
674
|
+
bare text;
|
|
675
|
+
want_foreign boolean;
|
|
676
|
+
role_ text;
|
|
677
|
+
nth integer;
|
|
678
|
+
values_ text[];
|
|
679
|
+
here text := __KN__.always_quote(target);
|
|
680
|
+
BEGIN
|
|
681
|
+
out := out || ('CREATE SCHEMA ' || here);
|
|
682
|
+
|
|
683
|
+
-- The app's own types first: a column, a check, a view or a cast can all
|
|
684
|
+
-- name one, and nothing that mentions a type can be created before it.
|
|
685
|
+
FOR made IN SELECT * FROM jsonb_array_elements(coalesce(plan->'types', '[]'::jsonb)) LOOP
|
|
686
|
+
IF made->>'kind' = 'e' THEN
|
|
687
|
+
out := out || ('CREATE TYPE ' || here || '.' || __KN__.always_quote(made->>'name')
|
|
688
|
+
|| ' AS ENUM (' || coalesce((SELECT string_agg(
|
|
689
|
+
'''' || replace(label, '''', '''''') || '''', ', ' ORDER BY at)
|
|
690
|
+
FROM jsonb_array_elements_text(
|
|
691
|
+
CASE WHEN jsonb_typeof(made->'labels') = 'array'
|
|
692
|
+
THEN made->'labels' ELSE '[]'::jsonb END)
|
|
693
|
+
WITH ORDINALITY AS l(label, at)), '') || ')');
|
|
694
|
+
ELSE
|
|
695
|
+
out := out || (('CREATE DOMAIN ' || here || '.' || __KN__.always_quote(made->>'name')
|
|
696
|
+
|| ' AS ' || __KN__.rewrite_schema_refs(made->>'base_type', source, target))
|
|
697
|
+
|| coalesce(' DEFAULT ' || __KN__.rewrite_schema_refs(made->>'default_value', source, target), '')
|
|
698
|
+
|| CASE WHEN (made->>'not_null')::boolean THEN ' NOT NULL' ELSE '' END
|
|
699
|
+
|| coalesce((SELECT string_agg(' ' || __KN__.rewrite_schema_refs(rule, source, target), '' ORDER BY at)
|
|
700
|
+
FROM jsonb_array_elements_text(
|
|
701
|
+
CASE WHEN jsonb_typeof(made->'constraints') = 'array'
|
|
702
|
+
THEN made->'constraints' ELSE '[]'::jsonb END)
|
|
703
|
+
WITH ORDINALITY AS r(rule, at)), ''));
|
|
704
|
+
END IF;
|
|
705
|
+
END LOOP;
|
|
706
|
+
|
|
707
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(plan->'tables') LOOP
|
|
708
|
+
-- Sequences first, or a serial column's default has nothing to point at.
|
|
709
|
+
FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
|
|
710
|
+
CONTINUE WHEN col->>'default_expr' IS NULL;
|
|
711
|
+
bare := (regexp_match(col->>'default_expr', 'nextval\(''([^'']+)'''))[1];
|
|
712
|
+
CONTINUE WHEN bare IS NULL;
|
|
713
|
+
bare := replace(split_part(bare, '.', greatest(array_length(string_to_array(bare, '.'), 1), 1)), '"', '');
|
|
714
|
+
out := out || ('CREATE SEQUENCE IF NOT EXISTS ' || here || '.' || __KN__.always_quote(bare));
|
|
715
|
+
END LOOP;
|
|
716
|
+
|
|
717
|
+
cols := ARRAY[]::text[];
|
|
718
|
+
FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
|
|
719
|
+
cols := cols || __KN__.column_definition(col, source, target, external);
|
|
720
|
+
END LOOP;
|
|
721
|
+
out := out || ('CREATE TABLE ' || here || '.' || __KN__.always_quote(tab->>'name')
|
|
722
|
+
|| ' (' || array_to_string(cols, ', ') || ')');
|
|
723
|
+
|
|
724
|
+
-- And the sequence belongs to its column, the way serial makes it.
|
|
725
|
+
--
|
|
726
|
+
-- Not tidiness. A sequence a column owns is linked to it in pg_depend,
|
|
727
|
+
-- and that link is how the grants on it are found again - so a copy
|
|
728
|
+
-- whose sequences stand loose reads back as having no sequence grants
|
|
729
|
+
-- at all, however many were replayed onto it.
|
|
730
|
+
FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
|
|
731
|
+
CONTINUE WHEN col->>'default_expr' IS NULL;
|
|
732
|
+
bare := (regexp_match(col->>'default_expr', 'nextval\(''([^'']+)'''))[1];
|
|
733
|
+
CONTINUE WHEN bare IS NULL;
|
|
734
|
+
bare := replace(split_part(bare, '.', greatest(array_length(string_to_array(bare, '.'), 1), 1)), '"', '');
|
|
735
|
+
out := out || ('ALTER SEQUENCE ' || here || '.' || __KN__.always_quote(bare)
|
|
736
|
+
|| ' OWNED BY ' || here || '.' || __KN__.always_quote(tab->>'name')
|
|
737
|
+
|| '.' || __KN__.always_quote(col->>'name'));
|
|
738
|
+
END LOOP;
|
|
739
|
+
END LOOP;
|
|
740
|
+
|
|
741
|
+
-- Stand-ins for the tables outside this schema that its foreign keys point
|
|
742
|
+
-- at, with two rows already in them so seeding has something to reference.
|
|
743
|
+
FOR stub IN SELECT * FROM jsonb_array_elements(external) LOOP
|
|
744
|
+
cols := ARRAY[]::text[];
|
|
745
|
+
FOR col IN SELECT * FROM jsonb_array_elements(stub->'columns') LOOP
|
|
746
|
+
cols := cols || (__KN__.always_quote(col->>'name') || ' ' || (col->>'type') || ' NOT NULL');
|
|
747
|
+
END LOOP;
|
|
748
|
+
out := out || ('CREATE TABLE ' || here || '.' || __KN__.always_quote(stub->>'stub')
|
|
749
|
+
|| ' (' || array_to_string(cols, ', ')
|
|
750
|
+
|| ', PRIMARY KEY (' || (SELECT string_agg(__KN__.always_quote(c->>'name'), ', ')
|
|
751
|
+
FROM jsonb_array_elements(stub->'columns') c) || '))');
|
|
752
|
+
FOR nth IN 0..1 LOOP
|
|
753
|
+
values_ := ARRAY[]::text[];
|
|
754
|
+
FOR col IN SELECT * FROM jsonb_array_elements(stub->'columns') LOOP
|
|
755
|
+
values_ := values_ || __KN__.stub_value(col->>'type', nth);
|
|
756
|
+
END LOOP;
|
|
757
|
+
out := out || ('INSERT INTO ' || here || '.' || __KN__.always_quote(stub->>'stub')
|
|
758
|
+
|| ' VALUES (' || array_to_string(values_, ', ') || ')');
|
|
759
|
+
END LOOP;
|
|
760
|
+
END LOOP;
|
|
761
|
+
|
|
762
|
+
-- Keys and uniques across every table first, then the foreign keys. A
|
|
763
|
+
-- foreign key on `orders` used to be created before the primary key on
|
|
764
|
+
-- `profiles` existed, and Postgres refused it.
|
|
765
|
+
FOREACH want_foreign IN ARRAY ARRAY[false, true] LOOP
|
|
766
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(plan->'tables') LOOP
|
|
767
|
+
FOR con IN SELECT * FROM jsonb_array_elements(tab->'constraints') LOOP
|
|
768
|
+
CONTINUE WHEN (con->>'kind' = 'f') <> want_foreign;
|
|
769
|
+
out := out || ('ALTER TABLE ' || here || '.' || __KN__.always_quote(tab->>'name')
|
|
770
|
+
|| ' ADD CONSTRAINT ' || __KN__.always_quote(con->>'name') || ' '
|
|
771
|
+
|| __KN__.qualify_own_refs(
|
|
772
|
+
__KN__.rewrite_external_refs(
|
|
773
|
+
__KN__.rewrite_schema_refs(con->>'definition', source, target),
|
|
774
|
+
external, target),
|
|
775
|
+
plan, target));
|
|
776
|
+
END LOOP;
|
|
777
|
+
END LOOP;
|
|
778
|
+
END LOOP;
|
|
779
|
+
|
|
780
|
+
-- Unique indexes, once every table exists. Every index definition carries
|
|
781
|
+
-- the schema name unquoted, so replayed as-is the copy would build its
|
|
782
|
+
-- indexes on the customer's real tables.
|
|
783
|
+
FOR idx IN SELECT * FROM jsonb_array_elements(coalesce(plan->'indexes', '[]'::jsonb)) LOOP
|
|
784
|
+
out := out || __KN__.rewrite_schema_refs(idx->>'definition', source, target);
|
|
785
|
+
END LOOP;
|
|
786
|
+
|
|
787
|
+
-- Views last, because they read from the tables above.
|
|
788
|
+
FOR view_ IN SELECT * FROM jsonb_array_elements(coalesce(plan->'views', '[]'::jsonb)) LOOP
|
|
789
|
+
out := out || ('CREATE '
|
|
790
|
+
|| CASE WHEN (view_->>'materialised')::boolean THEN 'MATERIALIZED VIEW ' ELSE 'VIEW ' END
|
|
791
|
+
|| here || '.' || __KN__.always_quote(view_->>'name')
|
|
792
|
+
|| CASE WHEN coalesce(view_->>'options', '') <> '' THEN ' WITH (' || (view_->>'options') || ')' ELSE '' END
|
|
793
|
+
|| ' AS ' || __KN__.rewrite_external_refs(
|
|
794
|
+
__KN__.rewrite_schema_refs(view_->>'definition', source, target), external, target));
|
|
795
|
+
END LOOP;
|
|
796
|
+
|
|
797
|
+
FOR grant_ IN SELECT * FROM jsonb_array_elements(coalesce(plan->'viewGrants', '[]'::jsonb)) LOOP
|
|
798
|
+
out := out || ('GRANT ' || (grant_->>'privilege_type') || ' ON ' || here || '.'
|
|
799
|
+
|| __KN__.always_quote(grant_->>'table_name') || ' TO '
|
|
800
|
+
|| CASE WHEN grant_->>'grantee' = 'PUBLIC' THEN 'PUBLIC'
|
|
801
|
+
ELSE __KN__.always_quote(grant_->>'grantee') END);
|
|
802
|
+
END LOOP;
|
|
803
|
+
|
|
804
|
+
-- The copy needs the roles PostgREST switches into to be able to reach it.
|
|
805
|
+
-- Granted on the copy's own schema and nowhere else.
|
|
806
|
+
FOREACH role_ IN ARRAY __KN__.existing_roles(ARRAY['anon', 'authenticated']) LOOP
|
|
807
|
+
out := out || ('GRANT USAGE ON SCHEMA ' || here || ' TO ' || __KN__.always_quote(role_));
|
|
808
|
+
END LOOP;
|
|
809
|
+
|
|
810
|
+
FOR grant_ IN SELECT * FROM jsonb_array_elements(coalesce(plan->'grants', '[]'::jsonb)) LOOP
|
|
811
|
+
out := out || ('GRANT ' || (grant_->>'privilege_type') || ' ON ' || here || '.'
|
|
812
|
+
|| __KN__.always_quote(grant_->>'table_name') || ' TO '
|
|
813
|
+
|| CASE WHEN grant_->>'grantee' = 'PUBLIC' THEN 'PUBLIC'
|
|
814
|
+
ELSE __KN__.always_quote(grant_->>'grantee') END);
|
|
815
|
+
END LOOP;
|
|
816
|
+
|
|
817
|
+
-- The sequences, on the same terms as the tables. A table grant without
|
|
818
|
+
-- the sequence grant that goes with it is a copy nobody can insert into.
|
|
819
|
+
FOR grant_ IN SELECT * FROM jsonb_array_elements(coalesce(plan->'sequenceGrants', '[]'::jsonb)) LOOP
|
|
820
|
+
out := out || ('GRANT ' || (grant_->>'privilege_type') || ' ON SEQUENCE ' || here || '.'
|
|
821
|
+
|| __KN__.always_quote(grant_->>'sequence_name') || ' TO '
|
|
822
|
+
|| CASE WHEN grant_->>'grantee' = 'PUBLIC' THEN 'PUBLIC'
|
|
823
|
+
ELSE __KN__.always_quote(grant_->>'grantee') END);
|
|
824
|
+
END LOOP;
|
|
825
|
+
|
|
826
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(plan->'tables') LOOP
|
|
827
|
+
IF (tab->>'rlsEnabled')::boolean THEN
|
|
828
|
+
out := out || ('ALTER TABLE ' || here || '.' || __KN__.always_quote(tab->>'name')
|
|
829
|
+
|| ' ENABLE ROW LEVEL SECURITY');
|
|
830
|
+
END IF;
|
|
831
|
+
IF (tab->>'rlsForced')::boolean THEN
|
|
832
|
+
out := out || ('ALTER TABLE ' || here || '.' || __KN__.always_quote(tab->>'name')
|
|
833
|
+
|| ' FORCE ROW LEVEL SECURITY');
|
|
834
|
+
END IF;
|
|
835
|
+
END LOOP;
|
|
836
|
+
|
|
837
|
+
FOR pol IN SELECT * FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) LOOP
|
|
838
|
+
out := out || (
|
|
839
|
+
'CREATE POLICY ' || __KN__.always_quote(pol->>'name')
|
|
840
|
+
|| ' ON ' || here || '.' || __KN__.always_quote(pol->>'table_name')
|
|
841
|
+
|| ' AS ' || CASE WHEN pol->>'permissive' = 'PERMISSIVE' THEN 'PERMISSIVE' ELSE 'RESTRICTIVE' END
|
|
842
|
+
|| ' FOR ' || (pol->>'cmd')
|
|
843
|
+
|| ' TO ' || coalesce(nullif((SELECT string_agg(r, ', ')
|
|
844
|
+
FROM jsonb_array_elements_text(pol->'roles') r), ''), 'PUBLIC')
|
|
845
|
+
|| CASE WHEN pol->>'qual' IS NOT NULL THEN ' USING (' || (pol->>'qual') || ')' ELSE '' END
|
|
846
|
+
|| CASE WHEN pol->>'with_check' IS NOT NULL THEN ' WITH CHECK (' || (pol->>'with_check') || ')' ELSE '' END
|
|
847
|
+
);
|
|
848
|
+
END LOOP;
|
|
849
|
+
|
|
850
|
+
RETURN to_jsonb(out);
|
|
851
|
+
END $$;
|
|
852
|
+
|
|
853
|
+
/* Builds the schema again, in a database we own. */
|
|
854
|
+
CREATE OR REPLACE FUNCTION __KN__.write_schema(plan jsonb, target text)
|
|
855
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
856
|
+
DECLARE
|
|
857
|
+
statements jsonb := __KN__.must_stay_inside(__KN__.copy_statements(plan, target), target);
|
|
858
|
+
s text;
|
|
859
|
+
strays text;
|
|
860
|
+
saved text := current_setting('search_path');
|
|
861
|
+
BEGIN
|
|
862
|
+
-- Built with the copy first on the search_path, so a bare name binds to the
|
|
863
|
+
-- copy and not to whatever the customer happens to have.
|
|
864
|
+
--
|
|
865
|
+
-- Postgres writes a name without its schema whenever that name is already
|
|
866
|
+
-- reachable, so a real app in `public` hands back "REFERENCES profiles(id)"
|
|
867
|
+
-- and "nextval('orders_id_seq'::regclass)" - and a rewrite looking for a
|
|
868
|
+
-- schema name finds nothing to change in either. Replayed into the copy,
|
|
869
|
+
-- the foreign keys pointed at the customer's tables and the copy drew its
|
|
870
|
+
-- keys from the customer's sequences, which advanced them.
|
|
871
|
+
--
|
|
872
|
+
-- Rewriting each kind of expression in turn has no end to it. Naming the
|
|
873
|
+
-- copy first changes what a bare name means, which ends all of them.
|
|
874
|
+
-- `public` stays on, after the copy, because the copy legitimately needs
|
|
875
|
+
-- what lives there. Local to the transaction, so it undoes itself.
|
|
876
|
+
PERFORM set_config('search_path',
|
|
877
|
+
__KN__.always_quote(target) || ', public, extensions', true);
|
|
878
|
+
|
|
879
|
+
FOR s IN SELECT jsonb_array_elements_text(statements) LOOP
|
|
880
|
+
EXECUTE s;
|
|
881
|
+
END LOOP;
|
|
882
|
+
|
|
883
|
+
PERFORM set_config('search_path', saved, true);
|
|
884
|
+
|
|
885
|
+
-- And then look at what was built, rather than at what was meant.
|
|
886
|
+
--
|
|
887
|
+
-- must_stay_inside reads the statements before they run. This asks Postgres
|
|
888
|
+
-- where the copy ended up pointing, which is the only account of it that a
|
|
889
|
+
-- spelling nobody anticipated cannot fool - and one was not: a bare
|
|
890
|
+
-- "REFERENCES profiles(id)" bound to the customer's own table on every app
|
|
891
|
+
-- that lives in a schema on the search_path.
|
|
892
|
+
--
|
|
893
|
+
-- Nothing is written to a table outside the copy either way. The damage is
|
|
894
|
+
-- that a copy tied to the customer's rows is not the app being attacked,
|
|
895
|
+
-- and every verdict taken from it is about something else.
|
|
896
|
+
-- Foreign keys AND column defaults. The second is where this hid after the
|
|
897
|
+
-- first was closed: a serial column's default is nextval('<sequence>'), and
|
|
898
|
+
-- the copy was calling the customer's. Nothing was written to their tables,
|
|
899
|
+
-- but nextval advances a sequence, so their database changed.
|
|
900
|
+
--
|
|
901
|
+
-- Asked of pg_depend rather than of the text, so it holds for any
|
|
902
|
+
-- expression that ends up pointing at a relation.
|
|
903
|
+
SELECT string_agg(what || ' -> ' || points_at, ', ') INTO strays FROM (
|
|
904
|
+
SELECT con.conname AS what, rn.nspname AS points_at
|
|
905
|
+
FROM pg_constraint con
|
|
906
|
+
JOIN pg_class cl ON cl.oid = con.conrelid
|
|
907
|
+
JOIN pg_namespace cn ON cn.oid = cl.relnamespace
|
|
908
|
+
JOIN pg_class rc ON rc.oid = con.confrelid
|
|
909
|
+
JOIN pg_namespace rn ON rn.oid = rc.relnamespace
|
|
910
|
+
WHERE con.contype = 'f' AND cn.nspname = target AND rn.nspname <> target
|
|
911
|
+
UNION ALL
|
|
912
|
+
SELECT cl.relname || '.' || a.attname || ' default',
|
|
913
|
+
rn.nspname || '.' || rc.relname
|
|
914
|
+
FROM pg_depend d
|
|
915
|
+
JOIN pg_attrdef ad ON ad.oid = d.objid AND d.classid = 'pg_attrdef'::regclass
|
|
916
|
+
JOIN pg_class cl ON cl.oid = ad.adrelid
|
|
917
|
+
JOIN pg_namespace cn ON cn.oid = cl.relnamespace
|
|
918
|
+
JOIN pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
|
|
919
|
+
JOIN pg_class rc ON rc.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
|
|
920
|
+
JOIN pg_namespace rn ON rn.oid = rc.relnamespace
|
|
921
|
+
WHERE cn.nspname = target AND rn.nspname <> target AND rn.nspname <> 'pg_catalog'
|
|
922
|
+
) outside;
|
|
923
|
+
|
|
924
|
+
IF strays IS NOT NULL THEN
|
|
925
|
+
RAISE EXCEPTION 'the copy points outside itself, so it is not the app: %', strays;
|
|
926
|
+
END IF;
|
|
927
|
+
|
|
928
|
+
RETURN statements;
|
|
929
|
+
END $$;
|
|
930
|
+
|
|
931
|
+
-- --------------------------------------------------------------------------
|
|
932
|
+
-- Slice 3: seeding, in SQL.
|
|
933
|
+
--
|
|
934
|
+
-- Mirrors attack.js. The three functions here are the deterministic half of
|
|
935
|
+
-- deciding what to put in a row: who a row belongs to, how wide a value may
|
|
936
|
+
-- be, and what a CHECK constraint will actually accept. They take the shape
|
|
937
|
+
-- read_schema hands back and nothing else, so the twin check can compare them
|
|
938
|
+
-- answer for answer with no database state in the way.
|
|
939
|
+
-- --------------------------------------------------------------------------
|
|
940
|
+
|
|
941
|
+
/*
|
|
942
|
+
* The column that says who a row belongs to, or NULL if nothing does.
|
|
943
|
+
*
|
|
944
|
+
* The order is what these names are worth believing, not alphabetical. `id`
|
|
945
|
+
* is last and only counts on a table that looks like a profile, where the row
|
|
946
|
+
* id IS the person.
|
|
947
|
+
*/
|
|
948
|
+
CREATE OR REPLACE FUNCTION __KN__.owner_column(tab jsonb)
|
|
949
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
950
|
+
DECLARE
|
|
951
|
+
wanted text;
|
|
952
|
+
found text;
|
|
953
|
+
BEGIN
|
|
954
|
+
FOREACH wanted IN ARRAY ARRAY['user_id', 'owner_id', 'owner', 'profile_id',
|
|
955
|
+
'account_id', 'created_by', 'author_id'] LOOP
|
|
956
|
+
SELECT c->>'name' INTO found
|
|
957
|
+
FROM jsonb_array_elements(tab->'columns') c
|
|
958
|
+
WHERE c->>'type' = 'uuid' AND c->>'name' = wanted
|
|
959
|
+
LIMIT 1;
|
|
960
|
+
IF found IS NOT NULL THEN RETURN found; END IF;
|
|
961
|
+
END LOOP;
|
|
962
|
+
|
|
963
|
+
-- A profiles table keyed by the person themselves.
|
|
964
|
+
IF (tab->>'name') ~* '(profile|user|account|member)' THEN
|
|
965
|
+
SELECT c->>'name' INTO found
|
|
966
|
+
FROM jsonb_array_elements(tab->'columns') c
|
|
967
|
+
WHERE c->>'type' = 'uuid' AND c->>'name' = 'id'
|
|
968
|
+
LIMIT 1;
|
|
969
|
+
IF found IS NOT NULL THEN RETURN found; END IF;
|
|
970
|
+
END IF;
|
|
971
|
+
|
|
972
|
+
RETURN NULL;
|
|
973
|
+
END $$;
|
|
974
|
+
|
|
975
|
+
/* Keeps a generated value inside a declared width like varchar(20). */
|
|
976
|
+
CREATE OR REPLACE FUNCTION __KN__.fit_to(text_ text, kind text)
|
|
977
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
978
|
+
DECLARE
|
|
979
|
+
width text := (regexp_match(btrim(coalesce(kind, '')), '^[a-z ]*\((\d+)\)$'))[1];
|
|
980
|
+
BEGIN
|
|
981
|
+
IF width IS NULL THEN RETURN text_; END IF;
|
|
982
|
+
IF length(text_) > width::integer THEN RETURN left(text_, width::integer); END IF;
|
|
983
|
+
RETURN text_;
|
|
984
|
+
END $$;
|
|
985
|
+
|
|
986
|
+
/*
|
|
987
|
+
* Values a CHECK constraint will actually accept for one column.
|
|
988
|
+
*
|
|
989
|
+
* `status text CHECK (status IN ('open','closed'))` is one of the most common
|
|
990
|
+
* things anyone writes, and Postgres stores it as
|
|
991
|
+
* `CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text])))`. Reading the
|
|
992
|
+
* literals back out turns a table that could never be seeded into one that can.
|
|
993
|
+
*
|
|
994
|
+
* Only this one shape is understood, deliberately. A CHECK can contain
|
|
995
|
+
* anything, and pretending to satisfy an arbitrary one would mean inventing
|
|
996
|
+
* rows the app itself would reject.
|
|
997
|
+
*/
|
|
998
|
+
CREATE OR REPLACE FUNCTION __KN__.allowed_by_check(tab jsonb, column_name text)
|
|
999
|
+
RETURNS text[] LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1000
|
+
DECLARE
|
|
1001
|
+
found text[] := ARRAY[]::text[];
|
|
1002
|
+
con jsonb;
|
|
1003
|
+
inside text;
|
|
1004
|
+
literal text;
|
|
1005
|
+
BEGIN
|
|
1006
|
+
FOR con IN SELECT * FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) LOOP
|
|
1007
|
+
CONTINUE WHEN con->>'kind' <> 'c';
|
|
1008
|
+
-- Both spellings of the same rule. On a text column Postgres writes
|
|
1009
|
+
-- state = ANY (ARRAY['open'::text, ...])
|
|
1010
|
+
-- and on a varchar column it writes
|
|
1011
|
+
-- (state)::text = ANY ((ARRAY['open'::character varying, ...])::text[])
|
|
1012
|
+
-- which the first pattern could not cross: it stopped at the bracket that
|
|
1013
|
+
-- closes the cast, so every varchar column with an IN list was seeded with
|
|
1014
|
+
-- an invented value, refused by its own constraint, and its table reported
|
|
1015
|
+
-- as one that could not be checked.
|
|
1016
|
+
--
|
|
1017
|
+
-- What may sit between the column and the = is spelled out rather than
|
|
1018
|
+
-- left to a negated class: anything looser reaches across the next AND and
|
|
1019
|
+
-- hands one column's allowed values to another.
|
|
1020
|
+
inside := (regexp_match(
|
|
1021
|
+
con->>'definition',
|
|
1022
|
+
'\m' || regexp_replace(column_name, '([.*+?^${}()|\[\]\\])', '\\\1', 'g')
|
|
1023
|
+
|| '\M\)?(?:::[a-z ]+)?\s*=\s*ANY\s*\(+\s*ARRAY\[(.*?)\]',
|
|
1024
|
+
'i'))[1];
|
|
1025
|
+
CONTINUE WHEN inside IS NULL;
|
|
1026
|
+
-- Picked out one at a time rather than split on commas, which came apart
|
|
1027
|
+
-- in the middle of any value that had a comma in it.
|
|
1028
|
+
FOR literal IN
|
|
1029
|
+
SELECT (m)[1] FROM regexp_matches(inside, '''((?:[^'']|'''')*)''', 'g') m
|
|
1030
|
+
LOOP
|
|
1031
|
+
found := found || replace(literal, '''''', '''');
|
|
1032
|
+
END LOOP;
|
|
1033
|
+
END LOOP;
|
|
1034
|
+
RETURN found;
|
|
1035
|
+
END $$;
|
|
1036
|
+
|
|
1037
|
+
/*
|
|
1038
|
+
* Something valid to put in a column, so a row can exist at all.
|
|
1039
|
+
*
|
|
1040
|
+
* Mirrors valueFor in attack.js. What comes back is the TEXT of the value,
|
|
1041
|
+
* which the insert casts to the column's own type - the Node side hands the
|
|
1042
|
+
* same thing to the driver as a parameter instead. Both are "the value that
|
|
1043
|
+
* belongs here", written the way each engine has to write it.
|
|
1044
|
+
*
|
|
1045
|
+
* `distinct` is what keeps two seeded rows from being identical. Without it
|
|
1046
|
+
* every row carried the same text, so a table with a unique email column
|
|
1047
|
+
* refused the second insert and the whole table was reported as "not checked"
|
|
1048
|
+
* - a well-built app treated as an unknown one. The tag goes at the front
|
|
1049
|
+
* because a narrow varchar truncates the end, and two rows truncated to the
|
|
1050
|
+
* same string is that bug all over again.
|
|
1051
|
+
*/
|
|
1052
|
+
CREATE OR REPLACE FUNCTION __KN__.value_for(col jsonb, owner text, distinct_ text, attempt integer)
|
|
1053
|
+
RETURNS text LANGUAGE plpgsql AS $$
|
|
1054
|
+
DECLARE
|
|
1055
|
+
-- A domain is somebody's own type with a rule bolted on. The rule cannot be
|
|
1056
|
+
-- guessed at from here, but the type underneath it can be filled in.
|
|
1057
|
+
kind text := lower(coalesce(col->>'base_type', col->>'type'));
|
|
1058
|
+
tag text := coalesce(distinct_, '');
|
|
1059
|
+
step integer := coalesce(nullif(tag, '')::numeric::integer, 0);
|
|
1060
|
+
shapes text[];
|
|
1061
|
+
nth integer;
|
|
1062
|
+
BEGIN
|
|
1063
|
+
-- An enum accepts one of a fixed list and nothing else. Every generated
|
|
1064
|
+
-- string was rejected, and the table went down as "not checked".
|
|
1065
|
+
IF jsonb_typeof(col->'enum_labels') = 'array'
|
|
1066
|
+
AND jsonb_array_length(col->'enum_labels') > 0 THEN
|
|
1067
|
+
RETURN col->'enum_labels'->>0;
|
|
1068
|
+
END IF;
|
|
1069
|
+
-- Anything at all is allowed in an empty array, whatever the element type.
|
|
1070
|
+
IF (col->>'is_array')::boolean OR kind ~ '\[\]$' THEN RETURN '{}'; END IF;
|
|
1071
|
+
|
|
1072
|
+
IF kind = 'uuid' THEN RETURN owner; END IF;
|
|
1073
|
+
IF kind ~ '^(integer|bigint|smallint|numeric|decimal|real|double|money)' THEN
|
|
1074
|
+
RETURN (1 + step)::text;
|
|
1075
|
+
END IF;
|
|
1076
|
+
IF kind ~ '^bool' THEN RETURN 'true'; END IF;
|
|
1077
|
+
IF kind ~ '^(timestamp|date)' THEN
|
|
1078
|
+
RETURN to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"');
|
|
1079
|
+
END IF;
|
|
1080
|
+
IF kind ~ '^time' THEN RETURN '12:00:00'; END IF;
|
|
1081
|
+
IF kind ~ '^interval' THEN RETURN '1 day'; END IF;
|
|
1082
|
+
IF kind ~ '^json' THEN RETURN '{}'; END IF;
|
|
1083
|
+
IF kind ~ '^(inet|cidr)' THEN RETURN '192.0.2.' || (1 + step)::text; END IF;
|
|
1084
|
+
IF kind ~ '^macaddr8' THEN RETURN '08:00:2b:01:02:03:04:0' || (5 + step)::text; END IF;
|
|
1085
|
+
IF kind ~ '^macaddr' THEN RETURN '08:00:2b:01:02:0' || (3 + step)::text; END IF;
|
|
1086
|
+
IF kind ~ '^(tsvector|tsquery)' THEN RETURN 'kryptheon'; END IF;
|
|
1087
|
+
IF kind ~ '^bytea' THEN RETURN 'kryptheon'; END IF;
|
|
1088
|
+
IF kind ~ '^xml' THEN RETURN '<kryptheon/>'; END IF;
|
|
1089
|
+
IF kind ~ '^bit' THEN RETURN '0'; END IF;
|
|
1090
|
+
IF kind ~ '^(point|line|lseg|box|path|polygon|circle)' THEN RETURN '(0,0)'; END IF;
|
|
1091
|
+
|
|
1092
|
+
-- Text is where the rules live that cannot be read: a domain that insists on
|
|
1093
|
+
-- an @, a CHECK on a length, a regex for a product code. Rather than pretend
|
|
1094
|
+
-- to understand them, the seeder works down a short ladder of shapes and
|
|
1095
|
+
-- keeps whichever one the database accepts.
|
|
1096
|
+
shapes := ARRAY[
|
|
1097
|
+
CASE WHEN tag <> '' THEN tag || ' kryptheon test' ELSE 'kryptheon test' END,
|
|
1098
|
+
'kryptheon' || tag || '@example.com',
|
|
1099
|
+
'KN' || CASE WHEN tag <> '' THEN tag ELSE '1' END,
|
|
1100
|
+
(step + 1)::text,
|
|
1101
|
+
'https://example.com/kryptheon'
|
|
1102
|
+
];
|
|
1103
|
+
nth := least(greatest(coalesce(attempt, 0), 0), array_length(shapes, 1) - 1);
|
|
1104
|
+
RETURN __KN__.fit_to(shapes[nth + 1], kind);
|
|
1105
|
+
END $$;
|
|
1106
|
+
|
|
1107
|
+
/*
|
|
1108
|
+
* The foreign keys on a table, read out of Postgres's own wording.
|
|
1109
|
+
*
|
|
1110
|
+
* Every column of the key, not just the first. A key over (org_id, cart_id)
|
|
1111
|
+
* used to be read as though it were only org_id: the second column got an
|
|
1112
|
+
* invented value, the pair pointed at no row that existed, and the table was
|
|
1113
|
+
* reported as one that could not be checked.
|
|
1114
|
+
*/
|
|
1115
|
+
CREATE OR REPLACE FUNCTION __KN__.foreign_keys(tab jsonb)
|
|
1116
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1117
|
+
DECLARE
|
|
1118
|
+
keys jsonb := '[]'::jsonb;
|
|
1119
|
+
con jsonb;
|
|
1120
|
+
parts text[];
|
|
1121
|
+
BEGIN
|
|
1122
|
+
FOR con IN SELECT * FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) LOOP
|
|
1123
|
+
CONTINUE WHEN con->>'kind' <> 'f';
|
|
1124
|
+
parts := regexp_match(
|
|
1125
|
+
con->>'definition',
|
|
1126
|
+
'FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)',
|
|
1127
|
+
'i');
|
|
1128
|
+
CONTINUE WHEN parts IS NULL;
|
|
1129
|
+
keys := keys || jsonb_build_array(jsonb_build_object(
|
|
1130
|
+
'columns', __KN__.unquoted_list(parts[1]),
|
|
1131
|
+
-- The schema is dropped: inside the copy every table it can point at is
|
|
1132
|
+
-- in the copy, and keeping the original's name would send it home.
|
|
1133
|
+
'refTable', __KN__.unquoted(split_part(parts[2], '.', greatest(
|
|
1134
|
+
array_length(string_to_array(parts[2], '.'), 1), 1))),
|
|
1135
|
+
'refColumns', __KN__.unquoted_list(parts[3])
|
|
1136
|
+
));
|
|
1137
|
+
END LOOP;
|
|
1138
|
+
RETURN keys;
|
|
1139
|
+
END $$;
|
|
1140
|
+
|
|
1141
|
+
/* A name as Postgres wrote it, with the quoting taken back off. */
|
|
1142
|
+
CREATE OR REPLACE FUNCTION __KN__.unquoted(name text)
|
|
1143
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1144
|
+
SELECT replace(btrim(name), '"', '');
|
|
1145
|
+
$$;
|
|
1146
|
+
|
|
1147
|
+
/* And a comma-separated list of them. */
|
|
1148
|
+
CREATE OR REPLACE FUNCTION __KN__.unquoted_list(list text)
|
|
1149
|
+
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
1150
|
+
SELECT coalesce(jsonb_agg(__KN__.unquoted(piece) ORDER BY at), '[]'::jsonb)
|
|
1151
|
+
FROM regexp_split_to_table(list, ',') WITH ORDINALITY AS p(piece, at);
|
|
1152
|
+
$$;
|
|
1153
|
+
|
|
1154
|
+
/*
|
|
1155
|
+
* One step of the walk: everything this table points at, before the table.
|
|
1156
|
+
*
|
|
1157
|
+
* The three sets are carried in and out rather than held in a closure, which
|
|
1158
|
+
* plpgsql does not have. Post-order, exactly as the Node side walks it, so the
|
|
1159
|
+
* two engines hand back the same order and not merely a workable one.
|
|
1160
|
+
*/
|
|
1161
|
+
CREATE OR REPLACE FUNCTION __KN__.dependency_visit(name text, tables jsonb, state jsonb)
|
|
1162
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1163
|
+
DECLARE
|
|
1164
|
+
tab jsonb;
|
|
1165
|
+
key jsonb;
|
|
1166
|
+
parent text;
|
|
1167
|
+
BEGIN
|
|
1168
|
+
IF state->'done' ? name OR state->'visiting' ? name THEN RETURN state; END IF;
|
|
1169
|
+
|
|
1170
|
+
SELECT t INTO tab FROM jsonb_array_elements(tables) t WHERE t->>'name' = name;
|
|
1171
|
+
IF tab IS NULL THEN RETURN state; END IF;
|
|
1172
|
+
|
|
1173
|
+
state := jsonb_set(state, '{visiting}', (state->'visiting') || to_jsonb(name));
|
|
1174
|
+
|
|
1175
|
+
FOR key IN SELECT * FROM jsonb_array_elements(__KN__.foreign_keys(tab)) LOOP
|
|
1176
|
+
parent := key->>'refTable';
|
|
1177
|
+
CONTINUE WHEN parent = name;
|
|
1178
|
+
CONTINUE WHEN NOT EXISTS (
|
|
1179
|
+
SELECT 1 FROM jsonb_array_elements(tables) t WHERE t->>'name' = parent);
|
|
1180
|
+
state := __KN__.dependency_visit(parent, tables, state);
|
|
1181
|
+
END LOOP;
|
|
1182
|
+
|
|
1183
|
+
state := jsonb_set(state, '{visiting}',
|
|
1184
|
+
coalesce((SELECT jsonb_agg(v) FROM jsonb_array_elements(state->'visiting') v
|
|
1185
|
+
WHERE v <> to_jsonb(name)), '[]'::jsonb));
|
|
1186
|
+
state := jsonb_set(state, '{done}', (state->'done') || to_jsonb(name));
|
|
1187
|
+
state := jsonb_set(state, '{ordered}', (state->'ordered') || to_jsonb(name));
|
|
1188
|
+
RETURN state;
|
|
1189
|
+
END $$;
|
|
1190
|
+
|
|
1191
|
+
/*
|
|
1192
|
+
* Parents before children.
|
|
1193
|
+
*
|
|
1194
|
+
* Tables come back in alphabetical order, which put `orders` before `profiles`
|
|
1195
|
+
* and made every insert fail on the foreign key - on the first real app it was
|
|
1196
|
+
* pointed at, because every app has one of these. A cycle is left in whatever
|
|
1197
|
+
* order it arrived: it cannot be satisfied anyway, and the table that fails is
|
|
1198
|
+
* reported rather than dropped.
|
|
1199
|
+
*/
|
|
1200
|
+
CREATE OR REPLACE FUNCTION __KN__.dependency_order(tables jsonb)
|
|
1201
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1202
|
+
DECLARE
|
|
1203
|
+
state jsonb := jsonb_build_object('done', '[]'::jsonb, 'visiting', '[]'::jsonb, 'ordered', '[]'::jsonb);
|
|
1204
|
+
tab jsonb;
|
|
1205
|
+
out jsonb := '[]'::jsonb;
|
|
1206
|
+
name text;
|
|
1207
|
+
BEGIN
|
|
1208
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
1209
|
+
state := __KN__.dependency_visit(tab->>'name', tables, state);
|
|
1210
|
+
END LOOP;
|
|
1211
|
+
|
|
1212
|
+
FOR name IN SELECT jsonb_array_elements_text(state->'ordered') LOOP
|
|
1213
|
+
out := out || jsonb_build_array(
|
|
1214
|
+
(SELECT t FROM jsonb_array_elements(tables) t WHERE t->>'name' = name));
|
|
1215
|
+
END LOOP;
|
|
1216
|
+
RETURN out;
|
|
1217
|
+
END $$;
|
|
1218
|
+
|
|
1219
|
+
-- --------------------------------------------------------------------------
|
|
1220
|
+
-- Seeding, the half that writes.
|
|
1221
|
+
--
|
|
1222
|
+
-- Everything above this decides things from the shape and nothing else. From
|
|
1223
|
+
-- here on rows really go in, which is why the twin check stops comparing
|
|
1224
|
+
-- answers and starts comparing the two copies that come out.
|
|
1225
|
+
-- --------------------------------------------------------------------------
|
|
1226
|
+
|
|
1227
|
+
/* The two people the copy is seeded with. */
|
|
1228
|
+
CREATE OR REPLACE FUNCTION __KN__.user_a() RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1229
|
+
SELECT '11111111-1111-4111-8111-111111111111';
|
|
1230
|
+
$$;
|
|
1231
|
+
CREATE OR REPLACE FUNCTION __KN__.user_b() RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1232
|
+
SELECT '22222222-2222-4222-8222-222222222222';
|
|
1233
|
+
$$;
|
|
1234
|
+
|
|
1235
|
+
-- Two more who own nothing at all. The seeded pair already hold a row each,
|
|
1236
|
+
-- so an attack that inserts under their name collides with the row the
|
|
1237
|
+
-- seeder put there - on a table keyed by the person that is a primary key
|
|
1238
|
+
-- clash, and it was being read as the app refusing the attack.
|
|
1239
|
+
CREATE OR REPLACE FUNCTION __KN__.user_c() RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1240
|
+
SELECT '55555555-5555-4555-8555-555555555555';
|
|
1241
|
+
$$;
|
|
1242
|
+
CREATE OR REPLACE FUNCTION __KN__.user_d() RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1243
|
+
SELECT '66666666-6666-4666-8666-666666666666';
|
|
1244
|
+
$$;
|
|
1245
|
+
|
|
1246
|
+
/*
|
|
1247
|
+
* One row that is really in the parent table, so a foreign key is satisfied.
|
|
1248
|
+
*
|
|
1249
|
+
* The whole row, not one column of it. A composite key has to point at a pair
|
|
1250
|
+
* that exists together: borrowing each column from a separate row would build
|
|
1251
|
+
* a combination the parent never had.
|
|
1252
|
+
*/
|
|
1253
|
+
CREATE OR REPLACE FUNCTION __KN__.existing_row(source text, key jsonb)
|
|
1254
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
1255
|
+
DECLARE
|
|
1256
|
+
picked text[];
|
|
1257
|
+
names text;
|
|
1258
|
+
BEGIN
|
|
1259
|
+
SELECT string_agg(__KN__.always_quote(name), ', ' ORDER BY at) INTO names
|
|
1260
|
+
FROM jsonb_array_elements_text(key->'refColumns') WITH ORDINALITY AS c(name, at);
|
|
1261
|
+
IF names IS NULL THEN RETURN NULL; END IF;
|
|
1262
|
+
|
|
1263
|
+
EXECUTE 'SELECT ARRAY[' || replace(names, ', ', '::text, ') || '::text] FROM '
|
|
1264
|
+
|| __KN__.always_quote(source) || '.' || __KN__.always_quote(key->>'refTable')
|
|
1265
|
+
|| ' LIMIT 1'
|
|
1266
|
+
INTO picked;
|
|
1267
|
+
IF picked IS NULL THEN RETURN NULL; END IF;
|
|
1268
|
+
RETURN to_jsonb(picked);
|
|
1269
|
+
EXCEPTION WHEN OTHERS THEN
|
|
1270
|
+
-- A parent that cannot be read is a key that cannot be satisfied, and the
|
|
1271
|
+
-- table that holds it is reported rather than guessed at.
|
|
1272
|
+
RETURN NULL;
|
|
1273
|
+
END $$;
|
|
1274
|
+
|
|
1275
|
+
/*
|
|
1276
|
+
* A row that will actually go in.
|
|
1277
|
+
*
|
|
1278
|
+
* Owner column set to the person, foreign keys pointing at rows that really
|
|
1279
|
+
* exist, every NOT NULL column filled, and anything with a default left to
|
|
1280
|
+
* supply its own value.
|
|
1281
|
+
*
|
|
1282
|
+
* `overrides` is what the Collision attack needs: it forces one column to a
|
|
1283
|
+
* chosen value while everything else stays distinct, so when two inserts race
|
|
1284
|
+
* they collide on that column and on nothing else. Without it a second unique
|
|
1285
|
+
* column elsewhere in the table would refuse the insert, and the refusal would
|
|
1286
|
+
* be read as the app defending itself.
|
|
1287
|
+
*/
|
|
1288
|
+
CREATE OR REPLACE FUNCTION __KN__.row_for(
|
|
1289
|
+
source text, tab jsonb, person text, distinct_ text, overrides jsonb, attempt integer)
|
|
1290
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
1291
|
+
DECLARE
|
|
1292
|
+
forced jsonb := coalesce(overrides, '{}'::jsonb);
|
|
1293
|
+
owner text := __KN__.owner_column(tab);
|
|
1294
|
+
columns jsonb := '[]'::jsonb;
|
|
1295
|
+
values_ jsonb := '[]'::jsonb;
|
|
1296
|
+
borrowed jsonb := '{}'::jsonb;
|
|
1297
|
+
part_of_key jsonb := '[]'::jsonb;
|
|
1298
|
+
key jsonb;
|
|
1299
|
+
row_ jsonb;
|
|
1300
|
+
col jsonb;
|
|
1301
|
+
name text;
|
|
1302
|
+
allowed text[];
|
|
1303
|
+
nth integer;
|
|
1304
|
+
at integer;
|
|
1305
|
+
BEGIN
|
|
1306
|
+
-- Every column that takes part in a foreign key, and the value it has to
|
|
1307
|
+
-- hold. Resolved one key at a time so that all of a composite key's columns
|
|
1308
|
+
-- come from the same parent row.
|
|
1309
|
+
FOR key IN SELECT * FROM jsonb_array_elements(__KN__.foreign_keys(tab)) LOOP
|
|
1310
|
+
row_ := __KN__.existing_row(source, key);
|
|
1311
|
+
CONTINUE WHEN row_ IS NULL;
|
|
1312
|
+
at := 0;
|
|
1313
|
+
FOR name IN SELECT jsonb_array_elements_text(key->'columns') LOOP
|
|
1314
|
+
IF NOT (borrowed ? name) THEN
|
|
1315
|
+
borrowed := jsonb_set(borrowed, ARRAY[name], coalesce(row_->at, 'null'::jsonb));
|
|
1316
|
+
END IF;
|
|
1317
|
+
at := at + 1;
|
|
1318
|
+
END LOOP;
|
|
1319
|
+
END LOOP;
|
|
1320
|
+
FOR key IN SELECT * FROM jsonb_array_elements(__KN__.foreign_keys(tab)) LOOP
|
|
1321
|
+
part_of_key := part_of_key || (key->'columns');
|
|
1322
|
+
END LOOP;
|
|
1323
|
+
|
|
1324
|
+
FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
|
|
1325
|
+
name := col->>'name';
|
|
1326
|
+
|
|
1327
|
+
IF forced ? name THEN
|
|
1328
|
+
columns := columns || to_jsonb(name);
|
|
1329
|
+
values_ := values_ || jsonb_build_array(forced->name);
|
|
1330
|
+
CONTINUE;
|
|
1331
|
+
END IF;
|
|
1332
|
+
|
|
1333
|
+
IF name = owner THEN
|
|
1334
|
+
columns := columns || to_jsonb(name);
|
|
1335
|
+
values_ := values_ || jsonb_build_array(to_jsonb(person));
|
|
1336
|
+
CONTINUE;
|
|
1337
|
+
END IF;
|
|
1338
|
+
|
|
1339
|
+
-- A column pointing at another table has to hold something that is
|
|
1340
|
+
-- actually there, whatever its type would otherwise suggest.
|
|
1341
|
+
IF part_of_key ? name THEN
|
|
1342
|
+
IF borrowed ? name THEN
|
|
1343
|
+
columns := columns || to_jsonb(name);
|
|
1344
|
+
values_ := values_ || jsonb_build_array(borrowed->name);
|
|
1345
|
+
CONTINUE;
|
|
1346
|
+
END IF;
|
|
1347
|
+
IF (col->>'not_null')::boolean THEN
|
|
1348
|
+
RAISE EXCEPTION 'nothing to point % at', name;
|
|
1349
|
+
END IF;
|
|
1350
|
+
CONTINUE;
|
|
1351
|
+
END IF;
|
|
1352
|
+
|
|
1353
|
+
-- A generated column computes itself and refuses to be written to at all.
|
|
1354
|
+
--
|
|
1355
|
+
-- Two halves, and only one of them can be observed. An identity column
|
|
1356
|
+
-- carries no default_expr, so without the identity half the seeder writes
|
|
1357
|
+
-- to it and Postgres refuses the row - both engines are caught doing it.
|
|
1358
|
+
-- A stored generated column keeps its expression IN default_expr, so the
|
|
1359
|
+
-- line below skips it whether or not this one does: removing the generated
|
|
1360
|
+
-- half changes nothing any fixture could see. It stays because that is a
|
|
1361
|
+
-- fact about how readColumns fills the shape, not about Postgres, and the
|
|
1362
|
+
-- day it changes this is the only thing standing in the way.
|
|
1363
|
+
CONTINUE WHEN col->>'generated' IS NOT NULL OR (col->>'identity')::boolean;
|
|
1364
|
+
-- Anything with a default can supply its own value.
|
|
1365
|
+
CONTINUE WHEN col->>'default_expr' IS NOT NULL;
|
|
1366
|
+
CONTINUE WHEN NOT (col->>'not_null')::boolean;
|
|
1367
|
+
|
|
1368
|
+
columns := columns || to_jsonb(name);
|
|
1369
|
+
-- A CHECK that lists what it will accept beats anything invented here.
|
|
1370
|
+
allowed := __KN__.allowed_by_check(tab, name);
|
|
1371
|
+
IF array_length(allowed, 1) > 0 THEN
|
|
1372
|
+
nth := least(greatest(coalesce(attempt, 0), 0), array_length(allowed, 1) - 1);
|
|
1373
|
+
values_ := values_ || jsonb_build_array(to_jsonb(allowed[nth + 1]));
|
|
1374
|
+
ELSE
|
|
1375
|
+
values_ := values_ || jsonb_build_array(
|
|
1376
|
+
to_jsonb(__KN__.value_for(col, person, distinct_, attempt)));
|
|
1377
|
+
END IF;
|
|
1378
|
+
END LOOP;
|
|
1379
|
+
|
|
1380
|
+
RETURN jsonb_build_object('columns', columns, 'values', values_);
|
|
1381
|
+
END $$;
|
|
1382
|
+
|
|
1383
|
+
/*
|
|
1384
|
+
* The statement that puts a built row in.
|
|
1385
|
+
*
|
|
1386
|
+
* Written out rather than run, because the tampering attack needs the same
|
|
1387
|
+
* insert the seeder would use and needs it as text: it has to be built
|
|
1388
|
+
* before the attack stops being itself, and run afterwards as somebody else.
|
|
1389
|
+
*/
|
|
1390
|
+
CREATE OR REPLACE FUNCTION __KN__.insert_statement(source text, table_name text, row_ jsonb)
|
|
1391
|
+
RETURNS text LANGUAGE plpgsql AS $$
|
|
1392
|
+
DECLARE
|
|
1393
|
+
where_ text := 'INSERT INTO ' || __KN__.always_quote(source) || '.' || __KN__.always_quote(table_name);
|
|
1394
|
+
names text;
|
|
1395
|
+
places text;
|
|
1396
|
+
BEGIN
|
|
1397
|
+
-- A table of nothing but an id and its defaults leaves no columns to name,
|
|
1398
|
+
-- and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a
|
|
1399
|
+
-- spelling for exactly this, and without it every settings and flags table
|
|
1400
|
+
-- in the world came back as one that could not be checked.
|
|
1401
|
+
IF jsonb_array_length(row_->'columns') = 0 THEN
|
|
1402
|
+
RETURN where_ || ' DEFAULT VALUES';
|
|
1403
|
+
END IF;
|
|
1404
|
+
|
|
1405
|
+
SELECT string_agg(__KN__.always_quote(name), ', ' ORDER BY at) INTO names
|
|
1406
|
+
FROM jsonb_array_elements_text(row_->'columns') WITH ORDINALITY AS c(name, at);
|
|
1407
|
+
-- Written as literals rather than passed as parameters: EXECUTE ... USING
|
|
1408
|
+
-- needs a fixed argument list, and the number of columns is not known until
|
|
1409
|
+
-- the row is built. quote_nullable casts nothing and lets the column's own
|
|
1410
|
+
-- type decide, which is what the driver does for the other engine.
|
|
1411
|
+
SELECT string_agg(quote_nullable(v), ', ' ORDER BY at) INTO places
|
|
1412
|
+
FROM jsonb_array_elements_text(row_->'values') WITH ORDINALITY AS c(v, at);
|
|
1413
|
+
|
|
1414
|
+
RETURN where_ || ' (' || names || ') VALUES (' || places || ')';
|
|
1415
|
+
END $$;
|
|
1416
|
+
|
|
1417
|
+
/* And running it. Separate so the same row can be raced against itself. */
|
|
1418
|
+
CREATE OR REPLACE FUNCTION __KN__.insert_row(source text, table_name text, row_ jsonb)
|
|
1419
|
+
RETURNS void LANGUAGE plpgsql AS $$
|
|
1420
|
+
BEGIN
|
|
1421
|
+
EXECUTE __KN__.insert_statement(source, table_name, row_);
|
|
1422
|
+
END $$;
|
|
1423
|
+
|
|
1424
|
+
/*
|
|
1425
|
+
* Two people, in every table, in an order that can actually be satisfied.
|
|
1426
|
+
*
|
|
1427
|
+
* A table whose rules reject every shape offered is reported, never quietly
|
|
1428
|
+
* passed over: an empty table and an open one look identical from outside.
|
|
1429
|
+
*/
|
|
1430
|
+
CREATE OR REPLACE FUNCTION __KN__.seed(source text, tables jsonb)
|
|
1431
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
1432
|
+
DECLARE
|
|
1433
|
+
seeded jsonb := '[]'::jsonb;
|
|
1434
|
+
skipped jsonb := '[]'::jsonb;
|
|
1435
|
+
tab jsonb;
|
|
1436
|
+
owner text;
|
|
1437
|
+
people text[];
|
|
1438
|
+
person text;
|
|
1439
|
+
refused text;
|
|
1440
|
+
landed boolean;
|
|
1441
|
+
worked integer;
|
|
1442
|
+
attempt integer;
|
|
1443
|
+
nth integer;
|
|
1444
|
+
shapes_to_try constant integer := 5;
|
|
1445
|
+
BEGIN
|
|
1446
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(__KN__.dependency_order(tables)) LOOP
|
|
1447
|
+
owner := __KN__.owner_column(tab);
|
|
1448
|
+
|
|
1449
|
+
-- A table with nobody's name on it still gets a row. Without one, an open
|
|
1450
|
+
-- door cannot be told from an empty room: a logged-out stranger reads zero
|
|
1451
|
+
-- rows either way, and the tool reports the app as safe. Settings tables,
|
|
1452
|
+
-- waitlists and contact forms are exactly this shape, and exactly the ones
|
|
1453
|
+
-- that get left open.
|
|
1454
|
+
IF owner IS NULL THEN
|
|
1455
|
+
people := ARRAY[__KN__.user_a()];
|
|
1456
|
+
ELSE
|
|
1457
|
+
people := ARRAY[__KN__.user_a(), __KN__.user_b()];
|
|
1458
|
+
END IF;
|
|
1459
|
+
|
|
1460
|
+
refused := NULL;
|
|
1461
|
+
landed := false;
|
|
1462
|
+
worked := 0;
|
|
1463
|
+
|
|
1464
|
+
attempt := 0;
|
|
1465
|
+
WHILE attempt < shapes_to_try AND NOT landed LOOP
|
|
1466
|
+
BEGIN
|
|
1467
|
+
nth := 0;
|
|
1468
|
+
FOREACH person IN ARRAY people LOOP
|
|
1469
|
+
nth := nth + 1;
|
|
1470
|
+
PERFORM __KN__.insert_row(source, tab->>'name',
|
|
1471
|
+
__KN__.row_for(source, tab, person, nth::text, NULL, attempt));
|
|
1472
|
+
END LOOP;
|
|
1473
|
+
landed := true;
|
|
1474
|
+
worked := attempt;
|
|
1475
|
+
EXCEPTION WHEN OTHERS THEN
|
|
1476
|
+
refused := SQLERRM;
|
|
1477
|
+
-- The block is a subtransaction, so anything that did go in on this
|
|
1478
|
+
-- attempt is already gone. The other engine has to delete it by hand;
|
|
1479
|
+
-- what matters is that the next attempt does not collide with a row
|
|
1480
|
+
-- this one left behind.
|
|
1481
|
+
END;
|
|
1482
|
+
attempt := attempt + 1;
|
|
1483
|
+
END LOOP;
|
|
1484
|
+
|
|
1485
|
+
IF landed THEN
|
|
1486
|
+
-- The shape that worked is kept: any later attack that has to insert
|
|
1487
|
+
-- into this table can use the same one instead of rediscovering it, and
|
|
1488
|
+
-- be sure a refusal is the app defending itself rather than a CHECK it
|
|
1489
|
+
-- never satisfied.
|
|
1490
|
+
seeded := seeded || jsonb_build_array(jsonb_build_object(
|
|
1491
|
+
'table', tab->>'name', 'owner', owner, 'attempt', worked));
|
|
1492
|
+
ELSE
|
|
1493
|
+
-- Recorded, never swallowed. The report has to say this table was not
|
|
1494
|
+
-- checked rather than let an empty table pass for a safe one.
|
|
1495
|
+
skipped := skipped || jsonb_build_array(jsonb_build_object(
|
|
1496
|
+
'table', tab->>'name', 'why', refused));
|
|
1497
|
+
END IF;
|
|
1498
|
+
END LOOP;
|
|
1499
|
+
|
|
1500
|
+
RETURN jsonb_build_object('seeded', seeded, 'skipped', skipped);
|
|
1501
|
+
END $$;
|
|
1502
|
+
|
|
1503
|
+
-- --------------------------------------------------------------------------
|
|
1504
|
+
-- The impersonation attack, inside the database.
|
|
1505
|
+
--
|
|
1506
|
+
-- Nothing here is SECURITY DEFINER, and it cannot be: Postgres refuses to let
|
|
1507
|
+
-- a security-definer function change role, and changing role is the whole
|
|
1508
|
+
-- attack. Measured, not assumed - probe-plpgsql.js asks exactly this.
|
|
1509
|
+
-- --------------------------------------------------------------------------
|
|
1510
|
+
|
|
1511
|
+
/*
|
|
1512
|
+
* A refusal is only an answer when it is the right refusal.
|
|
1513
|
+
*
|
|
1514
|
+
* "permission denied for table orders" means this caller cannot reach the
|
|
1515
|
+
* table at all. That is the attack being defeated, and it is good news worth
|
|
1516
|
+
* recording as a pass.
|
|
1517
|
+
*
|
|
1518
|
+
* Everything else - a schema the policy needs and the role cannot use, a
|
|
1519
|
+
* function the policy calls that is not there, a timeout - means the rule was
|
|
1520
|
+
* never evaluated. No verdict exists. Both come back as an error and return
|
|
1521
|
+
* zero rows, and zero rows is exactly what a perfectly secured table returns,
|
|
1522
|
+
* so telling them apart is the difference between "you are safe" and "I could
|
|
1523
|
+
* not tell", which is the difference the whole product rests on.
|
|
1524
|
+
*/
|
|
1525
|
+
CREATE OR REPLACE FUNCTION __KN__.refusal_means(message text)
|
|
1526
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1527
|
+
-- The multi-word kinds come first. Postgres says "permission denied for
|
|
1528
|
+
-- materialized view hits", and an alternation that tried `view` first would
|
|
1529
|
+
-- never reach it - so a matview nobody had granted was filed as untested
|
|
1530
|
+
-- rather than as the attack being beaten, and a correct app collected a
|
|
1531
|
+
-- warning it had not earned.
|
|
1532
|
+
SELECT CASE WHEN coalesce(message, '') ~*
|
|
1533
|
+
'permission denied for (materialized view|foreign table|partitioned table|table|relation|view|sequence)'
|
|
1534
|
+
THEN 'unreachable' ELSE 'untested' END;
|
|
1535
|
+
$$;
|
|
1536
|
+
|
|
1537
|
+
/*
|
|
1538
|
+
* Reads a table the way a request would, as whoever is asking.
|
|
1539
|
+
*
|
|
1540
|
+
* Counted rather than fetched. The Node side pulls the rows back and counts
|
|
1541
|
+
* them there; here the count is the only thing wanted, and row level security
|
|
1542
|
+
* applies to a count exactly as it applies to a select.
|
|
1543
|
+
*
|
|
1544
|
+
* `owner` being given also asks the second question: of the rows this caller
|
|
1545
|
+
* can see, how many belong to somebody else.
|
|
1546
|
+
*/
|
|
1547
|
+
CREATE OR REPLACE FUNCTION __KN__.read_as(
|
|
1548
|
+
source text, table_name text, role_ text, user_id text, owner text)
|
|
1549
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
1550
|
+
DECLARE
|
|
1551
|
+
claims text;
|
|
1552
|
+
total bigint;
|
|
1553
|
+
theirs bigint := NULL;
|
|
1554
|
+
target text := __KN__.always_quote(source) || '.' || __KN__.always_quote(table_name);
|
|
1555
|
+
-- Both statements are written out before the role changes, and this is
|
|
1556
|
+
-- not tidiness. Once this function becomes anon it cannot call anything
|
|
1557
|
+
-- in the engine schema any more - anon has no USAGE on it - so a call to
|
|
1558
|
+
-- always_quote or user_b made after the switch fails with "permission
|
|
1559
|
+
-- denied for schema kn_engine_...". That is not the app defending
|
|
1560
|
+
-- itself, but it arrives looking exactly like it: every crossed attack
|
|
1561
|
+
-- came back as a table that could not be tested.
|
|
1562
|
+
--
|
|
1563
|
+
-- The rule for everything on this side of the engine: whatever the
|
|
1564
|
+
-- attack needs, it must already hold before it stops being itself.
|
|
1565
|
+
counting text := 'SELECT count(*) FROM ' || target;
|
|
1566
|
+
counting_theirs text := CASE WHEN owner IS NULL THEN NULL ELSE
|
|
1567
|
+
'SELECT count(*) FROM ' || target || ' WHERE '
|
|
1568
|
+
|| __KN__.always_quote(owner) || '::text = ' || quote_literal(__KN__.user_b()) END;
|
|
1569
|
+
BEGIN
|
|
1570
|
+
-- A logged-out visitor is not "no claims". Supabase hands PostgREST the
|
|
1571
|
+
-- anon key, which is itself a JWT, so request.jwt.claims arrives as a real
|
|
1572
|
+
-- JSON object that simply has no `sub` in it.
|
|
1573
|
+
--
|
|
1574
|
+
-- Sending an empty string instead made auth.uid() throw on the cast, and a
|
|
1575
|
+
-- read that throws returns no rows - which is exactly what a properly
|
|
1576
|
+
-- secured table returns. Every table whose policy calls auth.uid(), which
|
|
1577
|
+
-- is nearly every table anyone writes, came back looking safe without the
|
|
1578
|
+
-- rule ever being evaluated.
|
|
1579
|
+
claims := CASE WHEN user_id IS NULL
|
|
1580
|
+
THEN json_build_object('role', role_)::text
|
|
1581
|
+
ELSE json_build_object('sub', user_id, 'role', role_)::text END;
|
|
1582
|
+
|
|
1583
|
+
BEGIN
|
|
1584
|
+
EXECUTE 'SET LOCAL ROLE ' || quote_ident(role_);
|
|
1585
|
+
PERFORM set_config('request.jwt.claims', claims, true);
|
|
1586
|
+
|
|
1587
|
+
EXECUTE counting INTO total;
|
|
1588
|
+
IF counting_theirs IS NOT NULL THEN
|
|
1589
|
+
EXECUTE counting_theirs INTO theirs;
|
|
1590
|
+
END IF;
|
|
1591
|
+
|
|
1592
|
+
RESET ROLE;
|
|
1593
|
+
PERFORM set_config('request.jwt.claims', '', true);
|
|
1594
|
+
RETURN jsonb_build_object('ok', true, 'count', total, 'theirs', theirs);
|
|
1595
|
+
EXCEPTION WHEN OTHERS THEN
|
|
1596
|
+
-- A refusal is an answer: the table is not reachable by this caller at
|
|
1597
|
+
-- all. Which kind of refusal it was is decided by the caller.
|
|
1598
|
+
RESET ROLE;
|
|
1599
|
+
PERFORM set_config('request.jwt.claims', '', true);
|
|
1600
|
+
RETURN jsonb_build_object('ok', false, 'why', SQLERRM);
|
|
1601
|
+
END;
|
|
1602
|
+
END $$;
|
|
1603
|
+
|
|
1604
|
+
/*
|
|
1605
|
+
* What each table gives away, and to whom.
|
|
1606
|
+
*
|
|
1607
|
+
* Two separate findings, because they are two different conversations with the
|
|
1608
|
+
* person who has to fix it:
|
|
1609
|
+
*
|
|
1610
|
+
* exposed - a logged-out stranger can read the table. This is the one that
|
|
1611
|
+
* ends up on a news site.
|
|
1612
|
+
* crossed - a signed-in customer can read another customer's rows. Quieter,
|
|
1613
|
+
* and the one that breaks trust with the people already paying.
|
|
1614
|
+
*/
|
|
1615
|
+
CREATE OR REPLACE FUNCTION __KN__.impersonate(source text, tables jsonb)
|
|
1616
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
1617
|
+
DECLARE
|
|
1618
|
+
findings jsonb := '[]'::jsonb;
|
|
1619
|
+
completed jsonb := '[]'::jsonb;
|
|
1620
|
+
blocked jsonb := '[]'::jsonb;
|
|
1621
|
+
tab jsonb;
|
|
1622
|
+
owner text;
|
|
1623
|
+
anon jsonb;
|
|
1624
|
+
as_a jsonb;
|
|
1625
|
+
key text;
|
|
1626
|
+
named jsonb;
|
|
1627
|
+
reached boolean;
|
|
1628
|
+
BEGIN
|
|
1629
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
1630
|
+
owner := __KN__.owner_column(tab);
|
|
1631
|
+
named := coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c),
|
|
1632
|
+
'[]'::jsonb);
|
|
1633
|
+
|
|
1634
|
+
anon := __KN__.read_as(source, tab->>'name', 'anon', NULL, NULL);
|
|
1635
|
+
as_a := __KN__.read_as(source, tab->>'name', 'authenticated', __KN__.user_a(), owner);
|
|
1636
|
+
|
|
1637
|
+
-- Did this read produce a verdict, and if not, why not?
|
|
1638
|
+
key := 'exposed:' || (tab->>'name');
|
|
1639
|
+
IF (anon->>'ok')::boolean THEN
|
|
1640
|
+
completed := completed || to_jsonb(key);
|
|
1641
|
+
reached := true;
|
|
1642
|
+
ELSIF __KN__.refusal_means(anon->>'why') = 'unreachable' THEN
|
|
1643
|
+
-- Refused outright. The attack ran and lost, which is the result we
|
|
1644
|
+
-- want for a table that is properly closed.
|
|
1645
|
+
completed := completed || to_jsonb(key);
|
|
1646
|
+
reached := false;
|
|
1647
|
+
ELSE
|
|
1648
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
1649
|
+
'table', tab->>'name', 'key', key,
|
|
1650
|
+
'why', 'as a logged-out visitor: ' || (anon->>'why')));
|
|
1651
|
+
reached := false;
|
|
1652
|
+
END IF;
|
|
1653
|
+
|
|
1654
|
+
IF reached AND (anon->>'count')::bigint > 0 THEN
|
|
1655
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
1656
|
+
'kind', 'exposed',
|
|
1657
|
+
'table', tab->>'name',
|
|
1658
|
+
'readable', (anon->>'count')::bigint,
|
|
1659
|
+
'columns', named,
|
|
1660
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false),
|
|
1661
|
+
'isView', coalesce((tab->>'isView')::boolean, false)));
|
|
1662
|
+
END IF;
|
|
1663
|
+
|
|
1664
|
+
-- Crossed is only ever looked for where a row says who it belongs to, so
|
|
1665
|
+
-- on a table with no owner there is no attack to record either way.
|
|
1666
|
+
IF owner IS NOT NULL THEN
|
|
1667
|
+
key := 'crossed:' || (tab->>'name');
|
|
1668
|
+
IF (as_a->>'ok')::boolean THEN
|
|
1669
|
+
completed := completed || to_jsonb(key);
|
|
1670
|
+
IF coalesce((as_a->>'theirs')::bigint, 0) > 0 THEN
|
|
1671
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
1672
|
+
'kind', 'crossed',
|
|
1673
|
+
'table', tab->>'name',
|
|
1674
|
+
'owner', owner,
|
|
1675
|
+
'readable', (as_a->>'theirs')::bigint,
|
|
1676
|
+
'columns', named,
|
|
1677
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
1678
|
+
END IF;
|
|
1679
|
+
ELSIF __KN__.refusal_means(as_a->>'why') = 'unreachable' THEN
|
|
1680
|
+
completed := completed || to_jsonb(key);
|
|
1681
|
+
ELSE
|
|
1682
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
1683
|
+
'table', tab->>'name', 'key', key,
|
|
1684
|
+
'why', 'as a signed-in customer: ' || (as_a->>'why')));
|
|
1685
|
+
END IF;
|
|
1686
|
+
END IF;
|
|
1687
|
+
END LOOP;
|
|
1688
|
+
|
|
1689
|
+
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
|
|
1690
|
+
END $$;
|
|
1691
|
+
|
|
1692
|
+
-- --------------------------------------------------------------------------
|
|
1693
|
+
-- Can a stranger change your data?
|
|
1694
|
+
--
|
|
1695
|
+
-- Everything above asks whether the wrong person can READ. This asks whether
|
|
1696
|
+
-- they can WRITE, and the answer matters more: a leak is bad, but a stranger
|
|
1697
|
+
-- who can delete your customers table has taken something you cannot get back.
|
|
1698
|
+
--
|
|
1699
|
+
-- EVERYTHING HERE IS ROLLED BACK. Each write runs inside an exception block,
|
|
1700
|
+
-- which in plpgsql is a subtransaction, and the block is always left by
|
|
1701
|
+
-- raising on purpose - so the write happens, the count is kept, and the row
|
|
1702
|
+
-- is gone. Measured before it was written: probe-plpgsql.js asks exactly this.
|
|
1703
|
+
-- --------------------------------------------------------------------------
|
|
1704
|
+
|
|
1705
|
+
/*
|
|
1706
|
+
* A column an UPDATE can harmlessly set to itself.
|
|
1707
|
+
*
|
|
1708
|
+
* Setting a column to its own value changes nothing about the row while still
|
|
1709
|
+
* proving the write was allowed - so the finding is real and the data is not
|
|
1710
|
+
* even momentarily wrong inside the transaction that gets rolled back.
|
|
1711
|
+
*/
|
|
1712
|
+
CREATE OR REPLACE FUNCTION __KN__.first_writable(tab jsonb)
|
|
1713
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1714
|
+
DECLARE
|
|
1715
|
+
found text;
|
|
1716
|
+
BEGIN
|
|
1717
|
+
SELECT c->>'name' INTO found
|
|
1718
|
+
FROM jsonb_array_elements(tab->'columns') WITH ORDINALITY AS e(c, at)
|
|
1719
|
+
WHERE c->>'generated' IS NULL AND NOT (c->>'identity')::boolean
|
|
1720
|
+
AND (c->>'name') !~* '^id$'
|
|
1721
|
+
ORDER BY at LIMIT 1;
|
|
1722
|
+
IF found IS NOT NULL THEN RETURN found; END IF;
|
|
1723
|
+
|
|
1724
|
+
SELECT c->>'name' INTO found
|
|
1725
|
+
FROM jsonb_array_elements(tab->'columns') WITH ORDINALITY AS e(c, at)
|
|
1726
|
+
WHERE c->>'generated' IS NULL AND NOT (c->>'identity')::boolean
|
|
1727
|
+
ORDER BY at LIMIT 1;
|
|
1728
|
+
RETURN coalesce(found, 'id');
|
|
1729
|
+
END $$;
|
|
1730
|
+
|
|
1731
|
+
/*
|
|
1732
|
+
* What a write actually achieved.
|
|
1733
|
+
*
|
|
1734
|
+
* Three outcomes, and the middle one is the one that matters: a statement can
|
|
1735
|
+
* succeed and change nothing, which is row level security doing its job. Row
|
|
1736
|
+
* level security filters rows away rather than complaining, so every verdict
|
|
1737
|
+
* is taken from the number of rows that actually moved.
|
|
1738
|
+
*/
|
|
1739
|
+
CREATE OR REPLACE FUNCTION __KN__.what_happened(result jsonb)
|
|
1740
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
1741
|
+
SELECT CASE
|
|
1742
|
+
WHEN (result->>'ok')::boolean THEN
|
|
1743
|
+
CASE WHEN (result->>'count')::bigint > 0 THEN 'got through' ELSE 'refused' END
|
|
1744
|
+
-- A WITH CHECK turning a write away IS the app defending itself, and it is
|
|
1745
|
+
-- the single most common way a correct app says no. Reading it as "I could
|
|
1746
|
+
-- not tell" put a warning on every table that had got it right, and a
|
|
1747
|
+
-- warning nobody earned is how a report stops being read.
|
|
1748
|
+
WHEN coalesce(result->>'why', '') ~* 'violates row-level security policy' THEN 'refused'
|
|
1749
|
+
WHEN __KN__.refusal_means(result->>'why') = 'unreachable' THEN 'refused'
|
|
1750
|
+
ELSE 'untested'
|
|
1751
|
+
END;
|
|
1752
|
+
$$;
|
|
1753
|
+
|
|
1754
|
+
/*
|
|
1755
|
+
* Runs one write the way a request runs it, and never keeps the result.
|
|
1756
|
+
*
|
|
1757
|
+
* The rollback is not a tidy-up, it is the safety property. Nothing this
|
|
1758
|
+
* attack does survives the statement that did it.
|
|
1759
|
+
*
|
|
1760
|
+
* The statement arrives already written, for the same reason read_as builds
|
|
1761
|
+
* its queries up front: once this function becomes anon it can no longer call
|
|
1762
|
+
* anything in the engine schema, and a refusal from its own housekeeping is
|
|
1763
|
+
* indistinguishable from the app defending itself.
|
|
1764
|
+
*/
|
|
1765
|
+
CREATE OR REPLACE FUNCTION __KN__.try_write(role_ text, identity text, statement text)
|
|
1766
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
1767
|
+
DECLARE
|
|
1768
|
+
claims text := CASE WHEN identity IS NULL
|
|
1769
|
+
THEN json_build_object('role', role_)::text
|
|
1770
|
+
ELSE json_build_object('sub', identity, 'role', role_)::text END;
|
|
1771
|
+
moved bigint := 0;
|
|
1772
|
+
worked boolean := false;
|
|
1773
|
+
why text := NULL;
|
|
1774
|
+
BEGIN
|
|
1775
|
+
BEGIN
|
|
1776
|
+
EXECUTE 'SET LOCAL statement_timeout = ' || quote_literal('15s');
|
|
1777
|
+
EXECUTE 'SET LOCAL ROLE ' || quote_ident(role_);
|
|
1778
|
+
PERFORM set_config('request.jwt.claims', claims, true);
|
|
1779
|
+
|
|
1780
|
+
EXECUTE statement;
|
|
1781
|
+
GET DIAGNOSTICS moved = ROW_COUNT;
|
|
1782
|
+
worked := true;
|
|
1783
|
+
|
|
1784
|
+
-- Raised on purpose. The block is a subtransaction, so leaving it this way
|
|
1785
|
+
-- undoes the write while the count, which is a plpgsql variable and not a
|
|
1786
|
+
-- database change, survives.
|
|
1787
|
+
RAISE EXCEPTION 'kryptheon: undoing the write' USING ERRCODE = 'KN001';
|
|
1788
|
+
EXCEPTION
|
|
1789
|
+
WHEN SQLSTATE 'KN001' THEN
|
|
1790
|
+
NULL;
|
|
1791
|
+
WHEN OTHERS THEN
|
|
1792
|
+
worked := false;
|
|
1793
|
+
moved := 0;
|
|
1794
|
+
why := SQLERRM;
|
|
1795
|
+
END;
|
|
1796
|
+
|
|
1797
|
+
RESET ROLE;
|
|
1798
|
+
PERFORM set_config('request.jwt.claims', '', true);
|
|
1799
|
+
PERFORM set_config('statement_timeout', '0', true);
|
|
1800
|
+
RETURN jsonb_build_object('ok', worked, 'count', moved, 'why', why);
|
|
1801
|
+
END $$;
|
|
1802
|
+
|
|
1803
|
+
/*
|
|
1804
|
+
* Every way in, per table, per kind of caller.
|
|
1805
|
+
*
|
|
1806
|
+
* `seeded` carries the shape of row that worked when the table was seeded, so
|
|
1807
|
+
* the insert here is not rejected by some CHECK the seeder already solved.
|
|
1808
|
+
*/
|
|
1809
|
+
CREATE OR REPLACE FUNCTION __KN__.tamper(source text, tables jsonb, seeded jsonb,
|
|
1810
|
+
policies jsonb DEFAULT '[]'::jsonb)
|
|
1811
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
1812
|
+
DECLARE
|
|
1813
|
+
findings jsonb := '[]'::jsonb;
|
|
1814
|
+
completed jsonb := '[]'::jsonb;
|
|
1815
|
+
blocked jsonb := '[]'::jsonb;
|
|
1816
|
+
tab jsonb;
|
|
1817
|
+
owner text;
|
|
1818
|
+
shape integer;
|
|
1819
|
+
actor record;
|
|
1820
|
+
key text;
|
|
1821
|
+
can jsonb;
|
|
1822
|
+
changed jsonb;
|
|
1823
|
+
stuck text;
|
|
1824
|
+
their_rows text;
|
|
1825
|
+
at text;
|
|
1826
|
+
row_ jsonb;
|
|
1827
|
+
column_ text;
|
|
1828
|
+
moves jsonb;
|
|
1829
|
+
move jsonb;
|
|
1830
|
+
result jsonb;
|
|
1831
|
+
outcome text;
|
|
1832
|
+
-- Which rules each table actually has, so the report can say why a write
|
|
1833
|
+
-- got through instead of assuming. Permissive only: a restrictive policy can
|
|
1834
|
+
-- narrow what is allowed and never open it, so it is never the reason.
|
|
1835
|
+
rules_for jsonb;
|
|
1836
|
+
named jsonb;
|
|
1837
|
+
BEGIN
|
|
1838
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
1839
|
+
-- Never seeded, so there is nothing in it to protect.
|
|
1840
|
+
SELECT (s->>'attempt')::integer INTO shape
|
|
1841
|
+
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
1842
|
+
WHERE s->>'table' = tab->>'name';
|
|
1843
|
+
CONTINUE WHEN NOT FOUND;
|
|
1844
|
+
shape := coalesce(shape, 0);
|
|
1845
|
+
|
|
1846
|
+
-- Read off the table, never assumed. The node engine said of every
|
|
1847
|
+
-- writable table with row level security on that its rule "covers every
|
|
1848
|
+
-- command rather than only reading"; measured against a table whose only
|
|
1849
|
+
-- policy was FOR INSERT, that was false twice over. The finding was
|
|
1850
|
+
-- proved and the explanation invented, so the explanation is now read.
|
|
1851
|
+
SELECT coalesce(jsonb_agg(DISTINCT upper(p->>'cmd')), '[]'::jsonb)
|
|
1852
|
+
INTO rules_for
|
|
1853
|
+
FROM jsonb_array_elements(coalesce(policies, '[]'::jsonb)) p
|
|
1854
|
+
WHERE p->>'table_name' = tab->>'name'
|
|
1855
|
+
AND upper(coalesce(p->>'permissive', 'PERMISSIVE')) = 'PERMISSIVE';
|
|
1856
|
+
|
|
1857
|
+
owner := __KN__.owner_column(tab);
|
|
1858
|
+
at := __KN__.always_quote(source) || '.' || __KN__.always_quote(tab->>'name');
|
|
1859
|
+
column_ := __KN__.always_quote(__KN__.first_writable(tab));
|
|
1860
|
+
named := coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c),
|
|
1861
|
+
'[]'::jsonb);
|
|
1862
|
+
|
|
1863
|
+
-- Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
1864
|
+
-- customer deleting their OWN rows is not a finding, it is the feature,
|
|
1865
|
+
-- and an unscoped DELETE would report every correctly built app.
|
|
1866
|
+
their_rows := CASE WHEN owner IS NULL THEN ''
|
|
1867
|
+
ELSE ' WHERE ' || __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a()) END;
|
|
1868
|
+
|
|
1869
|
+
-- Written under a name nobody has used, which is both what makes the row
|
|
1870
|
+
-- land at all and what makes it the right test: adding a row of your own
|
|
1871
|
+
-- is the feature, adding one under somebody else's name is not.
|
|
1872
|
+
row_ := __KN__.row_for(source, tab, __KN__.user_c(), '7', NULL, shape);
|
|
1873
|
+
|
|
1874
|
+
-- Every statement written out before anybody changes role.
|
|
1875
|
+
moves := jsonb_build_array(
|
|
1876
|
+
jsonb_build_object('what', 'add',
|
|
1877
|
+
'statement', __KN__.insert_statement(source, tab->>'name', row_)),
|
|
1878
|
+
jsonb_build_object('what', 'change',
|
|
1879
|
+
'statement', 'UPDATE ' || at || ' SET ' || column_ || ' = ' || column_ || their_rows),
|
|
1880
|
+
jsonb_build_object('what', 'delete',
|
|
1881
|
+
'statement', 'DELETE FROM ' || at || their_rows));
|
|
1882
|
+
|
|
1883
|
+
FOR actor IN
|
|
1884
|
+
SELECT * FROM (VALUES
|
|
1885
|
+
('anyone', 'anon', NULL::text),
|
|
1886
|
+
('any customer', 'authenticated', __KN__.user_b())
|
|
1887
|
+
) AS a(who, role_, identity)
|
|
1888
|
+
LOOP
|
|
1889
|
+
key := 'writable:' || (tab->>'name') || ':' || actor.who;
|
|
1890
|
+
can := '[]'::jsonb;
|
|
1891
|
+
changed := '{}'::jsonb;
|
|
1892
|
+
stuck := NULL;
|
|
1893
|
+
|
|
1894
|
+
FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
|
|
1895
|
+
result := __KN__.try_write(actor.role_, actor.identity, move->>'statement');
|
|
1896
|
+
outcome := __KN__.what_happened(result);
|
|
1897
|
+
IF outcome = 'got through' THEN
|
|
1898
|
+
can := can || to_jsonb(move->>'what');
|
|
1899
|
+
changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb((result->>'count')::bigint));
|
|
1900
|
+
ELSIF outcome = 'untested' THEN
|
|
1901
|
+
stuck := result->>'why';
|
|
1902
|
+
END IF;
|
|
1903
|
+
END LOOP;
|
|
1904
|
+
|
|
1905
|
+
IF stuck IS NOT NULL THEN
|
|
1906
|
+
-- Something went wrong that was not the app defending itself, so no
|
|
1907
|
+
-- verdict exists for this table and saying nothing would read as safe.
|
|
1908
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
1909
|
+
'table', tab->>'name', 'key', key,
|
|
1910
|
+
'why', 'as ' || actor.who || ': ' || stuck));
|
|
1911
|
+
CONTINUE;
|
|
1912
|
+
END IF;
|
|
1913
|
+
|
|
1914
|
+
completed := completed || to_jsonb(key);
|
|
1915
|
+
IF jsonb_array_length(can) > 0 THEN
|
|
1916
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
1917
|
+
'kind', 'writable',
|
|
1918
|
+
'table', tab->>'name',
|
|
1919
|
+
'who', actor.who,
|
|
1920
|
+
'can', can,
|
|
1921
|
+
'changed', changed,
|
|
1922
|
+
'owner', owner,
|
|
1923
|
+
'columns', named,
|
|
1924
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false),
|
|
1925
|
+
'rules', rules_for));
|
|
1926
|
+
END IF;
|
|
1927
|
+
END LOOP;
|
|
1928
|
+
END LOOP;
|
|
1929
|
+
|
|
1930
|
+
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
|
|
1931
|
+
END $$;
|
|
1932
|
+
|
|
1933
|
+
-- --------------------------------------------------------------------------
|
|
1934
|
+
-- The interruption attack: can a half-finished write survive?
|
|
1935
|
+
--
|
|
1936
|
+
-- A request cut off partway is mostly a question about the app's code - were
|
|
1937
|
+
-- the two inserts wrapped in a transaction? - and this tool never sees the
|
|
1938
|
+
-- app's code. But there is a half of it the database answers on its own. A
|
|
1939
|
+
-- foreign key is what makes a half-finished state impossible to keep, no
|
|
1940
|
+
-- matter how badly the app behaves or where the connection drops.
|
|
1941
|
+
--
|
|
1942
|
+
-- So the attack is: put in a row pointing at something that is not there, and
|
|
1943
|
+
-- see whether the database takes it. Rolled back, always.
|
|
1944
|
+
-- --------------------------------------------------------------------------
|
|
1945
|
+
|
|
1946
|
+
/*
|
|
1947
|
+
* Tables whose job is to remember things after they are gone.
|
|
1948
|
+
*
|
|
1949
|
+
* A dangling id in an audit row is the feature, and a foreign key there would
|
|
1950
|
+
* be the bug.
|
|
1951
|
+
*/
|
|
1952
|
+
CREATE OR REPLACE FUNCTION __KN__.keeps_history(name text)
|
|
1953
|
+
RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
|
|
1954
|
+
SELECT name ~* '(^|_)(log|logs|audit|audits|event|events|history|archive|archives|snapshot|snapshots|activity|activities)(_|$)';
|
|
1955
|
+
$$;
|
|
1956
|
+
|
|
1957
|
+
/* The single-column primary key of a table, or NULL if it has none. */
|
|
1958
|
+
CREATE OR REPLACE FUNCTION __KN__.primary_key_of(tab jsonb)
|
|
1959
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1960
|
+
DECLARE
|
|
1961
|
+
con jsonb;
|
|
1962
|
+
inside text;
|
|
1963
|
+
names jsonb;
|
|
1964
|
+
BEGIN
|
|
1965
|
+
FOR con IN SELECT * FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) LOOP
|
|
1966
|
+
CONTINUE WHEN con->>'kind' <> 'p';
|
|
1967
|
+
inside := (regexp_match(con->>'definition', 'PRIMARY KEY \(([^)]+)\)', 'i'))[1];
|
|
1968
|
+
CONTINUE WHEN inside IS NULL;
|
|
1969
|
+
names := __KN__.unquoted_list(inside);
|
|
1970
|
+
-- A composite key is not something a single `<thing>_id` column points at.
|
|
1971
|
+
IF jsonb_array_length(names) = 1 THEN RETURN names->>0; END IF;
|
|
1972
|
+
RETURN NULL;
|
|
1973
|
+
END LOOP;
|
|
1974
|
+
RETURN NULL;
|
|
1975
|
+
END $$;
|
|
1976
|
+
|
|
1977
|
+
/*
|
|
1978
|
+
* The table a column named `<thing>_id` is pointing at, if there is one.
|
|
1979
|
+
*
|
|
1980
|
+
* The name has to match a table that is really here, and the types have to
|
|
1981
|
+
* agree. Both, because `stripe_id` matches nothing and `org_id integer` does
|
|
1982
|
+
* not point at an `orgs.id` that is a uuid. Telling somebody to add a foreign
|
|
1983
|
+
* key to a column that names something outside this database would be telling
|
|
1984
|
+
* them to break their app.
|
|
1985
|
+
*/
|
|
1986
|
+
CREATE OR REPLACE FUNCTION __KN__.parent_for(col jsonb, tables jsonb)
|
|
1987
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
1988
|
+
DECLARE
|
|
1989
|
+
stem text := (regexp_match(col->>'name', '^(.+)_id$', 'i'))[1];
|
|
1990
|
+
wanted text;
|
|
1991
|
+
parent jsonb;
|
|
1992
|
+
key text;
|
|
1993
|
+
key_column jsonb;
|
|
1994
|
+
BEGIN
|
|
1995
|
+
IF stem IS NULL THEN RETURN NULL; END IF;
|
|
1996
|
+
wanted := lower(stem);
|
|
1997
|
+
|
|
1998
|
+
SELECT t INTO parent FROM jsonb_array_elements(tables) t
|
|
1999
|
+
WHERE lower(t->>'name') IN (wanted, wanted || 's', wanted || 'es')
|
|
2000
|
+
LIMIT 1;
|
|
2001
|
+
IF parent IS NULL THEN RETURN NULL; END IF;
|
|
2002
|
+
|
|
2003
|
+
key := __KN__.primary_key_of(parent);
|
|
2004
|
+
IF key IS NULL THEN RETURN NULL; END IF;
|
|
2005
|
+
|
|
2006
|
+
SELECT c INTO key_column FROM jsonb_array_elements(parent->'columns') c
|
|
2007
|
+
WHERE c->>'name' = key LIMIT 1;
|
|
2008
|
+
IF key_column IS NULL OR (key_column->>'type') IS DISTINCT FROM (col->>'type') THEN
|
|
2009
|
+
RETURN NULL;
|
|
2010
|
+
END IF;
|
|
2011
|
+
|
|
2012
|
+
RETURN jsonb_build_object('table', parent->>'name', 'keyColumn', key, 'type', key_column->>'type');
|
|
2013
|
+
END $$;
|
|
2014
|
+
|
|
2015
|
+
/* Is this column already held down by a foreign key? */
|
|
2016
|
+
CREATE OR REPLACE FUNCTION __KN__.already_tied(tab jsonb, column_name text)
|
|
2017
|
+
RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
|
|
2018
|
+
SELECT EXISTS (
|
|
2019
|
+
SELECT 1 FROM jsonb_array_elements(__KN__.foreign_keys(tab)) k
|
|
2020
|
+
WHERE k->'columns' ? column_name);
|
|
2021
|
+
$$;
|
|
2022
|
+
|
|
2023
|
+
/*
|
|
2024
|
+
* Every column that looks like it points somewhere, with whether it is already
|
|
2025
|
+
* tied down.
|
|
2026
|
+
*
|
|
2027
|
+
* Tied columns are attacked too. Skipping them would mean that the moment
|
|
2028
|
+
* somebody adds the foreign key this asked for, the attack stops running - and
|
|
2029
|
+
* the re-check could no longer watch it be refused, so it would report the fix
|
|
2030
|
+
* it requested as "could not confirm".
|
|
2031
|
+
*
|
|
2032
|
+
* `tied` decides what is reported, never what is attempted.
|
|
2033
|
+
*/
|
|
2034
|
+
CREATE OR REPLACE FUNCTION __KN__.candidates(tables jsonb)
|
|
2035
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2036
|
+
DECLARE
|
|
2037
|
+
found jsonb := '[]'::jsonb;
|
|
2038
|
+
tab jsonb;
|
|
2039
|
+
col jsonb;
|
|
2040
|
+
parent jsonb;
|
|
2041
|
+
BEGIN
|
|
2042
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2043
|
+
CONTINUE WHEN __KN__.keeps_history(tab->>'name');
|
|
2044
|
+
FOR col IN SELECT * FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) LOOP
|
|
2045
|
+
parent := __KN__.parent_for(col, tables);
|
|
2046
|
+
CONTINUE WHEN parent IS NULL;
|
|
2047
|
+
found := found || jsonb_build_array(jsonb_build_object(
|
|
2048
|
+
'table', tab->>'name',
|
|
2049
|
+
'column', col->>'name',
|
|
2050
|
+
'parent', parent->>'table',
|
|
2051
|
+
'parentKey', parent->>'keyColumn',
|
|
2052
|
+
'type', parent->>'type',
|
|
2053
|
+
'tied', __KN__.already_tied(tab, col->>'name')));
|
|
2054
|
+
END LOOP;
|
|
2055
|
+
END LOOP;
|
|
2056
|
+
RETURN found;
|
|
2057
|
+
END $$;
|
|
2058
|
+
|
|
2059
|
+
/* A value of the right type that is certainly not in the parent table. */
|
|
2060
|
+
CREATE OR REPLACE FUNCTION __KN__.nobody(kind text)
|
|
2061
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
2062
|
+
SELECT CASE
|
|
2063
|
+
WHEN lower(kind) = 'uuid' THEN '99999999-9999-4999-8999-999999999999'
|
|
2064
|
+
WHEN lower(kind) ~ '^(integer|bigint|smallint)' THEN '2147480000'
|
|
2065
|
+
WHEN lower(kind) ~ '^(numeric|decimal|real|double)' THEN '2147480000'
|
|
2066
|
+
ELSE 'kryptheon-nobody'
|
|
2067
|
+
END;
|
|
2068
|
+
$$;
|
|
2069
|
+
|
|
2070
|
+
/*
|
|
2071
|
+
* Point a row at something that is not there, and see if it is taken.
|
|
2072
|
+
*
|
|
2073
|
+
* Run as the owner of the schema on purpose. The question is not who is
|
|
2074
|
+
* allowed to create an orphan - it is whether the database permits one to
|
|
2075
|
+
* exist at all, which is what decides whether a dropped connection can leave
|
|
2076
|
+
* one behind.
|
|
2077
|
+
*/
|
|
2078
|
+
CREATE OR REPLACE FUNCTION __KN__.orphan(source text, tables jsonb, seeded jsonb)
|
|
2079
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
2080
|
+
DECLARE
|
|
2081
|
+
findings jsonb := '[]'::jsonb;
|
|
2082
|
+
completed jsonb := '[]'::jsonb;
|
|
2083
|
+
not_tried jsonb := '[]'::jsonb;
|
|
2084
|
+
target jsonb;
|
|
2085
|
+
tab jsonb;
|
|
2086
|
+
key text;
|
|
2087
|
+
missing text;
|
|
2088
|
+
already integer;
|
|
2089
|
+
shape integer;
|
|
2090
|
+
row_ jsonb;
|
|
2091
|
+
statement text;
|
|
2092
|
+
landed boolean;
|
|
2093
|
+
refused text;
|
|
2094
|
+
named jsonb;
|
|
2095
|
+
BEGIN
|
|
2096
|
+
FOR target IN SELECT * FROM jsonb_array_elements(__KN__.candidates(tables)) LOOP
|
|
2097
|
+
key := 'orphaned:' || (target->>'table') || ':' || (target->>'column');
|
|
2098
|
+
SELECT t INTO tab FROM jsonb_array_elements(tables) t WHERE t->>'name' = target->>'table';
|
|
2099
|
+
missing := __KN__.nobody(target->>'type');
|
|
2100
|
+
|
|
2101
|
+
-- It only proves anything if the value really is absent from the parent.
|
|
2102
|
+
BEGIN
|
|
2103
|
+
EXECUTE 'SELECT 1 FROM ' || __KN__.always_quote(source) || '.'
|
|
2104
|
+
|| __KN__.always_quote(target->>'parent') || ' WHERE '
|
|
2105
|
+
|| __KN__.always_quote(target->>'parentKey') || '::text = '
|
|
2106
|
+
|| quote_literal(missing) || ' LIMIT 1'
|
|
2107
|
+
INTO already;
|
|
2108
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2109
|
+
not_tried := not_tried || jsonb_build_array(jsonb_build_object(
|
|
2110
|
+
'table', target->>'table', 'column', target->>'column',
|
|
2111
|
+
'why', 'could not look in ' || (target->>'parent') || ': ' || SQLERRM));
|
|
2112
|
+
CONTINUE;
|
|
2113
|
+
END;
|
|
2114
|
+
IF already IS NOT NULL THEN
|
|
2115
|
+
not_tried := not_tried || jsonb_build_array(jsonb_build_object(
|
|
2116
|
+
'table', target->>'table', 'column', target->>'column',
|
|
2117
|
+
'why', 'the test value was already in ' || (target->>'parent')));
|
|
2118
|
+
CONTINUE;
|
|
2119
|
+
END IF;
|
|
2120
|
+
|
|
2121
|
+
SELECT (s->>'attempt')::integer INTO shape
|
|
2122
|
+
FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
2123
|
+
WHERE s->>'table' = target->>'table';
|
|
2124
|
+
shape := coalesce(shape, 0);
|
|
2125
|
+
|
|
2126
|
+
BEGIN
|
|
2127
|
+
row_ := __KN__.row_for(source, tab, __KN__.user_c(), '9',
|
|
2128
|
+
jsonb_build_object(target->>'column', to_jsonb(missing)), shape);
|
|
2129
|
+
statement := __KN__.insert_statement(source, target->>'table', row_);
|
|
2130
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2131
|
+
not_tried := not_tried || jsonb_build_array(jsonb_build_object(
|
|
2132
|
+
'table', target->>'table', 'column', target->>'column', 'why', SQLERRM));
|
|
2133
|
+
CONTINUE;
|
|
2134
|
+
END;
|
|
2135
|
+
|
|
2136
|
+
landed := false;
|
|
2137
|
+
refused := NULL;
|
|
2138
|
+
BEGIN
|
|
2139
|
+
EXECUTE 'SET LOCAL statement_timeout = ' || quote_literal('15s');
|
|
2140
|
+
EXECUTE statement;
|
|
2141
|
+
landed := true;
|
|
2142
|
+
-- Raised on purpose, to undo the row that just landed. The block is a
|
|
2143
|
+
-- subtransaction; the fact that it landed is a variable and survives.
|
|
2144
|
+
RAISE EXCEPTION 'kryptheon: undoing the orphan' USING ERRCODE = 'KN001';
|
|
2145
|
+
EXCEPTION
|
|
2146
|
+
WHEN SQLSTATE 'KN001' THEN
|
|
2147
|
+
NULL;
|
|
2148
|
+
WHEN OTHERS THEN
|
|
2149
|
+
landed := false;
|
|
2150
|
+
refused := SQLERRM;
|
|
2151
|
+
END;
|
|
2152
|
+
PERFORM set_config('statement_timeout', '0', true);
|
|
2153
|
+
|
|
2154
|
+
IF NOT landed AND coalesce(refused, '') !~* 'violates foreign key constraint' THEN
|
|
2155
|
+
-- Turned away by something other than referential integrity, so nothing
|
|
2156
|
+
-- was learned about whether an orphan can exist.
|
|
2157
|
+
not_tried := not_tried || jsonb_build_array(jsonb_build_object(
|
|
2158
|
+
'table', target->>'table', 'column', target->>'column', 'why', refused));
|
|
2159
|
+
CONTINUE;
|
|
2160
|
+
END IF;
|
|
2161
|
+
|
|
2162
|
+
completed := completed || to_jsonb(key);
|
|
2163
|
+
-- Whether a key is supposedly there does not come into it. What is
|
|
2164
|
+
-- reported is what landed.
|
|
2165
|
+
IF landed THEN
|
|
2166
|
+
named := coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c),
|
|
2167
|
+
'[]'::jsonb);
|
|
2168
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
2169
|
+
'kind', 'orphaned',
|
|
2170
|
+
'table', target->>'table',
|
|
2171
|
+
'column', target->>'column',
|
|
2172
|
+
'parent', target->>'parent',
|
|
2173
|
+
'parentKey', target->>'parentKey',
|
|
2174
|
+
'columns', named));
|
|
2175
|
+
END IF;
|
|
2176
|
+
END LOOP;
|
|
2177
|
+
|
|
2178
|
+
RETURN jsonb_build_object('findings', findings, 'completed', completed, 'notTried', not_tried);
|
|
2179
|
+
END $$;
|
|
2180
|
+
|
|
2181
|
+
-- --------------------------------------------------------------------------
|
|
2182
|
+
-- The collision attack, and why this engine cannot run it.
|
|
2183
|
+
--
|
|
2184
|
+
-- "Can the same thing exist twice" is answered by two requests arriving at the
|
|
2185
|
+
-- same instant: the second insert has to be in flight while the first
|
|
2186
|
+
-- transaction is still open. A plpgsql function is one session, so it needs a
|
|
2187
|
+
-- second one from inside the database.
|
|
2188
|
+
--
|
|
2189
|
+
-- MEASURED, ON A REAL HOST (scratchpad/dblinkrace.js)
|
|
2190
|
+
--
|
|
2191
|
+
-- dblink is available and this role may even create it. It cannot connect
|
|
2192
|
+
-- back to its own database without a password - dbname alone, an empty
|
|
2193
|
+
-- conninfo and a local socket all answer "password or GSSAPI delegated
|
|
2194
|
+
-- credentials required". So the only way to open that second session is to
|
|
2195
|
+
-- hold a credential, and the whole reason this engine exists is that no
|
|
2196
|
+
-- credential ever moves.
|
|
2197
|
+
--
|
|
2198
|
+
-- So it is not run here. What matters is what that is called. An attack that
|
|
2199
|
+
-- did not run is not a table that held, and reporting nothing would read as
|
|
2200
|
+
-- safety - so every column that WOULD have been raced comes back as notTried,
|
|
2201
|
+
-- by name, with the reason. The npx door still races them for real, because
|
|
2202
|
+
-- Node has two connections and can.
|
|
2203
|
+
--
|
|
2204
|
+
-- The candidates are worked out here in full, identically to the other engine,
|
|
2205
|
+
-- precisely so that the two can be compared: what was considered has to match
|
|
2206
|
+
-- even when what was concluded cannot.
|
|
2207
|
+
-- --------------------------------------------------------------------------
|
|
2208
|
+
|
|
2209
|
+
/*
|
|
2210
|
+
* Columns where two rows holding one value is a security problem rather than
|
|
2211
|
+
* an untidy spreadsheet.
|
|
2212
|
+
*
|
|
2213
|
+
* Anchored on the whole name on purpose. `token` is a credential; `token_id`,
|
|
2214
|
+
* `token_expires_at` and `has_token` are not, and matching loosely would put
|
|
2215
|
+
* three false alarms on screen for every real one.
|
|
2216
|
+
*/
|
|
2217
|
+
CREATE OR REPLACE FUNCTION __KN__.must_be_unique(column_name text, table_name text)
|
|
2218
|
+
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
|
2219
|
+
SELECT CASE
|
|
2220
|
+
-- One secret matching two rows opens two different doors.
|
|
2221
|
+
WHEN column_name ~* '^(token|auth_token|access_token|refresh_token|reset_token|session_token|session_id|api_key|apikey|access_key|secret_key)$'
|
|
2222
|
+
THEN 'credential'
|
|
2223
|
+
-- A one-time code that can exist twice can be redeemed twice.
|
|
2224
|
+
WHEN column_name ~* '^(invite_code|invitation_code|coupon_code|promo_code|promotion_code|referral_code|voucher_code|redemption_code|activation_code|license_key|serial_key)$'
|
|
2225
|
+
THEN 'code'
|
|
2226
|
+
-- Two accounts answering to one login make "who is this" ambiguous, and
|
|
2227
|
+
-- password reset has to pick one of them. Only on a table that really is
|
|
2228
|
+
-- the account table: `customers` is left alone on purpose, because in half
|
|
2229
|
+
-- the apps it is a contact list where a shared office email is correct.
|
|
2230
|
+
WHEN column_name ~* '^(email|e_mail|username|user_name|handle|login)$'
|
|
2231
|
+
AND table_name ~* '^(users?|profiles?|accounts?|members?|auth_users|app_users|logins?)$'
|
|
2232
|
+
THEN 'identity'
|
|
2233
|
+
ELSE NULL
|
|
2234
|
+
END;
|
|
2235
|
+
$$;
|
|
2236
|
+
|
|
2237
|
+
/*
|
|
2238
|
+
* Is this column already protected?
|
|
2239
|
+
*
|
|
2240
|
+
* Deliberately generous. A composite UNIQUE (org_id, email) counts, because
|
|
2241
|
+
* the same email in two different organisations is how multi-tenant apps are
|
|
2242
|
+
* supposed to work. A partial index counts, and so does an expression index on
|
|
2243
|
+
* lower(email). The cost is that a column merely mentioned in some other
|
|
2244
|
+
* index's WHERE clause also counts and gets skipped - an attack not run rather
|
|
2245
|
+
* than a false alarm raised, which is the right way round.
|
|
2246
|
+
*/
|
|
2247
|
+
CREATE OR REPLACE FUNCTION __KN__.covered_by_unique(tab jsonb, column_name text, indexes jsonb)
|
|
2248
|
+
RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2249
|
+
DECLARE
|
|
2250
|
+
word text := '(^|[^A-Za-z0-9_])'
|
|
2251
|
+
|| regexp_replace(column_name, '([.*+?^${}()|\[\]\\])', '\\\1', 'g')
|
|
2252
|
+
|| '([^A-Za-z0-9_]|$)';
|
|
2253
|
+
text_ text;
|
|
2254
|
+
BEGIN
|
|
2255
|
+
FOR text_ IN
|
|
2256
|
+
SELECT c->>'definition' FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) c
|
|
2257
|
+
WHERE c->>'kind' IN ('u', 'p')
|
|
2258
|
+
UNION ALL
|
|
2259
|
+
SELECT i->>'definition' FROM jsonb_array_elements(coalesce(indexes, '[]'::jsonb)) i
|
|
2260
|
+
WHERE i->>'table_name' = tab->>'name'
|
|
2261
|
+
LOOP
|
|
2262
|
+
IF text_ ~ word THEN RETURN true; END IF;
|
|
2263
|
+
END LOOP;
|
|
2264
|
+
RETURN false;
|
|
2265
|
+
END $$;
|
|
2266
|
+
|
|
2267
|
+
/* Every column where the same value twice would be somebody's problem. */
|
|
2268
|
+
CREATE OR REPLACE FUNCTION __KN__.collision_candidates(tables jsonb, indexes jsonb)
|
|
2269
|
+
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2270
|
+
DECLARE
|
|
2271
|
+
found jsonb := '[]'::jsonb;
|
|
2272
|
+
tab jsonb;
|
|
2273
|
+
col jsonb;
|
|
2274
|
+
expectation text;
|
|
2275
|
+
BEGIN
|
|
2276
|
+
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
2277
|
+
FOR col IN SELECT * FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) LOOP
|
|
2278
|
+
expectation := __KN__.must_be_unique(col->>'name', tab->>'name');
|
|
2279
|
+
CONTINUE WHEN expectation IS NULL;
|
|
2280
|
+
found := found || jsonb_build_array(jsonb_build_object(
|
|
2281
|
+
'table', tab->>'name',
|
|
2282
|
+
'column', col->>'name',
|
|
2283
|
+
'expectation', expectation,
|
|
2284
|
+
'type', col->>'type',
|
|
2285
|
+
'covered', __KN__.covered_by_unique(tab, col->>'name', indexes)));
|
|
2286
|
+
END LOOP;
|
|
2287
|
+
END LOOP;
|
|
2288
|
+
RETURN found;
|
|
2289
|
+
END $$;
|
|
2290
|
+
|
|
2291
|
+
/*
|
|
2292
|
+
* What this engine can say about "can the same thing exist twice".
|
|
2293
|
+
*
|
|
2294
|
+
* Nothing, and it says so by name. Reporting an empty result would be the one
|
|
2295
|
+
* mistake this product cannot afford: a column nobody tested reading as a
|
|
2296
|
+
* column that held.
|
|
2297
|
+
*/
|
|
2298
|
+
CREATE OR REPLACE FUNCTION __KN__.collide(source text, tables jsonb, indexes jsonb)
|
|
2299
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
2300
|
+
DECLARE
|
|
2301
|
+
not_tried jsonb := '[]'::jsonb;
|
|
2302
|
+
one jsonb;
|
|
2303
|
+
why constant text :=
|
|
2304
|
+
'this attack needs two requests at the same instant, and a second connection '
|
|
2305
|
+
'cannot be opened from inside the database without a credential - which this '
|
|
2306
|
+
'engine is never given. Run the command line scan to have it raced for real.';
|
|
2307
|
+
BEGIN
|
|
2308
|
+
FOR one IN SELECT * FROM jsonb_array_elements(
|
|
2309
|
+
__KN__.collision_candidates(tables, indexes)) LOOP
|
|
2310
|
+
not_tried := not_tried || jsonb_build_array(jsonb_build_object(
|
|
2311
|
+
'table', one->>'table',
|
|
2312
|
+
'column', one->>'column',
|
|
2313
|
+
'why', why));
|
|
2314
|
+
END LOOP;
|
|
2315
|
+
|
|
2316
|
+
RETURN jsonb_build_object(
|
|
2317
|
+
'findings', '[]'::jsonb,
|
|
2318
|
+
'completed', '[]'::jsonb,
|
|
2319
|
+
'notTried', not_tried);
|
|
2320
|
+
END $$;
|
|
2321
|
+
-- --------------------------------------------------------------------------
|
|
2322
|
+
-- Slice 5: the night's work, run from inside the database.
|
|
2323
|
+
--
|
|
2324
|
+
-- The other door. Down `npx` the engine is installed, used and dropped inside
|
|
2325
|
+
-- one command; here the same functions stay put and pg_cron calls the one
|
|
2326
|
+
-- below at three in the morning. Nothing in it is new work: it is read_schema,
|
|
2327
|
+
-- write_schema, seed, impersonate, tamper, orphan and collide in the order
|
|
2328
|
+
-- scan.js runs them, with the answer written to a table instead of to a
|
|
2329
|
+
-- screen. If the two doors ever stop agreeing, it is this ordering that has
|
|
2330
|
+
-- drifted.
|
|
2331
|
+
--
|
|
2332
|
+
-- The one thing this door cannot do is race two requests at once, so collide
|
|
2333
|
+
-- names every column it would have tried and says why. That is written up in
|
|
2334
|
+
-- STATE.md and said out loud in the report: the nightly run is strictly weaker
|
|
2335
|
+
-- than the command line for exactly one attack.
|
|
2336
|
+
-- --------------------------------------------------------------------------
|
|
2337
|
+
|
|
2338
|
+
/*
|
|
2339
|
+
* The copy's name, in the form every sweep in this product recognises.
|
|
2340
|
+
*
|
|
2341
|
+
* `kn_<milliseconds in base 36>`, because `sweepOldCopies` in scan.js reads
|
|
2342
|
+
* the age of an abandoned copy out of its name - there is nothing in Postgres
|
|
2343
|
+
* that records when a schema was made. A nightly run that named its copies
|
|
2344
|
+
* any other way would leave them in the customer's database for ever, and the
|
|
2345
|
+
* sweep would walk straight past them. Postgres has no base 36, so here it is.
|
|
2346
|
+
*/
|
|
2347
|
+
CREATE OR REPLACE FUNCTION __KN__.base36(n bigint)
|
|
2348
|
+
RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
|
|
2349
|
+
DECLARE
|
|
2350
|
+
digits constant text := '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
2351
|
+
out text := '';
|
|
2352
|
+
left_ bigint := n;
|
|
2353
|
+
BEGIN
|
|
2354
|
+
IF left_ IS NULL OR left_ <= 0 THEN RETURN '0'; END IF;
|
|
2355
|
+
WHILE left_ > 0 LOOP
|
|
2356
|
+
out := substr(digits, (left_ % 36)::integer + 1, 1) || out;
|
|
2357
|
+
left_ := left_ / 36;
|
|
2358
|
+
END LOOP;
|
|
2359
|
+
RETURN out;
|
|
2360
|
+
END $$;
|
|
2361
|
+
|
|
2362
|
+
/* Where the nightly answers are kept, for `kryptheon night` to read back. */
|
|
2363
|
+
CREATE TABLE IF NOT EXISTS __KN__.runs (
|
|
2364
|
+
id bigserial PRIMARY KEY,
|
|
2365
|
+
ran_at timestamptz NOT NULL DEFAULT now(),
|
|
2366
|
+
source text NOT NULL,
|
|
2367
|
+
stopped text,
|
|
2368
|
+
attacks_run integer NOT NULL DEFAULT 0,
|
|
2369
|
+
findings jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
2370
|
+
not_checked jsonb NOT NULL DEFAULT '[]'::jsonb
|
|
2371
|
+
);
|
|
2372
|
+
|
|
2373
|
+
/*
|
|
2374
|
+
* One night's work, and the id of the row it wrote.
|
|
2375
|
+
*
|
|
2376
|
+
* Every failure is recorded rather than raised. A nightly job that throws
|
|
2377
|
+
* leaves pg_cron with a message nobody reads and the customer with no answer
|
|
2378
|
+
* at all - and "no answer" and "nothing got through" look identical from the
|
|
2379
|
+
* outside, which is the one confusion this product exists to prevent.
|
|
2380
|
+
*/
|
|
2381
|
+
CREATE OR REPLACE FUNCTION __KN__.nightly(source text)
|
|
2382
|
+
RETURNS bigint LANGUAGE plpgsql AS $$
|
|
2383
|
+
DECLARE
|
|
2384
|
+
copy_name text := 'kn_' || __KN__.base36((extract(epoch from clock_timestamp()) * 1000)::bigint);
|
|
2385
|
+
plan jsonb;
|
|
2386
|
+
copy_plan jsonb;
|
|
2387
|
+
stand_ins jsonb;
|
|
2388
|
+
theirs jsonb;
|
|
2389
|
+
views jsonb;
|
|
2390
|
+
sown jsonb;
|
|
2391
|
+
seen jsonb;
|
|
2392
|
+
wrote jsonb;
|
|
2393
|
+
stranded jsonb;
|
|
2394
|
+
raced jsonb;
|
|
2395
|
+
findings jsonb := '[]'::jsonb;
|
|
2396
|
+
not_checked jsonb := '[]'::jsonb;
|
|
2397
|
+
attempted jsonb := '[]'::jsonb;
|
|
2398
|
+
run_id bigint;
|
|
2399
|
+
why text;
|
|
2400
|
+
BEGIN
|
|
2401
|
+
BEGIN
|
|
2402
|
+
plan := __KN__.read_schema(source);
|
|
2403
|
+
|
|
2404
|
+
IF jsonb_array_length(plan->'tables') = 0 THEN
|
|
2405
|
+
INSERT INTO __KN__.runs (source, stopped)
|
|
2406
|
+
VALUES (source, 'The schema "' || source || '" has no tables in it, so there was nothing to attack.')
|
|
2407
|
+
RETURNING id INTO run_id;
|
|
2408
|
+
RETURN run_id;
|
|
2409
|
+
END IF;
|
|
2410
|
+
|
|
2411
|
+
IF jsonb_array_length(coalesce(plan->'unsupported', '[]'::jsonb)) > 0 THEN
|
|
2412
|
+
INSERT INTO __KN__.runs (source, stopped)
|
|
2413
|
+
VALUES (source, 'Parts of this app could not be copied faithfully: ' ||
|
|
2414
|
+
(plan->'unsupported')::text)
|
|
2415
|
+
RETURNING id INTO run_id;
|
|
2416
|
+
RETURN run_id;
|
|
2417
|
+
END IF;
|
|
2418
|
+
|
|
2419
|
+
PERFORM __KN__.write_schema(plan, copy_name);
|
|
2420
|
+
copy_plan := __KN__.read_schema(copy_name);
|
|
2421
|
+
|
|
2422
|
+
-- The stand-ins belong to this tool, not to the customer. Attacking them
|
|
2423
|
+
-- would produce findings about a table that is not in their app.
|
|
2424
|
+
SELECT coalesce(jsonb_agg(e->>'stub'), '[]'::jsonb) INTO stand_ins
|
|
2425
|
+
FROM jsonb_array_elements(coalesce(plan->'external', '[]'::jsonb)) e;
|
|
2426
|
+
SELECT coalesce(jsonb_agg(t), '[]'::jsonb) INTO theirs
|
|
2427
|
+
FROM jsonb_array_elements(copy_plan->'tables') t
|
|
2428
|
+
WHERE NOT (stand_ins ? (t->>'name'));
|
|
2429
|
+
|
|
2430
|
+
sown := __KN__.seed(copy_name, theirs);
|
|
2431
|
+
|
|
2432
|
+
-- Views have no rows of their own and are never seeded, which is exactly
|
|
2433
|
+
-- why they matter: one over a protected table hands out every row in it
|
|
2434
|
+
-- while the policy sits there intact.
|
|
2435
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
2436
|
+
'name', v->>'name',
|
|
2437
|
+
'columns', coalesce(v->'columns', '[]'::jsonb),
|
|
2438
|
+
'constraints', '[]'::jsonb,
|
|
2439
|
+
'rlsEnabled', false,
|
|
2440
|
+
'isView', true)), '[]'::jsonb)
|
|
2441
|
+
INTO views
|
|
2442
|
+
FROM jsonb_array_elements(coalesce(copy_plan->'views', '[]'::jsonb)) v;
|
|
2443
|
+
|
|
2444
|
+
seen := __KN__.impersonate(copy_name, theirs || views);
|
|
2445
|
+
wrote := __KN__.tamper(copy_name, theirs, sown->'seeded', copy_plan->'policies');
|
|
2446
|
+
stranded := __KN__.orphan(copy_name, theirs, sown->'seeded');
|
|
2447
|
+
raced := __KN__.collide(copy_name, theirs, copy_plan->'indexes');
|
|
2448
|
+
|
|
2449
|
+
findings := (seen->'findings') || (wrote->'findings') || (stranded->'findings');
|
|
2450
|
+
|
|
2451
|
+
-- Everything genuinely attacked, so a re-check can tell a finding that was
|
|
2452
|
+
-- fixed from one whose attack simply did not run this time.
|
|
2453
|
+
attempted := coalesce(seen->'completed', '[]'::jsonb)
|
|
2454
|
+
|| coalesce(wrote->'completed', '[]'::jsonb)
|
|
2455
|
+
|| coalesce(stranded->'completed', '[]'::jsonb);
|
|
2456
|
+
|
|
2457
|
+
-- And everything that was not. Said out loud, never left out: a table
|
|
2458
|
+
-- nothing could be put into reads exactly like a table nothing got out of.
|
|
2459
|
+
not_checked := coalesce(sown->'skipped', '[]'::jsonb)
|
|
2460
|
+
|| coalesce(seen->'blocked', '[]'::jsonb)
|
|
2461
|
+
|| coalesce(wrote->'blocked', '[]'::jsonb)
|
|
2462
|
+
|| coalesce(stranded->'notTried', '[]'::jsonb)
|
|
2463
|
+
|| coalesce(raced->'notTried', '[]'::jsonb);
|
|
2464
|
+
|
|
2465
|
+
INSERT INTO __KN__.runs (source, attacks_run, findings, not_checked)
|
|
2466
|
+
VALUES (source, jsonb_array_length(attempted), findings, not_checked)
|
|
2467
|
+
RETURNING id INTO run_id;
|
|
2468
|
+
|
|
2469
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2470
|
+
why := SQLERRM;
|
|
2471
|
+
INSERT INTO __KN__.runs (source, stopped) VALUES (source, why) RETURNING id INTO run_id;
|
|
2472
|
+
END;
|
|
2473
|
+
|
|
2474
|
+
-- Dropped whatever happened above, including when it threw. A copy left in
|
|
2475
|
+
-- the customer's database is the one thing this product promises never to
|
|
2476
|
+
-- do, and a nightly job has nobody watching it.
|
|
2477
|
+
BEGIN
|
|
2478
|
+
EXECUTE 'DROP SCHEMA IF EXISTS ' || quote_ident(copy_name) || ' CASCADE';
|
|
2479
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2480
|
+
NULL;
|
|
2481
|
+
END;
|
|
2482
|
+
|
|
2483
|
+
RETURN run_id;
|
|
2484
|
+
END $$;
|