kryptheon-night 0.1.39 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/attack.js +1028 -1025
- package/bin/kryptheon-night.js +736 -725
- package/engine.sql +4657 -4430
- package/finding.js +1715 -1612
- package/intro.js +311 -303
- package/package.json +98 -98
- package/scan.js +1330 -1311
- package/schema.js +1588 -1555
- package/tamper.js +949 -772
package/tamper.js
CHANGED
|
@@ -1,772 +1,949 @@
|
|
|
1
|
-
// Can a stranger change your data?
|
|
2
|
-
//
|
|
3
|
-
// Everything before this asks whether the wrong person can READ. This asks
|
|
4
|
-
// whether they can WRITE, and the answer matters more: a leak is bad, but a
|
|
5
|
-
// stranger who can delete your customers table has taken something you cannot
|
|
6
|
-
// get back.
|
|
7
|
-
//
|
|
8
|
-
// It is the commonest disaster in a no-code backend, and the reason is that
|
|
9
|
-
// nothing looks wrong. Supabase grants anon INSERT, UPDATE and DELETE on the
|
|
10
|
-
// public schema by default. Row level security is what takes those back, and
|
|
11
|
-
// on a table where nobody ever switched it on, a logged-out stranger can empty
|
|
12
|
-
// the table with one request while the dashboard shows nothing at all.
|
|
13
|
-
//
|
|
14
|
-
// EVERYTHING HERE IS ROLLED BACK. Every write runs inside a transaction that
|
|
15
|
-
// is always rolled back, measured afterwards to confirm the table came out as
|
|
16
|
-
// it went in. It runs against the copy, and the copy is thrown away - but the
|
|
17
|
-
// rollback is what makes it safe to reason about at all.
|
|
18
|
-
//
|
|
19
|
-
// WHAT WAS MEASURED FIRST (probe-write.js, against a real database)
|
|
20
|
-
//
|
|
21
|
-
// - no row level security + the default grants: a stranger can insert,
|
|
22
|
-
// rewrite and delete. Confirmed, all three.
|
|
23
|
-
// - a SELECT policy and nothing else: writes are refused. A read rule is
|
|
24
|
-
// genuinely not a write rule, so an app like this must not be reported.
|
|
25
|
-
// - FOR ALL USING (true): a stranger can do all three.
|
|
26
|
-
// - an owner policy: a signed-in customer cannot touch another customer's
|
|
27
|
-
// rows, and Postgres reuses USING as the check for UPDATE, so a row cannot
|
|
28
|
-
// be pushed out of its owner's reach either.
|
|
29
|
-
//
|
|
30
|
-
// A statement that returns without error having changed zero rows is a
|
|
31
|
-
// refusal, not a success - row level security filters rows away rather than
|
|
32
|
-
// complaining. The first version of the probe got that wrong, so every verdict
|
|
33
|
-
// here is taken from the number of rows that actually moved.
|
|
34
|
-
|
|
35
|
-
const { quote } = require('./schema.js');
|
|
36
|
-
const attack = require('./attack.js');
|
|
37
|
-
|
|
38
|
-
/** Who is doing the asking, and what a finding should call them. */
|
|
39
|
-
const ACTORS = [
|
|
40
|
-
{ who: 'anyone', role: 'anon', identity: null },
|
|
41
|
-
{ who: 'any customer', role: 'authenticated', identity: attack.USER_B },
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Runs one write the way a request runs it, and never keeps the result.
|
|
46
|
-
*
|
|
47
|
-
* The rollback is not a tidy-up, it is the safety property. Nothing this
|
|
48
|
-
* attack does survives the statement that did it.
|
|
49
|
-
*/
|
|
50
|
-
async function tryWrite(client, role, identity, statement, values, first) {
|
|
51
|
-
await client.query('BEGIN');
|
|
52
|
-
try {
|
|
53
|
-
// Something the copy's owner does first, inside the same transaction, so
|
|
54
|
-
// it is undone with the write: putting a person into a team for one
|
|
55
|
-
// attempt (see teammate below).
|
|
56
|
-
if (first) await client.query(first.statement, first.values || []);
|
|
57
|
-
await client.query("SET LOCAL statement_timeout = '15s'");
|
|
58
|
-
await client.query('SET LOCAL role TO ' + role);
|
|
59
|
-
await client.query('SELECT set_config($1, $2, true)', [
|
|
60
|
-
'request.jwt.claims',
|
|
61
|
-
JSON.stringify(identity ? { sub: identity, role: role } : { role: role }),
|
|
62
|
-
]);
|
|
63
|
-
const result = await client.query(statement, values || []);
|
|
64
|
-
return { ok: true, count: result.rowCount };
|
|
65
|
-
} catch (err) {
|
|
66
|
-
return { ok: false, count: 0, why: err.message };
|
|
67
|
-
} finally {
|
|
68
|
-
await client.query('ROLLBACK').catch(() => {});
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* What a write actually achieved.
|
|
74
|
-
*
|
|
75
|
-
* Three outcomes, and the middle one is the one that matters: a statement can
|
|
76
|
-
* succeed and change nothing, which is row level security doing its job.
|
|
77
|
-
*/
|
|
78
|
-
function whatHappened(result) {
|
|
79
|
-
if (result.ok) return result.count > 0 ? 'got through' : 'refused';
|
|
80
|
-
// A WITH CHECK turning a write away IS the app defending itself, and it is
|
|
81
|
-
// the single most common way a correct app says no. Reading it as "I could
|
|
82
|
-
// not tell" put a warning on every table that had got it right, and a
|
|
83
|
-
// warning nobody earned is how a report stops being read.
|
|
84
|
-
if (/violates row-level security policy/i.test(String(result.why))) return 'refused';
|
|
85
|
-
return attack.refusalMeans(result.why) === 'unreachable' ? 'refused' : 'untested';
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Every way in, per table, per kind of caller.
|
|
90
|
-
*
|
|
91
|
-
* `seeded` carries the shape of row that worked when the table was seeded, so
|
|
92
|
-
* the insert here is not rejected by some CHECK the seeder already solved.
|
|
93
|
-
*/
|
|
94
|
-
async function tamper(client, schema, tables, seeded, policies) {
|
|
95
|
-
const findings = [];
|
|
96
|
-
const completed = [];
|
|
97
|
-
const blocked = [];
|
|
98
|
-
const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
|
|
99
|
-
|
|
100
|
-
// Which rules each table actually has, so the report can say why a write got
|
|
101
|
-
// through instead of assuming. Without this the report told somebody their
|
|
102
|
-
// rule "covers every command rather than only reading" about a policy
|
|
103
|
-
// written FOR INSERT - the finding was true and the reason was invented,
|
|
104
|
-
// which is the one thing a report cannot do twice.
|
|
105
|
-
//
|
|
106
|
-
// Permissive only. A restrictive policy can narrow what is allowed and
|
|
107
|
-
// never open it, so it is never the reason a write succeeded.
|
|
108
|
-
const rulesFor = new Map();
|
|
109
|
-
for (const policy of policies || []) {
|
|
110
|
-
if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
|
|
111
|
-
const list = rulesFor.get(policy.table_name) || [];
|
|
112
|
-
const cmd = String(policy.cmd || 'ALL').toUpperCase();
|
|
113
|
-
if (!list.includes(cmd)) list.push(cmd);
|
|
114
|
-
rulesFor.set(policy.table_name, list);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// The first person's row in tables that hold one for each person but name
|
|
118
|
-
// nobody (attack.seed), so a write is aimed at a row that is not the
|
|
119
|
-
// caller's own.
|
|
120
|
-
const firstRowOf = new Map((seeded || []).filter((entry) => entry.rowOfA).map((entry) => [entry.table, entry.rowOfA]));
|
|
121
|
-
|
|
122
|
-
for (const table of tables) {
|
|
123
|
-
if (!shapeFor.has(table.name)) continue; // never seeded, so nothing to protect
|
|
124
|
-
const owner = attack.ownerColumn(table);
|
|
125
|
-
|
|
126
|
-
for (const actor of ACTORS) {
|
|
127
|
-
const key = 'writable:' + table.name + ':' + actor.who;
|
|
128
|
-
const can = [];
|
|
129
|
-
const changed = {};
|
|
130
|
-
let stuck = null;
|
|
131
|
-
|
|
132
|
-
// Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
133
|
-
// customer deleting their OWN rows is not a finding, it is the feature,
|
|
134
|
-
// and an unscoped DELETE would report every correctly built app.
|
|
135
|
-
const firstRow = firstRowOf.get(table.name);
|
|
136
|
-
const theirRows = owner
|
|
137
|
-
? ' WHERE ' + quote(owner) + ' = ' + "'" + attack.USER_A + "'"
|
|
138
|
-
: firstRow
|
|
139
|
-
? ' WHERE ' + quote(firstRow.column) + "::text = '" + firstRow.value.split("'").join("''") + "'"
|
|
140
|
-
: '';
|
|
141
|
-
const at = quote(schema) + '.' + quote(table.name);
|
|
142
|
-
|
|
143
|
-
// Written under a name nobody has used, which is both what makes the
|
|
144
|
-
// row land at all and what makes it the right test: adding a row of
|
|
145
|
-
// your own is the feature, adding one under somebody else name is not.
|
|
146
|
-
//
|
|
147
|
-
// Unless the owner is a key into one of the app's own tables - provider_id
|
|
148
|
-
// -> profiles.id - where only the two seeded people exist. Found on a
|
|
149
|
-
// blind test: the nameless insert hit the foreign key, and "any provider
|
|
150
|
-
// can add appointments for any other" came back as not tested. The other
|
|
151
|
-
// seeded person is still somebody else's name to the signed-in caller.
|
|
152
|
-
const row = await attack.rowFor(client, schema, table, writeAsFor(table, owner, tables), 7, null, shapeFor.get(table.name));
|
|
153
|
-
const placeholders = row.values.map((_, i) => '$' + (i + 1));
|
|
154
|
-
const attempts = [
|
|
155
|
-
row.columns.length
|
|
156
|
-
? {
|
|
157
|
-
what: 'add',
|
|
158
|
-
statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
159
|
-
placeholders.join(', ') + ')',
|
|
160
|
-
values: row.values,
|
|
161
|
-
}
|
|
162
|
-
: { what: 'add', statement: 'INSERT INTO ' + at + ' DEFAULT VALUES', values: [] },
|
|
163
|
-
{ what: 'change', statement: 'UPDATE ' + at + ' SET ' + quote(firstWritable(table)) + ' = ' +
|
|
164
|
-
quote(firstWritable(table)) + theirRows, values: [] },
|
|
165
|
-
{ what: 'delete', statement: 'DELETE FROM ' + at + theirRows, values: [] },
|
|
166
|
-
];
|
|
167
|
-
|
|
168
|
-
for (const move of attempts) {
|
|
169
|
-
if (!move.statement) continue;
|
|
170
|
-
const result = await tryWrite(client, actor.role, actor.identity, move.statement, move.values);
|
|
171
|
-
const outcome = whatHappened(result);
|
|
172
|
-
if (outcome === 'got through') {
|
|
173
|
-
can.push(move.what);
|
|
174
|
-
changed[move.what] = result.count;
|
|
175
|
-
} else if (outcome === 'untested') {
|
|
176
|
-
stuck = result.why;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (stuck) {
|
|
181
|
-
// Something went wrong that was not the app defending itself, so no
|
|
182
|
-
// verdict exists for this table and saying nothing would read as safe.
|
|
183
|
-
blocked.push({ table: table.name, key: key, why: 'as ' + actor.who + ': ' + stuck });
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
completed.push(key);
|
|
188
|
-
if (can.length) {
|
|
189
|
-
findings.push({
|
|
190
|
-
kind: 'writable',
|
|
191
|
-
table: table.name,
|
|
192
|
-
who: actor.who,
|
|
193
|
-
can: can,
|
|
194
|
-
changed: changed,
|
|
195
|
-
owner: owner,
|
|
196
|
-
columns: (table.columns || []).map((c) => c.name),
|
|
197
|
-
rlsEnabled: table.rlsEnabled,
|
|
198
|
-
rules: rulesFor.get(table.name) || [],
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*/
|
|
226
|
-
function
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1
|
+
// Can a stranger change your data?
|
|
2
|
+
//
|
|
3
|
+
// Everything before this asks whether the wrong person can READ. This asks
|
|
4
|
+
// whether they can WRITE, and the answer matters more: a leak is bad, but a
|
|
5
|
+
// stranger who can delete your customers table has taken something you cannot
|
|
6
|
+
// get back.
|
|
7
|
+
//
|
|
8
|
+
// It is the commonest disaster in a no-code backend, and the reason is that
|
|
9
|
+
// nothing looks wrong. Supabase grants anon INSERT, UPDATE and DELETE on the
|
|
10
|
+
// public schema by default. Row level security is what takes those back, and
|
|
11
|
+
// on a table where nobody ever switched it on, a logged-out stranger can empty
|
|
12
|
+
// the table with one request while the dashboard shows nothing at all.
|
|
13
|
+
//
|
|
14
|
+
// EVERYTHING HERE IS ROLLED BACK. Every write runs inside a transaction that
|
|
15
|
+
// is always rolled back, measured afterwards to confirm the table came out as
|
|
16
|
+
// it went in. It runs against the copy, and the copy is thrown away - but the
|
|
17
|
+
// rollback is what makes it safe to reason about at all.
|
|
18
|
+
//
|
|
19
|
+
// WHAT WAS MEASURED FIRST (probe-write.js, against a real database)
|
|
20
|
+
//
|
|
21
|
+
// - no row level security + the default grants: a stranger can insert,
|
|
22
|
+
// rewrite and delete. Confirmed, all three.
|
|
23
|
+
// - a SELECT policy and nothing else: writes are refused. A read rule is
|
|
24
|
+
// genuinely not a write rule, so an app like this must not be reported.
|
|
25
|
+
// - FOR ALL USING (true): a stranger can do all three.
|
|
26
|
+
// - an owner policy: a signed-in customer cannot touch another customer's
|
|
27
|
+
// rows, and Postgres reuses USING as the check for UPDATE, so a row cannot
|
|
28
|
+
// be pushed out of its owner's reach either.
|
|
29
|
+
//
|
|
30
|
+
// A statement that returns without error having changed zero rows is a
|
|
31
|
+
// refusal, not a success - row level security filters rows away rather than
|
|
32
|
+
// complaining. The first version of the probe got that wrong, so every verdict
|
|
33
|
+
// here is taken from the number of rows that actually moved.
|
|
34
|
+
|
|
35
|
+
const { quote } = require('./schema.js');
|
|
36
|
+
const attack = require('./attack.js');
|
|
37
|
+
|
|
38
|
+
/** Who is doing the asking, and what a finding should call them. */
|
|
39
|
+
const ACTORS = [
|
|
40
|
+
{ who: 'anyone', role: 'anon', identity: null },
|
|
41
|
+
{ who: 'any customer', role: 'authenticated', identity: attack.USER_B },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs one write the way a request runs it, and never keeps the result.
|
|
46
|
+
*
|
|
47
|
+
* The rollback is not a tidy-up, it is the safety property. Nothing this
|
|
48
|
+
* attack does survives the statement that did it.
|
|
49
|
+
*/
|
|
50
|
+
async function tryWrite(client, role, identity, statement, values, first) {
|
|
51
|
+
await client.query('BEGIN');
|
|
52
|
+
try {
|
|
53
|
+
// Something the copy's owner does first, inside the same transaction, so
|
|
54
|
+
// it is undone with the write: putting a person into a team for one
|
|
55
|
+
// attempt (see teammate below).
|
|
56
|
+
if (first) await client.query(first.statement, first.values || []);
|
|
57
|
+
await client.query("SET LOCAL statement_timeout = '15s'");
|
|
58
|
+
await client.query('SET LOCAL role TO ' + role);
|
|
59
|
+
await client.query('SELECT set_config($1, $2, true)', [
|
|
60
|
+
'request.jwt.claims',
|
|
61
|
+
JSON.stringify(identity ? { sub: identity, role: role } : { role: role }),
|
|
62
|
+
]);
|
|
63
|
+
const result = await client.query(statement, values || []);
|
|
64
|
+
return { ok: true, count: result.rowCount };
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { ok: false, count: 0, why: err.message };
|
|
67
|
+
} finally {
|
|
68
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What a write actually achieved.
|
|
74
|
+
*
|
|
75
|
+
* Three outcomes, and the middle one is the one that matters: a statement can
|
|
76
|
+
* succeed and change nothing, which is row level security doing its job.
|
|
77
|
+
*/
|
|
78
|
+
function whatHappened(result) {
|
|
79
|
+
if (result.ok) return result.count > 0 ? 'got through' : 'refused';
|
|
80
|
+
// A WITH CHECK turning a write away IS the app defending itself, and it is
|
|
81
|
+
// the single most common way a correct app says no. Reading it as "I could
|
|
82
|
+
// not tell" put a warning on every table that had got it right, and a
|
|
83
|
+
// warning nobody earned is how a report stops being read.
|
|
84
|
+
if (/violates row-level security policy/i.test(String(result.why))) return 'refused';
|
|
85
|
+
return attack.refusalMeans(result.why) === 'unreachable' ? 'refused' : 'untested';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every way in, per table, per kind of caller.
|
|
90
|
+
*
|
|
91
|
+
* `seeded` carries the shape of row that worked when the table was seeded, so
|
|
92
|
+
* the insert here is not rejected by some CHECK the seeder already solved.
|
|
93
|
+
*/
|
|
94
|
+
async function tamper(client, schema, tables, seeded, policies) {
|
|
95
|
+
const findings = [];
|
|
96
|
+
const completed = [];
|
|
97
|
+
const blocked = [];
|
|
98
|
+
const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
|
|
99
|
+
|
|
100
|
+
// Which rules each table actually has, so the report can say why a write got
|
|
101
|
+
// through instead of assuming. Without this the report told somebody their
|
|
102
|
+
// rule "covers every command rather than only reading" about a policy
|
|
103
|
+
// written FOR INSERT - the finding was true and the reason was invented,
|
|
104
|
+
// which is the one thing a report cannot do twice.
|
|
105
|
+
//
|
|
106
|
+
// Permissive only. A restrictive policy can narrow what is allowed and
|
|
107
|
+
// never open it, so it is never the reason a write succeeded.
|
|
108
|
+
const rulesFor = new Map();
|
|
109
|
+
for (const policy of policies || []) {
|
|
110
|
+
if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
|
|
111
|
+
const list = rulesFor.get(policy.table_name) || [];
|
|
112
|
+
const cmd = String(policy.cmd || 'ALL').toUpperCase();
|
|
113
|
+
if (!list.includes(cmd)) list.push(cmd);
|
|
114
|
+
rulesFor.set(policy.table_name, list);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The first person's row in tables that hold one for each person but name
|
|
118
|
+
// nobody (attack.seed), so a write is aimed at a row that is not the
|
|
119
|
+
// caller's own.
|
|
120
|
+
const firstRowOf = new Map((seeded || []).filter((entry) => entry.rowOfA).map((entry) => [entry.table, entry.rowOfA]));
|
|
121
|
+
|
|
122
|
+
for (const table of tables) {
|
|
123
|
+
if (!shapeFor.has(table.name)) continue; // never seeded, so nothing to protect
|
|
124
|
+
const owner = attack.ownerColumn(table);
|
|
125
|
+
|
|
126
|
+
for (const actor of ACTORS) {
|
|
127
|
+
const key = 'writable:' + table.name + ':' + actor.who;
|
|
128
|
+
const can = [];
|
|
129
|
+
const changed = {};
|
|
130
|
+
let stuck = null;
|
|
131
|
+
|
|
132
|
+
// Rows belonging to the other fake person. Scoped on purpose: a signed-in
|
|
133
|
+
// customer deleting their OWN rows is not a finding, it is the feature,
|
|
134
|
+
// and an unscoped DELETE would report every correctly built app.
|
|
135
|
+
const firstRow = firstRowOf.get(table.name);
|
|
136
|
+
const theirRows = owner
|
|
137
|
+
? ' WHERE ' + quote(owner) + ' = ' + "'" + attack.USER_A + "'"
|
|
138
|
+
: firstRow
|
|
139
|
+
? ' WHERE ' + quote(firstRow.column) + "::text = '" + firstRow.value.split("'").join("''") + "'"
|
|
140
|
+
: '';
|
|
141
|
+
const at = quote(schema) + '.' + quote(table.name);
|
|
142
|
+
|
|
143
|
+
// Written under a name nobody has used, which is both what makes the
|
|
144
|
+
// row land at all and what makes it the right test: adding a row of
|
|
145
|
+
// your own is the feature, adding one under somebody else name is not.
|
|
146
|
+
//
|
|
147
|
+
// Unless the owner is a key into one of the app's own tables - provider_id
|
|
148
|
+
// -> profiles.id - where only the two seeded people exist. Found on a
|
|
149
|
+
// blind test: the nameless insert hit the foreign key, and "any provider
|
|
150
|
+
// can add appointments for any other" came back as not tested. The other
|
|
151
|
+
// seeded person is still somebody else's name to the signed-in caller.
|
|
152
|
+
const row = await attack.rowFor(client, schema, table, writeAsFor(table, owner, tables), 7, null, shapeFor.get(table.name));
|
|
153
|
+
const placeholders = row.values.map((_, i) => '$' + (i + 1));
|
|
154
|
+
const attempts = [
|
|
155
|
+
row.columns.length
|
|
156
|
+
? {
|
|
157
|
+
what: 'add',
|
|
158
|
+
statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
159
|
+
placeholders.join(', ') + ')',
|
|
160
|
+
values: row.values,
|
|
161
|
+
}
|
|
162
|
+
: { what: 'add', statement: 'INSERT INTO ' + at + ' DEFAULT VALUES', values: [] },
|
|
163
|
+
{ what: 'change', statement: 'UPDATE ' + at + ' SET ' + quote(firstWritable(table)) + ' = ' +
|
|
164
|
+
quote(firstWritable(table)) + theirRows, values: [] },
|
|
165
|
+
{ what: 'delete', statement: 'DELETE FROM ' + at + theirRows, values: [] },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
for (const move of attempts) {
|
|
169
|
+
if (!move.statement) continue;
|
|
170
|
+
const result = await tryWrite(client, actor.role, actor.identity, move.statement, move.values);
|
|
171
|
+
const outcome = whatHappened(result);
|
|
172
|
+
if (outcome === 'got through') {
|
|
173
|
+
can.push(move.what);
|
|
174
|
+
changed[move.what] = result.count;
|
|
175
|
+
} else if (outcome === 'untested') {
|
|
176
|
+
stuck = result.why;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (stuck) {
|
|
181
|
+
// Something went wrong that was not the app defending itself, so no
|
|
182
|
+
// verdict exists for this table and saying nothing would read as safe.
|
|
183
|
+
blocked.push({ table: table.name, key: key, why: 'as ' + actor.who + ': ' + stuck });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
completed.push(key);
|
|
188
|
+
if (can.length) {
|
|
189
|
+
findings.push({
|
|
190
|
+
kind: 'writable',
|
|
191
|
+
table: table.name,
|
|
192
|
+
who: actor.who,
|
|
193
|
+
can: can,
|
|
194
|
+
changed: changed,
|
|
195
|
+
owner: owner,
|
|
196
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
197
|
+
rlsEnabled: table.rlsEnabled,
|
|
198
|
+
rules: rulesFor.get(table.name) || [],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const through = await viewWrites(client, schema, tables, shapeFor, firstRowOf);
|
|
205
|
+
return {
|
|
206
|
+
findings: findings.concat(through.findings),
|
|
207
|
+
completed: completed.concat(through.completed),
|
|
208
|
+
blocked: blocked.concat(through.blocked),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Writes through a view (blind round 17, DB-09). A view runs with its
|
|
214
|
+
* creator's rights unless it says otherwise, and a plain one - one table,
|
|
215
|
+
* nothing grouped - can be written through. So a stranger can change and
|
|
216
|
+
* delete rows of a table whose rules are right, past those rules, through the
|
|
217
|
+
* view the app reads from - and only reads were ever tried on views.
|
|
218
|
+
*
|
|
219
|
+
* Every view of the copy that Postgres can write through, over one table that
|
|
220
|
+
* was seeded: the same add, change and delete the table gets, each run on the
|
|
221
|
+
* view and on its table. Only what the view let through beyond what the
|
|
222
|
+
* table did counts - a view that runs as the caller (security_invoker), or
|
|
223
|
+
* one nobody was granted writes on, says nothing. Mirrors view_writes in
|
|
224
|
+
* engine.sql.
|
|
225
|
+
*/
|
|
226
|
+
async function viewWrites(client, schema, tables, shapeFor, firstRowOf) {
|
|
227
|
+
const findings = [];
|
|
228
|
+
const completed = [];
|
|
229
|
+
const blocked = [];
|
|
230
|
+
// pg_relation_is_updatable: 4 update, 8 insert, 16 delete, without
|
|
231
|
+
// INSTEAD OF triggers - a trigger writes what it decides, not the row.
|
|
232
|
+
const { rows } = await client.query(
|
|
233
|
+
`SELECT c.relname AS name,
|
|
234
|
+
pg_relation_is_updatable(c.oid::regclass, false) AS can,
|
|
235
|
+
(SELECT array_agg(DISTINCT b.relname::text ORDER BY b.relname::text)
|
|
236
|
+
FROM pg_rewrite r
|
|
237
|
+
JOIN pg_depend d ON d.classid = 'pg_rewrite'::regclass AND d.objid = r.oid
|
|
238
|
+
AND d.refclassid = 'pg_class'::regclass AND d.refobjid <> c.oid
|
|
239
|
+
JOIN pg_class b ON b.oid = d.refobjid AND b.relkind IN ('r', 'p')
|
|
240
|
+
WHERE r.ev_class = c.oid) AS bases,
|
|
241
|
+
(SELECT array_agg(a.attname::text ORDER BY a.attnum) FROM pg_attribute a
|
|
242
|
+
WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns
|
|
243
|
+
FROM pg_class c
|
|
244
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
245
|
+
WHERE n.nspname = $1 AND c.relkind = 'v'
|
|
246
|
+
ORDER BY c.relname`,
|
|
247
|
+
[schema],
|
|
248
|
+
);
|
|
249
|
+
for (const view of rows) {
|
|
250
|
+
if (!(view.can & 28) || !view.bases || view.bases.length !== 1) continue;
|
|
251
|
+
const base = (tables || []).find((t) => t.name === view.bases[0]);
|
|
252
|
+
if (!base) continue;
|
|
253
|
+
if (!shapeFor.has(base.name)) {
|
|
254
|
+
blocked.push({ table: view.name, key: 'writable:' + view.name, why: 'the ' + base.name + ' table under it has no rows in ' +
|
|
255
|
+
'the copy, so writing through the view was not tried' });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const inView = new Set(view.columns || []);
|
|
259
|
+
const owner = attack.ownerColumn(base);
|
|
260
|
+
const firstRow = firstRowOf.get(base.name);
|
|
261
|
+
const theirRows = owner && inView.has(owner)
|
|
262
|
+
? ' WHERE ' + quote(owner) + " = '" + attack.USER_A + "'"
|
|
263
|
+
: firstRow && inView.has(firstRow.column)
|
|
264
|
+
? ' WHERE ' + quote(firstRow.column) + "::text = '" + firstRow.value.split("'").join("''") + "'"
|
|
265
|
+
: '';
|
|
266
|
+
const writable = firstWritable(base);
|
|
267
|
+
const column = inView.has(writable) ? writable
|
|
268
|
+
: ((base.columns || []).find((c) => inView.has(c.name) && !c.generated && !c.identity) || {}).name;
|
|
269
|
+
const moves = [];
|
|
270
|
+
// Added only where every column the row needs is in the view: a view
|
|
271
|
+
// without them cannot take a row from anybody.
|
|
272
|
+
const row = await attack.rowFor(client, schema, base, writeAsFor(base, owner, tables), 7, null, shapeFor.get(base.name));
|
|
273
|
+
if ((view.can & 8) && row.columns.every((name) => inView.has(name))) {
|
|
274
|
+
const columns = row.columns.map(quote).join(', ');
|
|
275
|
+
const values = row.values.map((_, i) => '$' + (i + 1)).join(', ');
|
|
276
|
+
moves.push({ what: 'add', values: row.values, statement: (at) => row.columns.length
|
|
277
|
+
? 'INSERT INTO ' + at + ' (' + columns + ') VALUES (' + values + ')' : 'INSERT INTO ' + at + ' DEFAULT VALUES' });
|
|
278
|
+
}
|
|
279
|
+
if ((view.can & 4) && column) {
|
|
280
|
+
moves.push({ what: 'change', values: [], statement: (at) => 'UPDATE ' + at + ' SET ' + quote(column) + ' = ' + quote(column) + theirRows });
|
|
281
|
+
}
|
|
282
|
+
if (view.can & 16) moves.push({ what: 'delete', values: [], statement: (at) => 'DELETE FROM ' + at + theirRows });
|
|
283
|
+
if (!moves.length) continue;
|
|
284
|
+
const viewAt = quote(schema) + '.' + quote(view.name);
|
|
285
|
+
const baseAt = quote(schema) + '.' + quote(base.name);
|
|
286
|
+
for (const actor of ACTORS) {
|
|
287
|
+
const key = 'writable:' + view.name + ':' + actor.who;
|
|
288
|
+
const can = [];
|
|
289
|
+
const changed = {};
|
|
290
|
+
let stuck = null;
|
|
291
|
+
for (const move of moves) {
|
|
292
|
+
const onTable = await tryWrite(client, actor.role, actor.identity, move.statement(baseAt), move.values);
|
|
293
|
+
const onView = await tryWrite(client, actor.role, actor.identity, move.statement(viewAt), move.values);
|
|
294
|
+
const tableOutcome = whatHappened(onTable);
|
|
295
|
+
const viewOutcome = whatHappened(onView);
|
|
296
|
+
if (viewOutcome === 'untested') stuck = onView.why;
|
|
297
|
+
else if (tableOutcome === 'untested') stuck = onTable.why;
|
|
298
|
+
else if (viewOutcome === 'got through' && onView.count > (tableOutcome === 'got through' ? onTable.count : 0)) {
|
|
299
|
+
can.push(move.what);
|
|
300
|
+
changed[move.what] = onView.count - (tableOutcome === 'got through' ? onTable.count : 0);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (stuck) {
|
|
304
|
+
blocked.push({ table: view.name, key: key, why: 'as ' + actor.who + ', through the view: ' + stuck });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
completed.push(key);
|
|
308
|
+
if (can.length) {
|
|
309
|
+
findings.push({
|
|
310
|
+
kind: 'writable',
|
|
311
|
+
table: view.name,
|
|
312
|
+
isView: true,
|
|
313
|
+
base: base.name,
|
|
314
|
+
who: actor.who,
|
|
315
|
+
can: can,
|
|
316
|
+
changed: changed,
|
|
317
|
+
owner: owner && inView.has(owner) ? owner : null,
|
|
318
|
+
columns: view.columns || [],
|
|
319
|
+
rlsEnabled: base.rlsEnabled,
|
|
320
|
+
rules: [],
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return { findings: findings, completed: completed, blocked: blocked };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Whose name an insert is written under: nobody's yet (USER_C), unless the
|
|
330
|
+
* owner column is a key into one of the app's own tables, where only the two
|
|
331
|
+
* seeded people exist - then the one who is not the signed-in caller.
|
|
332
|
+
*/
|
|
333
|
+
function writeAsFor(table, owner, tables) {
|
|
334
|
+
if (!owner) return attack.USER_C;
|
|
335
|
+
const key = attack.foreignKeys(table).find((k) => k.columns.length === 1 && k.columns[0] === owner);
|
|
336
|
+
const inApp = key && (tables || []).some((t) => t.name === key.refTable);
|
|
337
|
+
return inApp ? attack.USER_A : attack.USER_C;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A column an UPDATE can harmlessly set to itself.
|
|
342
|
+
*
|
|
343
|
+
* Setting a column to its own value changes nothing about the row while still
|
|
344
|
+
* proving the write was allowed - so the finding is real and the data is not
|
|
345
|
+
* even momentarily wrong inside the transaction that gets rolled back.
|
|
346
|
+
*/
|
|
347
|
+
function firstWritable(table) {
|
|
348
|
+
const usable = (table.columns || []).filter((column) => !column.generated && !column.identity);
|
|
349
|
+
const plain = usable.find((column) => !/^(id)$/i.test(column.name));
|
|
350
|
+
return (plain || usable[0] || { name: 'id' }).name;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/*
|
|
354
|
+
* What the least trusted member of a team can change.
|
|
355
|
+
*
|
|
356
|
+
* Everything above is one customer against another. Found on two blind tests
|
|
357
|
+
* (OrbitDesk, AtlasPay): a rule written "members of this workspace may change
|
|
358
|
+
* it" lets a viewer change it too, and an UPDATE rule that checks the role is
|
|
359
|
+
* undone by a second one beside it that does not - Postgres lets a write
|
|
360
|
+
* through if any permissive rule allows it. Five planted holes of this shape
|
|
361
|
+
* went unreported, because both fake people were the owner of their own team
|
|
362
|
+
* and nobody else was ever in it.
|
|
363
|
+
*
|
|
364
|
+
* So one of them joins the other's team with the lowest role there is, for
|
|
365
|
+
* one attempt at a time, and tries to change what is in it. Whether a viewer
|
|
366
|
+
* may edit is the owner's decision, so this is reported as something to
|
|
367
|
+
* check, never as a confirmed break.
|
|
368
|
+
*/
|
|
369
|
+
|
|
370
|
+
// Labels that say "may only look", tried in this order before falling back
|
|
371
|
+
// to the last label that is not plainly in charge.
|
|
372
|
+
const LEAST_TRUSTED = [/view/i, /read/i, /guest/i, /observ/i, /audit/i];
|
|
373
|
+
const IN_CHARGE = /owner|admin|super|root/i;
|
|
374
|
+
// A role above the others when no label says owner or admin: manager, lead,
|
|
375
|
+
// maintainer, moderator, editor. Found on blind round 17 (DB-02): a team
|
|
376
|
+
// whose roles were manager, member and viewer had no role "at the top", and
|
|
377
|
+
// raising one's own role was never tried - and nothing said so.
|
|
378
|
+
const STEP_UP = /manag|lead|maintain|moderat|editor/i;
|
|
379
|
+
// A column that holds a member's role, by its name. access_level and
|
|
380
|
+
// permission are roles by another name (blind round 17, DB-02).
|
|
381
|
+
const ROLE_COLUMN = /role|access_?level|permission|member(ship)?_?type/i;
|
|
382
|
+
// A table named for who is in a team.
|
|
383
|
+
const TEAM_TABLE = /member|team|staff|collaborat|participant|seat/i;
|
|
384
|
+
// Owner columns that name who made a row rather than who is in it.
|
|
385
|
+
const CREATOR = /^(created_by|author_id|owner|owner_id)$/i;
|
|
386
|
+
// Columns that hold something a member should not simply be able to read:
|
|
387
|
+
// a token, a secret or a key, a password, or the payload a webhook carried.
|
|
388
|
+
// Found on a blind test (HelixOps): integration_tokens.token_value and
|
|
389
|
+
// webhook_deliveries.payload readable by every member, and api_clients
|
|
390
|
+
// (client_key, secret_hash) by a dispatcher. A word on its own between
|
|
391
|
+
// underscores, so tokens - a count - and question_key are not taken.
|
|
392
|
+
// AtlasPay's webhook deliveries kept theirs as request_body and response_body.
|
|
393
|
+
const SECRET_COLUMN = /(^|_)(token|secret|password|passwd|pwd|payload|credentials?|(api|private|access|client|signing|service|encryption|secret|license)_?key|(request|response)_body)(_|$)/i;
|
|
394
|
+
|
|
395
|
+
/** The columns of a table that look like they hold a secret. */
|
|
396
|
+
function secretColumns(table) {
|
|
397
|
+
return (table.columns || []).map((c) => c.name).filter((name) => SECRET_COLUMN.test(name));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Whether a role's own name says it may only look. */
|
|
401
|
+
function lookOnly(role) {
|
|
402
|
+
return LEAST_TRUSTED.some((pattern) => pattern.test(role));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The role a viewer has, out of a team's list of roles, or null. */
|
|
406
|
+
function lowestRole(labels) {
|
|
407
|
+
for (const pattern of LEAST_TRUSTED) {
|
|
408
|
+
const found = labels.find((label) => pattern.test(label));
|
|
409
|
+
if (found) return found;
|
|
410
|
+
}
|
|
411
|
+
const plain = labels.filter((label) => !IN_CHARGE.test(label));
|
|
412
|
+
return plain.length ? plain[plain.length - 1] : null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The column holding a member's role, and its lowest value: named for a role,
|
|
417
|
+
* with a fixed list of values (an enum, or a CHECK listing them).
|
|
418
|
+
*/
|
|
419
|
+
function roleColumnOf(table) {
|
|
420
|
+
for (const column of table.columns || []) {
|
|
421
|
+
if (!ROLE_COLUMN.test(column.name)) continue;
|
|
422
|
+
const labels = Array.isArray(column.enum_labels) && column.enum_labels.length
|
|
423
|
+
? column.enum_labels
|
|
424
|
+
: attack.allowedByCheck(table, column.name);
|
|
425
|
+
if (labels.length < 2) continue;
|
|
426
|
+
const lowest = lowestRole(labels);
|
|
427
|
+
// Every role not plainly in charge, the lowest first and then upwards:
|
|
428
|
+
// the order reads are tried in, so the least trusted one that can read a
|
|
429
|
+
// secret is the one named.
|
|
430
|
+
const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
|
|
431
|
+
// The role at the top: an owner if there is one, else the first in charge,
|
|
432
|
+
// else the first that names a step up (manager, lead ...).
|
|
433
|
+
const top = labels.find((label) => /owner/i.test(label)) || labels.find((label) => IN_CHARGE.test(label)) ||
|
|
434
|
+
labels.find((label) => label !== lowest && STEP_UP.test(label)) || null;
|
|
435
|
+
const inCharge = labels.filter((label) => IN_CHARGE.test(label));
|
|
436
|
+
if (lowest) return { column: column.name, type: column.type, lowest: lowest, ladder: ladder, top: top, inCharge: inCharge };
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Whether a column is a key on its own: one row per person, at most. */
|
|
442
|
+
function aloneUnique(table, column) {
|
|
443
|
+
return (table.constraints || []).some((constraint) => {
|
|
444
|
+
if (constraint.kind !== 'p' && constraint.kind !== 'u') return false;
|
|
445
|
+
const match = /(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)/i.exec(String(constraint.definition || ''));
|
|
446
|
+
if (!match) return false;
|
|
447
|
+
const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
|
|
448
|
+
return columns.length === 1 && columns[0] === column;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The tables saying who is in which team, and as what: a person, a role, and
|
|
454
|
+
* a key to a table of the app's own that is not the person. A table where the
|
|
455
|
+
* person can appear only once (profiles.org_id) is not one: nobody joins a
|
|
456
|
+
* second team there, so there is no one to add.
|
|
457
|
+
*/
|
|
458
|
+
function memberships(tables) {
|
|
459
|
+
const found = [];
|
|
460
|
+
const names = new Set((tables || []).map((table) => table.name));
|
|
461
|
+
for (const table of tables || []) {
|
|
462
|
+
const person = attack.ownerColumn(table);
|
|
463
|
+
// Whoever made the row is not who is in the team. Found on a blind test:
|
|
464
|
+
// workspace_invites (created_by, role, workspace_id) was taken for the
|
|
465
|
+
// members table, and an invitation made nobody a member of anything.
|
|
466
|
+
if (!person || CREATOR.test(person)) continue;
|
|
467
|
+
const role = roleColumnOf(table);
|
|
468
|
+
if (!role) continue;
|
|
469
|
+
const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
470
|
+
.filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
|
|
471
|
+
if (!keys.length || aloneUnique(table, person)) continue;
|
|
472
|
+
found.push({ table: table, person: person, role: role.column, roleType: role.type, lowest: role.lowest,
|
|
473
|
+
ladder: role.ladder, top: role.top, inCharge: role.inCharge || [], keys: keys });
|
|
474
|
+
}
|
|
475
|
+
// A table named for members first: it is the one a team rule asks.
|
|
476
|
+
const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
|
|
477
|
+
return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Tables that say who is in a team but that the team attacks cannot use, and
|
|
482
|
+
* why - said as not tested, never skipped in silence (blind round 17, DB-02):
|
|
483
|
+
* a person who can be in one team only (nobody joins a second), and a role
|
|
484
|
+
* column with no fixed list of roles (nobody can be put in at the lowest).
|
|
485
|
+
* Only on a table named for a team's members: a messages table's role (user,
|
|
486
|
+
* assistant) is not a team, and profiles.org_id is a person's one place, not
|
|
487
|
+
* a team they were added to (roles.check.js).
|
|
488
|
+
*/
|
|
489
|
+
function teamGaps(tables) {
|
|
490
|
+
const gaps = [];
|
|
491
|
+
const names = new Set((tables || []).map((table) => table.name));
|
|
492
|
+
const used = new Set(memberships(tables).map((m) => m.table.name));
|
|
493
|
+
for (const table of tables || []) {
|
|
494
|
+
if (used.has(table.name)) continue;
|
|
495
|
+
const person = attack.ownerColumn(table);
|
|
496
|
+
if (!person || CREATOR.test(person)) continue;
|
|
497
|
+
const column = (table.columns || []).find((c) => ROLE_COLUMN.test(c.name));
|
|
498
|
+
if (!column) continue;
|
|
499
|
+
const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
500
|
+
.filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
|
|
501
|
+
if (!keys.length || !TEAM_TABLE.test(table.name)) continue;
|
|
502
|
+
const parent = keys[0].refTable;
|
|
503
|
+
if (!roleColumnOf(table)) {
|
|
504
|
+
gaps.push({ table: table.name, key: 'role:' + table.name, why: 'its ' + column.name + ' column has no fixed list of roles ' +
|
|
505
|
+
'(an enum, or a CHECK listing them), so nobody could be put in a team as its lowest role - what a viewer can change, ' +
|
|
506
|
+
'and whether a member can raise their own role, were not tried' });
|
|
507
|
+
} else if (aloneUnique(table, person)) {
|
|
508
|
+
gaps.push({ table: table.name, key: 'role:' + table.name, why: 'a person can be in only one team here (' +
|
|
509
|
+
person + ' is unique), so the other person could not join a second one in ' + parent + ' - what a viewer can change, and whether a ' +
|
|
510
|
+
'member can raise their own ' + column.name + ', were not tried' });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return gaps;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// What each kind of write is called in a rule.
|
|
517
|
+
// A takeover is an UPDATE too: a rule for updating is what it would hide behind.
|
|
518
|
+
const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE', promote: 'UPDATE' };
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Whether the app's own rules name this role among those allowed to make
|
|
522
|
+
* this kind of write - the way a repository's role rule does it: the role in
|
|
523
|
+
* a list in a permissive rule for that command, has_role(team,
|
|
524
|
+
* ARRAY['owner', 'maintainer', 'developer']), or in a list inside a helper
|
|
525
|
+
* that rule calls (role IN ('owner', 'developer')). A rule written for one
|
|
526
|
+
* role on its own - role = 'analyst', the shape production added in
|
|
527
|
+
* LaunchRail - is not a list of who may write, and stays something to check.
|
|
528
|
+
*/
|
|
529
|
+
function allowedByRules(rules, table, what, role, type) {
|
|
530
|
+
const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
531
|
+
const label = "'" + escape(String(role).split("'").join("''")) + "'";
|
|
532
|
+
const typeName = escape(String(type || 'text').split('.').pop().split('"').join(''));
|
|
533
|
+
const inRuleList = new RegExp('ARRAY\\[[^\\]]*' + label + '::("?[A-Za-z0-9_$]+"?\\.)?"?' + typeName + '"?', 'i');
|
|
534
|
+
// The same list written as an array literal - has_role(team,
|
|
535
|
+
// '{owner,admin,member}') - which Postgres keeps as '{...}'::role[].
|
|
536
|
+
const bare = escape(String(role));
|
|
537
|
+
const inRuleLiteral = new RegExp("'\\{([^}']*,)?\"?" + bare + "\"?(,[^}']*)?\\}'::(\"?[A-Za-z0-9_$]+\"?\\.)?\"?" +
|
|
538
|
+
typeName + '"?\\[\\]', 'i');
|
|
539
|
+
const inHelperList = new RegExp('(\\bIN\\s*\\(|ARRAY\\[)[^)\\]]*' + label, 'i');
|
|
540
|
+
const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(text);
|
|
541
|
+
const functions = (rules && rules.functions) || [];
|
|
542
|
+
for (const policy of (rules && rules.policies) || []) {
|
|
543
|
+
if (policy.table_name !== table) continue;
|
|
544
|
+
if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
|
|
545
|
+
const cmd = String(policy.cmd || 'ALL').toUpperCase();
|
|
546
|
+
if (cmd !== 'ALL' && cmd !== COMMAND[what]) continue;
|
|
547
|
+
const text = String(policy.qual || '') + ' ' + String(policy.with_check || '');
|
|
548
|
+
if (inRuleList.test(text) || inRuleLiteral.test(text)) return true;
|
|
549
|
+
if (functions.some((fn) => calls(text, fn.name) && inHelperList.test(String(fn.src || '')))) return true;
|
|
550
|
+
}
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* For every team table: can a viewer change it?
|
|
556
|
+
*
|
|
557
|
+
* The other fake person joins the first one's team as its lowest role, and
|
|
558
|
+
* every write is tried twice - before joining and after - so only what joining
|
|
559
|
+
* added is counted: a hole open to every customer is already reported above,
|
|
560
|
+
* and a person changing their own rows is the feature. Both rolled back, the
|
|
561
|
+
* joining with them.
|
|
562
|
+
*/
|
|
563
|
+
async function teammate(client, schema, tables, seeded, rules) {
|
|
564
|
+
const findings = [];
|
|
565
|
+
const completed = [];
|
|
566
|
+
const blocked = [];
|
|
567
|
+
const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
|
|
568
|
+
const done = new Set();
|
|
569
|
+
// What a write or a read achieved, or null when it decided nothing.
|
|
570
|
+
const counted = (result) => {
|
|
571
|
+
const outcome = whatHappened(result);
|
|
572
|
+
return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
for (const gap of teamGaps(tables)) blocked.push(gap);
|
|
576
|
+
for (const member of memberships(tables)) {
|
|
577
|
+
if (!shapeFor.has(member.table.name)) continue;
|
|
578
|
+
// No role says it is above the others: raising one's own role, and
|
|
579
|
+
// moving one's own row in at the top, cannot be tried - said, not skipped.
|
|
580
|
+
if (!member.top) {
|
|
581
|
+
blocked.push({ table: member.table.name, key: 'promote:' + member.table.name, why: 'no role in its ' + member.role +
|
|
582
|
+
' list says it is above the others (owner, admin, manager ...), so whether a member can raise their own role was not tried' });
|
|
583
|
+
}
|
|
584
|
+
const at = quote(schema) + '.' + quote(member.table.name);
|
|
585
|
+
let theirs = null;
|
|
586
|
+
try {
|
|
587
|
+
theirs = (await client.query('SELECT * FROM ' + at + ' WHERE ' + quote(member.person) + ' = $1 LIMIT 1',
|
|
588
|
+
[attack.USER_A])).rows[0] || null;
|
|
589
|
+
} catch (err) {
|
|
590
|
+
theirs = null;
|
|
591
|
+
}
|
|
592
|
+
if (!theirs) continue;
|
|
593
|
+
|
|
594
|
+
// Their team, as the first person's own membership names it.
|
|
595
|
+
const team = {};
|
|
596
|
+
for (const key of member.keys) {
|
|
597
|
+
key.columns.forEach((name) => { if (name !== member.person) team[name] = theirs[name]; });
|
|
598
|
+
}
|
|
599
|
+
const parentKey = member.keys[0];
|
|
600
|
+
const parent = parentKey.refTable;
|
|
601
|
+
// Putting the other person in that team, as one role. Built once a role.
|
|
602
|
+
const joins = new Map();
|
|
603
|
+
const joinAs = async (role) => {
|
|
604
|
+
if (joins.has(role)) return joins.get(role);
|
|
605
|
+
const overrides = Object.assign({}, team);
|
|
606
|
+
overrides[member.role] = role;
|
|
607
|
+
const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
|
|
608
|
+
const join = {
|
|
609
|
+
statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
610
|
+
row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
|
|
611
|
+
values: row.values,
|
|
612
|
+
};
|
|
613
|
+
joins.set(role, join);
|
|
614
|
+
return join;
|
|
615
|
+
};
|
|
616
|
+
const as = (role) => 'as a "' + role + '" in ' + member.table.name;
|
|
617
|
+
let join = null;
|
|
618
|
+
let refused = null;
|
|
619
|
+
try {
|
|
620
|
+
join = await joinAs(member.lowest);
|
|
621
|
+
// Whether they can be put in the team at all, tried once on its own.
|
|
622
|
+
await client.query('BEGIN');
|
|
623
|
+
try {
|
|
624
|
+
await client.query(join.statement, join.values);
|
|
625
|
+
} finally {
|
|
626
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
627
|
+
}
|
|
628
|
+
} catch (err) {
|
|
629
|
+
refused = err.message;
|
|
630
|
+
}
|
|
631
|
+
if (refused) {
|
|
632
|
+
// Said once, on the table that would not take them: every team table
|
|
633
|
+
// went untested for the same reason.
|
|
634
|
+
blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
|
|
635
|
+
why: as(member.lowest) + ': I could not add one: ' + refused });
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Their own membership row - the one in their own team - moved into the
|
|
640
|
+
// first person's team, at the role at the top. Found on blind round 16
|
|
641
|
+
// (Meridian): memberships_update_self checked only user_id = auth.uid(),
|
|
642
|
+
// so anybody who had signed up and made a team of their own could move
|
|
643
|
+
// that row into any other organization as its owner, without ever being
|
|
644
|
+
// invited. Tried before they join, so nothing an invitation gave them
|
|
645
|
+
// counts. One row only: two of theirs moved into one team would be
|
|
646
|
+
// refused as duplicates and say nothing.
|
|
647
|
+
const teamColumns = Object.keys(team).filter((name) => team[name] !== null && team[name] !== undefined).sort();
|
|
648
|
+
if (member.top && teamColumns.length) {
|
|
649
|
+
const key = 'movein:' + member.table.name;
|
|
650
|
+
const literal = (value) => "'" + String(value).split("'").join("''") + "'";
|
|
651
|
+
const sets = teamColumns.map((name) => quote(name) + ' = ' + literal(team[name]))
|
|
652
|
+
.concat([quote(member.role) + ' = ' + literal(member.top)]);
|
|
653
|
+
const elsewhere = teamColumns.map((name) => quote(name) + ' IS DISTINCT FROM ' + literal(team[name])).join(' OR ');
|
|
654
|
+
const statement = 'UPDATE ' + at + ' SET ' + sets.join(', ') + ' WHERE ctid = (SELECT ctid FROM ' + at + ' WHERE ' +
|
|
655
|
+
quote(member.person) + " = '" + attack.USER_B + "' AND (" + elsewhere + ') LIMIT 1)';
|
|
656
|
+
const result = await tryWrite(client, 'authenticated', attack.USER_B, statement, []);
|
|
657
|
+
const moved = counted(result);
|
|
658
|
+
if (moved === null) {
|
|
659
|
+
blocked.push({ table: member.table.name, key: key, why: 'moving their own ' + member.table.name + ' row into another ' +
|
|
660
|
+
parent + ': ' + result.why });
|
|
661
|
+
} else {
|
|
662
|
+
completed.push(key);
|
|
663
|
+
if (moved > 0) {
|
|
664
|
+
findings.push({
|
|
665
|
+
kind: 'role',
|
|
666
|
+
table: member.table.name,
|
|
667
|
+
who: 'anyone signed in',
|
|
668
|
+
via: member.table.name,
|
|
669
|
+
roleColumn: member.role,
|
|
670
|
+
parent: parent,
|
|
671
|
+
can: ['movein'],
|
|
672
|
+
changed: { movein: moved },
|
|
673
|
+
below: [],
|
|
674
|
+
columns: (member.table.columns || []).map((c) => c.name),
|
|
675
|
+
rlsEnabled: member.table.rlsEnabled,
|
|
676
|
+
promote: true,
|
|
677
|
+
movein: true,
|
|
678
|
+
toRole: member.top,
|
|
679
|
+
moved: teamColumns,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
for (const table of tables) {
|
|
686
|
+
if (!shapeFor.has(table.name) || done.has(table.name)) continue;
|
|
687
|
+
done.add(table.name);
|
|
688
|
+
const owner = attack.ownerColumn(table);
|
|
689
|
+
// A table that names the team itself, and the team it is to name.
|
|
690
|
+
const toTeam = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
691
|
+
.find((k) => k.refTable === parent && k.columns.length === parentKey.refColumns.length);
|
|
692
|
+
const pointed = {};
|
|
693
|
+
if (toTeam) {
|
|
694
|
+
toTeam.columns.forEach((name, i) => {
|
|
695
|
+
pointed[name] = team[parentKey.columns[parentKey.refColumns.indexOf(toTeam.refColumns[i])]];
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
// Never their own rows: changing what you made yourself is not a role
|
|
699
|
+
// question, and the row that put them in the team is theirs. And only
|
|
700
|
+
// the team they joined, where the table says which team a row is in:
|
|
701
|
+
// their own team's rows, which they own, were refused by a foreign key
|
|
702
|
+
// on the way out and left the table untested.
|
|
703
|
+
const where = [];
|
|
704
|
+
if (owner) where.push(quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'");
|
|
705
|
+
for (const name of Object.keys(pointed)) {
|
|
706
|
+
where.push(quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'");
|
|
707
|
+
}
|
|
708
|
+
const notTheirs = where.length ? ' WHERE ' + where.join(' AND ') : '';
|
|
709
|
+
const target = quote(schema) + '.' + quote(table.name);
|
|
710
|
+
const column = quote(firstWritable(table));
|
|
711
|
+
const moves = [
|
|
712
|
+
{ what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
|
|
713
|
+
{ what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
|
|
714
|
+
];
|
|
715
|
+
// On the members table itself: putting their own id on the first
|
|
716
|
+
// person's membership row - taking that member's place, and their role.
|
|
717
|
+
// Found on a blind test (HarborLine): a member could update any member
|
|
718
|
+
// row of the team, and repointing an admin's row made them admin. A rule
|
|
719
|
+
// that lets a role edit memberships does not make this the design, so
|
|
720
|
+
// it is asked of every role, listed or not.
|
|
721
|
+
if (table.name === member.table.name) {
|
|
722
|
+
const firstPersons = [quote(member.person) + " = '" + attack.USER_A + "'"]
|
|
723
|
+
.concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
|
|
724
|
+
moves.push({
|
|
725
|
+
what: 'takeover',
|
|
726
|
+
anyRole: true,
|
|
727
|
+
statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
|
|
728
|
+
values: [],
|
|
729
|
+
});
|
|
730
|
+
// Their own membership row, given the role at the top. Found on blind
|
|
731
|
+
// round 15 (Northstar Ops on Supabase): memberships_update checked only
|
|
732
|
+
// user_id = auth.uid(), so a viewer set their own role to 'owner'.
|
|
733
|
+
// Before joining they have no row, so only what joining added counts.
|
|
734
|
+
if (member.top) {
|
|
735
|
+
const ownRow = [quote(member.person) + " = '" + attack.USER_B + "'"]
|
|
736
|
+
.concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
|
|
737
|
+
moves.push({
|
|
738
|
+
what: 'promote',
|
|
739
|
+
anyRole: true,
|
|
740
|
+
statement: 'UPDATE ' + target + ' SET ' + quote(member.role) + " = '" + String(member.top).split("'").join("''") +
|
|
741
|
+
"' WHERE " + ownRow.join(' AND '),
|
|
742
|
+
values: [],
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// Adding a row to the team, said only of a role whose own name says it
|
|
747
|
+
// may only look: a member adding rows is the feature, a viewer adding
|
|
748
|
+
// them is not. Found on a blind test (HelixOps): any member could add
|
|
749
|
+
// alert subscriptions. Only a table that names the team itself. Written
|
|
750
|
+
// under the other person's name: a rule that lets a member add a row
|
|
751
|
+
// only as themselves - a note they wrote - is a member speaking for
|
|
752
|
+
// themselves, and refuses it (OrbitDesk's internal notes, by design).
|
|
753
|
+
if (toTeam && member.ladder.some(lookOnly)) {
|
|
754
|
+
try {
|
|
755
|
+
const row = await attack.rowFor(client, schema, table, attack.USER_A, 11, pointed, shapeFor.get(table.name));
|
|
756
|
+
moves.unshift({
|
|
757
|
+
what: 'add',
|
|
758
|
+
lookOnly: true,
|
|
759
|
+
statement: row.columns.length
|
|
760
|
+
? 'INSERT INTO ' + target + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
761
|
+
row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')'
|
|
762
|
+
: 'INSERT INTO ' + target + ' DEFAULT VALUES',
|
|
763
|
+
values: row.values,
|
|
764
|
+
});
|
|
765
|
+
} catch (err) {
|
|
766
|
+
// Nothing to point a new row at: there is no row to add.
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Every role not in charge, from the lowest up, the way reads are tried:
|
|
771
|
+
// found on a blind test (LaunchRail), where production gave the
|
|
772
|
+
// "analyst" role its own UPDATE rule on feature flags and only the
|
|
773
|
+
// "viewer" below it was ever tried. The first role that can write where
|
|
774
|
+
// the app's own rules do not list it is the one named; a role a rule
|
|
775
|
+
// lists among those allowed to write is the design, and the roles above
|
|
776
|
+
// it are still tried. What each write did before joining is asked once.
|
|
777
|
+
const beforeJoining = new Map();
|
|
778
|
+
for (const move of moves) {
|
|
779
|
+
beforeJoining.set(move.what, await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values));
|
|
780
|
+
}
|
|
781
|
+
const below = [];
|
|
782
|
+
for (const role of member.ladder) {
|
|
783
|
+
const key = 'role:' + table.name + ':' + role;
|
|
784
|
+
let joined;
|
|
785
|
+
try {
|
|
786
|
+
joined = await joinAs(role);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
blocked.push({ table: table.name, key: key, why: as(role) + ': I could not add one: ' + err.message });
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
const can = [];
|
|
792
|
+
const changed = {};
|
|
793
|
+
let stuck = null;
|
|
794
|
+
// Whether this role could write where the rules list it: then it is
|
|
795
|
+
// not one that "could not", whatever is said of the roles above it.
|
|
796
|
+
let listed = false;
|
|
797
|
+
for (const move of moves) {
|
|
798
|
+
if (move.lookOnly && !lookOnly(role)) continue;
|
|
799
|
+
// A write the rules list this role for is not asked: whatever it
|
|
800
|
+
// did would not be reported, and a key refusing it on the way out
|
|
801
|
+
// left the table untested (HelixOps' assets, as a dispatcher).
|
|
802
|
+
if (!move.anyRole && allowedByRules(rules, table.name, move.what, role, member.roleType)) {
|
|
803
|
+
listed = true;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, joined);
|
|
807
|
+
const was = counted(beforeJoining.get(move.what));
|
|
808
|
+
const now = counted(after);
|
|
809
|
+
if (was === null || now === null) {
|
|
810
|
+
stuck = now === null ? after.why : beforeJoining.get(move.what).why;
|
|
811
|
+
} else if (now > was) {
|
|
812
|
+
can.push(move.what);
|
|
813
|
+
changed[move.what] = now - was;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (!can.length && stuck) {
|
|
817
|
+
blocked.push({ table: table.name, key: key, why: as(role) + ': ' + stuck });
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
completed.push(key);
|
|
821
|
+
if (can.length) {
|
|
822
|
+
findings.push(Object.assign({
|
|
823
|
+
kind: 'role',
|
|
824
|
+
table: table.name,
|
|
825
|
+
who: role,
|
|
826
|
+
via: member.table.name,
|
|
827
|
+
roleColumn: member.role,
|
|
828
|
+
parent: parent,
|
|
829
|
+
can: can,
|
|
830
|
+
changed: changed,
|
|
831
|
+
below: below.slice(),
|
|
832
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
833
|
+
rlsEnabled: table.rlsEnabled,
|
|
834
|
+
}, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {},
|
|
835
|
+
can.includes('promote') ? { promote: true, toRole: member.top } : {}));
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
if (!listed) below.push(role);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// In charge of their OWN team, reading the first person's. Found on
|
|
842
|
+
// blind round 15 (Northstar Ops on Supabase): projects_select let in
|
|
843
|
+
// anyone for whom `exists (select 1 from memberships m where m.role =
|
|
844
|
+
// 'admin')` - not tied to the project's organization - so an admin of
|
|
845
|
+
// any organization read every organization's projects. The second
|
|
846
|
+
// person's own membership is given each role in charge, one at a time,
|
|
847
|
+
// and the first person's team's rows are asked for; only what the role
|
|
848
|
+
// added counts. A table that names neither the team nor a person is
|
|
849
|
+
// not asked.
|
|
850
|
+
const theirTeam = Object.keys(pointed).length
|
|
851
|
+
? Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'").join(' AND ')
|
|
852
|
+
: owner ? quote(owner) + " = '" + attack.USER_A + "'" : null;
|
|
853
|
+
if (theirTeam && member.inCharge.length) {
|
|
854
|
+
const look = 'SELECT 1 FROM ' + target + ' WHERE ' + theirTeam;
|
|
855
|
+
const plain = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
|
|
856
|
+
for (const label of member.inCharge) {
|
|
857
|
+
const key = 'elevated:' + table.name + ':' + label;
|
|
858
|
+
const raise = {
|
|
859
|
+
statement: 'UPDATE ' + quote(schema) + '.' + quote(member.table.name) + ' SET ' + quote(member.role) + ' = $1 WHERE ' +
|
|
860
|
+
quote(member.person) + ' = $2',
|
|
861
|
+
values: [label, attack.USER_B],
|
|
862
|
+
};
|
|
863
|
+
const now = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, [], raise));
|
|
864
|
+
if (plain === null || now === null) {
|
|
865
|
+
blocked.push({ table: table.name, key: key, why: 'as "' + label + '" in their own ' + parent + ' row: reading it failed' });
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
completed.push(key);
|
|
869
|
+
if (now > plain) {
|
|
870
|
+
findings.push({
|
|
871
|
+
kind: 'crossed',
|
|
872
|
+
table: table.name,
|
|
873
|
+
owner: owner || Object.keys(pointed).sort()[0],
|
|
874
|
+
tenant: !owner,
|
|
875
|
+
readable: now - plain,
|
|
876
|
+
asRole: label,
|
|
877
|
+
via: member.table.name,
|
|
878
|
+
roleColumn: member.role,
|
|
879
|
+
parent: parent,
|
|
880
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
881
|
+
rlsEnabled: table.rlsEnabled,
|
|
882
|
+
});
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Reading what a member should not see, said only of a table holding a
|
|
889
|
+
// secret. Every role not in charge, from the lowest up; the first that
|
|
890
|
+
// can read is the one named. A member reading their team's ordinary
|
|
891
|
+
// rows is the feature.
|
|
892
|
+
const secrets = secretColumns(table);
|
|
893
|
+
if (!secrets.length) continue;
|
|
894
|
+
const look = 'SELECT 1 FROM ' + target + notTheirs;
|
|
895
|
+
const before = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
|
|
896
|
+
const tried = [];
|
|
897
|
+
for (const role of member.ladder) {
|
|
898
|
+
const readKey = 'teamread:' + table.name + ':' + role;
|
|
899
|
+
let after;
|
|
900
|
+
try {
|
|
901
|
+
after = await tryWrite(client, 'authenticated', attack.USER_B, look, [], await joinAs(role));
|
|
902
|
+
} catch (err) {
|
|
903
|
+
after = { ok: false, count: 0, why: err.message };
|
|
904
|
+
}
|
|
905
|
+
const now = counted(after);
|
|
906
|
+
if (before === null || now === null) {
|
|
907
|
+
blocked.push({ table: table.name, key: readKey, why: as(role) + ': ' + (now === null ? after.why : 'reading it failed before joining') });
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
completed.push(readKey);
|
|
911
|
+
if (now > before) {
|
|
912
|
+
findings.push({
|
|
913
|
+
kind: 'teamread',
|
|
914
|
+
table: table.name,
|
|
915
|
+
who: role,
|
|
916
|
+
via: member.table.name,
|
|
917
|
+
roleColumn: member.role,
|
|
918
|
+
parent: parent,
|
|
919
|
+
readable: now - before,
|
|
920
|
+
secrets: secrets,
|
|
921
|
+
below: tried.slice(),
|
|
922
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
923
|
+
rlsEnabled: table.rlsEnabled,
|
|
924
|
+
});
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
tried.push(role);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return { findings: findings, completed: completed, blocked: blocked };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
module.exports = {
|
|
935
|
+
ACTORS: ACTORS,
|
|
936
|
+
tryWrite: tryWrite,
|
|
937
|
+
whatHappened: whatHappened,
|
|
938
|
+
firstWritable: firstWritable,
|
|
939
|
+
writeAsFor: writeAsFor,
|
|
940
|
+
tamper: tamper,
|
|
941
|
+
lowestRole: lowestRole,
|
|
942
|
+
allowedByRules: allowedByRules,
|
|
943
|
+
secretColumns: secretColumns,
|
|
944
|
+
lookOnly: lookOnly,
|
|
945
|
+
roleColumnOf: roleColumnOf,
|
|
946
|
+
memberships: memberships,
|
|
947
|
+
teamGaps: teamGaps,
|
|
948
|
+
teammate: teammate,
|
|
949
|
+
};
|