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/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 $$;