kryptheon-night 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (11) hide show
  1. package/attack.js +1028 -1025
  2. package/bin/kryptheon-night.js +736 -725
  3. package/engine.sql +5001 -4430
  4. package/finding.js +1804 -1612
  5. package/intro.js +311 -303
  6. package/package.json +3 -3
  7. package/races.js +304 -0
  8. package/recheck.js +4 -0
  9. package/scan.js +1336 -1311
  10. package/schema.js +1588 -1555
  11. 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
- return { findings: findings, completed: completed, blocked: blocked };
205
- }
206
-
207
- /**
208
- * Whose name an insert is written under: nobody's yet (USER_C), unless the
209
- * owner column is a key into one of the app's own tables, where only the two
210
- * seeded people exist - then the one who is not the signed-in caller.
211
- */
212
- function writeAsFor(table, owner, tables) {
213
- if (!owner) return attack.USER_C;
214
- const key = attack.foreignKeys(table).find((k) => k.columns.length === 1 && k.columns[0] === owner);
215
- const inApp = key && (tables || []).some((t) => t.name === key.refTable);
216
- return inApp ? attack.USER_A : attack.USER_C;
217
- }
218
-
219
- /**
220
- * A column an UPDATE can harmlessly set to itself.
221
- *
222
- * Setting a column to its own value changes nothing about the row while still
223
- * proving the write was allowed - so the finding is real and the data is not
224
- * even momentarily wrong inside the transaction that gets rolled back.
225
- */
226
- function firstWritable(table) {
227
- const usable = (table.columns || []).filter((column) => !column.generated && !column.identity);
228
- const plain = usable.find((column) => !/^(id)$/i.test(column.name));
229
- return (plain || usable[0] || { name: 'id' }).name;
230
- }
231
-
232
- /*
233
- * What the least trusted member of a team can change.
234
- *
235
- * Everything above is one customer against another. Found on two blind tests
236
- * (OrbitDesk, AtlasPay): a rule written "members of this workspace may change
237
- * it" lets a viewer change it too, and an UPDATE rule that checks the role is
238
- * undone by a second one beside it that does not - Postgres lets a write
239
- * through if any permissive rule allows it. Five planted holes of this shape
240
- * went unreported, because both fake people were the owner of their own team
241
- * and nobody else was ever in it.
242
- *
243
- * So one of them joins the other's team with the lowest role there is, for
244
- * one attempt at a time, and tries to change what is in it. Whether a viewer
245
- * may edit is the owner's decision, so this is reported as something to
246
- * check, never as a confirmed break.
247
- */
248
-
249
- // Labels that say "may only look", tried in this order before falling back
250
- // to the last label that is not plainly in charge.
251
- const LEAST_TRUSTED = [/view/i, /read/i, /guest/i, /observ/i, /audit/i];
252
- const IN_CHARGE = /owner|admin|super|root/i;
253
- // Owner columns that name who made a row rather than who is in it.
254
- const CREATOR = /^(created_by|author_id|owner|owner_id)$/i;
255
- // Columns that hold something a member should not simply be able to read:
256
- // a token, a secret or a key, a password, or the payload a webhook carried.
257
- // Found on a blind test (HelixOps): integration_tokens.token_value and
258
- // webhook_deliveries.payload readable by every member, and api_clients
259
- // (client_key, secret_hash) by a dispatcher. A word on its own between
260
- // underscores, so tokens - a count - and question_key are not taken.
261
- // AtlasPay's webhook deliveries kept theirs as request_body and response_body.
262
- const SECRET_COLUMN = /(^|_)(token|secret|password|passwd|pwd|payload|credentials?|(api|private|access|client|signing|service|encryption|secret|license)_?key|(request|response)_body)(_|$)/i;
263
-
264
- /** The columns of a table that look like they hold a secret. */
265
- function secretColumns(table) {
266
- return (table.columns || []).map((c) => c.name).filter((name) => SECRET_COLUMN.test(name));
267
- }
268
-
269
- /** Whether a role's own name says it may only look. */
270
- function lookOnly(role) {
271
- return LEAST_TRUSTED.some((pattern) => pattern.test(role));
272
- }
273
-
274
- /** The role a viewer has, out of a team's list of roles, or null. */
275
- function lowestRole(labels) {
276
- for (const pattern of LEAST_TRUSTED) {
277
- const found = labels.find((label) => pattern.test(label));
278
- if (found) return found;
279
- }
280
- const plain = labels.filter((label) => !IN_CHARGE.test(label));
281
- return plain.length ? plain[plain.length - 1] : null;
282
- }
283
-
284
- /**
285
- * The column holding a member's role, and its lowest value: named for a role,
286
- * with a fixed list of values (an enum, or a CHECK listing them).
287
- */
288
- function roleColumnOf(table) {
289
- for (const column of table.columns || []) {
290
- if (!/role/i.test(column.name)) continue;
291
- const labels = Array.isArray(column.enum_labels) && column.enum_labels.length
292
- ? column.enum_labels
293
- : attack.allowedByCheck(table, column.name);
294
- if (labels.length < 2) continue;
295
- const lowest = lowestRole(labels);
296
- // Every role not plainly in charge, the lowest first and then upwards:
297
- // the order reads are tried in, so the least trusted one that can read a
298
- // secret is the one named.
299
- const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
300
- // The role at the top: an owner if there is one, else the first in charge.
301
- const top = labels.find((label) => /owner/i.test(label)) || labels.find((label) => IN_CHARGE.test(label)) || null;
302
- const inCharge = labels.filter((label) => IN_CHARGE.test(label));
303
- if (lowest) return { column: column.name, type: column.type, lowest: lowest, ladder: ladder, top: top, inCharge: inCharge };
304
- }
305
- return null;
306
- }
307
-
308
- /** Whether a column is a key on its own: one row per person, at most. */
309
- function aloneUnique(table, column) {
310
- return (table.constraints || []).some((constraint) => {
311
- if (constraint.kind !== 'p' && constraint.kind !== 'u') return false;
312
- const match = /(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)/i.exec(String(constraint.definition || ''));
313
- if (!match) return false;
314
- const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
315
- return columns.length === 1 && columns[0] === column;
316
- });
317
- }
318
-
319
- /**
320
- * The tables saying who is in which team, and as what: a person, a role, and
321
- * a key to a table of the app's own that is not the person. A table where the
322
- * person can appear only once (profiles.org_id) is not one: nobody joins a
323
- * second team there, so there is no one to add.
324
- */
325
- function memberships(tables) {
326
- const found = [];
327
- const names = new Set((tables || []).map((table) => table.name));
328
- for (const table of tables || []) {
329
- const person = attack.ownerColumn(table);
330
- // Whoever made the row is not who is in the team. Found on a blind test:
331
- // workspace_invites (created_by, role, workspace_id) was taken for the
332
- // members table, and an invitation made nobody a member of anything.
333
- if (!person || CREATOR.test(person)) continue;
334
- const role = roleColumnOf(table);
335
- if (!role) continue;
336
- const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
337
- .filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
338
- if (!keys.length || aloneUnique(table, person)) continue;
339
- found.push({ table: table, person: person, role: role.column, roleType: role.type, lowest: role.lowest,
340
- ladder: role.ladder, top: role.top, inCharge: role.inCharge || [], keys: keys });
341
- }
342
- // A table named for members first: it is the one a team rule asks.
343
- const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
344
- return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
345
- }
346
-
347
- // What each kind of write is called in a rule.
348
- // A takeover is an UPDATE too: a rule for updating is what it would hide behind.
349
- const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE', promote: 'UPDATE' };
350
-
351
- /**
352
- * Whether the app's own rules name this role among those allowed to make
353
- * this kind of write - the way a repository's role rule does it: the role in
354
- * a list in a permissive rule for that command, has_role(team,
355
- * ARRAY['owner', 'maintainer', 'developer']), or in a list inside a helper
356
- * that rule calls (role IN ('owner', 'developer')). A rule written for one
357
- * role on its own - role = 'analyst', the shape production added in
358
- * LaunchRail - is not a list of who may write, and stays something to check.
359
- */
360
- function allowedByRules(rules, table, what, role, type) {
361
- const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
362
- const label = "'" + escape(String(role).split("'").join("''")) + "'";
363
- const typeName = escape(String(type || 'text').split('.').pop().split('"').join(''));
364
- const inRuleList = new RegExp('ARRAY\\[[^\\]]*' + label + '::("?[A-Za-z0-9_$]+"?\\.)?"?' + typeName + '"?', 'i');
365
- // The same list written as an array literal - has_role(team,
366
- // '{owner,admin,member}') - which Postgres keeps as '{...}'::role[].
367
- const bare = escape(String(role));
368
- const inRuleLiteral = new RegExp("'\\{([^}']*,)?\"?" + bare + "\"?(,[^}']*)?\\}'::(\"?[A-Za-z0-9_$]+\"?\\.)?\"?" +
369
- typeName + '"?\\[\\]', 'i');
370
- const inHelperList = new RegExp('(\\bIN\\s*\\(|ARRAY\\[)[^)\\]]*' + label, 'i');
371
- const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(text);
372
- const functions = (rules && rules.functions) || [];
373
- for (const policy of (rules && rules.policies) || []) {
374
- if (policy.table_name !== table) continue;
375
- if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
376
- const cmd = String(policy.cmd || 'ALL').toUpperCase();
377
- if (cmd !== 'ALL' && cmd !== COMMAND[what]) continue;
378
- const text = String(policy.qual || '') + ' ' + String(policy.with_check || '');
379
- if (inRuleList.test(text) || inRuleLiteral.test(text)) return true;
380
- if (functions.some((fn) => calls(text, fn.name) && inHelperList.test(String(fn.src || '')))) return true;
381
- }
382
- return false;
383
- }
384
-
385
- /**
386
- * For every team table: can a viewer change it?
387
- *
388
- * The other fake person joins the first one's team as its lowest role, and
389
- * every write is tried twice - before joining and after - so only what joining
390
- * added is counted: a hole open to every customer is already reported above,
391
- * and a person changing their own rows is the feature. Both rolled back, the
392
- * joining with them.
393
- */
394
- async function teammate(client, schema, tables, seeded, rules) {
395
- const findings = [];
396
- const completed = [];
397
- const blocked = [];
398
- const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
399
- const done = new Set();
400
- // What a write or a read achieved, or null when it decided nothing.
401
- const counted = (result) => {
402
- const outcome = whatHappened(result);
403
- return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
404
- };
405
-
406
- for (const member of memberships(tables)) {
407
- if (!shapeFor.has(member.table.name)) continue;
408
- const at = quote(schema) + '.' + quote(member.table.name);
409
- let theirs = null;
410
- try {
411
- theirs = (await client.query('SELECT * FROM ' + at + ' WHERE ' + quote(member.person) + ' = $1 LIMIT 1',
412
- [attack.USER_A])).rows[0] || null;
413
- } catch (err) {
414
- theirs = null;
415
- }
416
- if (!theirs) continue;
417
-
418
- // Their team, as the first person's own membership names it.
419
- const team = {};
420
- for (const key of member.keys) {
421
- key.columns.forEach((name) => { if (name !== member.person) team[name] = theirs[name]; });
422
- }
423
- const parentKey = member.keys[0];
424
- const parent = parentKey.refTable;
425
- // Putting the other person in that team, as one role. Built once a role.
426
- const joins = new Map();
427
- const joinAs = async (role) => {
428
- if (joins.has(role)) return joins.get(role);
429
- const overrides = Object.assign({}, team);
430
- overrides[member.role] = role;
431
- const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
432
- const join = {
433
- statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
434
- row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
435
- values: row.values,
436
- };
437
- joins.set(role, join);
438
- return join;
439
- };
440
- const as = (role) => 'as a "' + role + '" in ' + member.table.name;
441
- let join = null;
442
- let refused = null;
443
- try {
444
- join = await joinAs(member.lowest);
445
- // Whether they can be put in the team at all, tried once on its own.
446
- await client.query('BEGIN');
447
- try {
448
- await client.query(join.statement, join.values);
449
- } finally {
450
- await client.query('ROLLBACK').catch(() => {});
451
- }
452
- } catch (err) {
453
- refused = err.message;
454
- }
455
- if (refused) {
456
- // Said once, on the table that would not take them: every team table
457
- // went untested for the same reason.
458
- blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
459
- why: as(member.lowest) + ': I could not add one: ' + refused });
460
- continue;
461
- }
462
-
463
- // Their own membership row - the one in their own team - moved into the
464
- // first person's team, at the role at the top. Found on blind round 16
465
- // (Meridian): memberships_update_self checked only user_id = auth.uid(),
466
- // so anybody who had signed up and made a team of their own could move
467
- // that row into any other organization as its owner, without ever being
468
- // invited. Tried before they join, so nothing an invitation gave them
469
- // counts. One row only: two of theirs moved into one team would be
470
- // refused as duplicates and say nothing.
471
- const teamColumns = Object.keys(team).filter((name) => team[name] !== null && team[name] !== undefined).sort();
472
- if (member.top && teamColumns.length) {
473
- const key = 'movein:' + member.table.name;
474
- const literal = (value) => "'" + String(value).split("'").join("''") + "'";
475
- const sets = teamColumns.map((name) => quote(name) + ' = ' + literal(team[name]))
476
- .concat([quote(member.role) + ' = ' + literal(member.top)]);
477
- const elsewhere = teamColumns.map((name) => quote(name) + ' IS DISTINCT FROM ' + literal(team[name])).join(' OR ');
478
- const statement = 'UPDATE ' + at + ' SET ' + sets.join(', ') + ' WHERE ctid = (SELECT ctid FROM ' + at + ' WHERE ' +
479
- quote(member.person) + " = '" + attack.USER_B + "' AND (" + elsewhere + ') LIMIT 1)';
480
- const result = await tryWrite(client, 'authenticated', attack.USER_B, statement, []);
481
- const moved = counted(result);
482
- if (moved === null) {
483
- blocked.push({ table: member.table.name, key: key, why: 'moving their own ' + member.table.name + ' row into another ' +
484
- parent + ': ' + result.why });
485
- } else {
486
- completed.push(key);
487
- if (moved > 0) {
488
- findings.push({
489
- kind: 'role',
490
- table: member.table.name,
491
- who: 'anyone signed in',
492
- via: member.table.name,
493
- roleColumn: member.role,
494
- parent: parent,
495
- can: ['movein'],
496
- changed: { movein: moved },
497
- below: [],
498
- columns: (member.table.columns || []).map((c) => c.name),
499
- rlsEnabled: member.table.rlsEnabled,
500
- promote: true,
501
- movein: true,
502
- toRole: member.top,
503
- moved: teamColumns,
504
- });
505
- }
506
- }
507
- }
508
-
509
- for (const table of tables) {
510
- if (!shapeFor.has(table.name) || done.has(table.name)) continue;
511
- done.add(table.name);
512
- const owner = attack.ownerColumn(table);
513
- // A table that names the team itself, and the team it is to name.
514
- const toTeam = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
515
- .find((k) => k.refTable === parent && k.columns.length === parentKey.refColumns.length);
516
- const pointed = {};
517
- if (toTeam) {
518
- toTeam.columns.forEach((name, i) => {
519
- pointed[name] = team[parentKey.columns[parentKey.refColumns.indexOf(toTeam.refColumns[i])]];
520
- });
521
- }
522
- // Never their own rows: changing what you made yourself is not a role
523
- // question, and the row that put them in the team is theirs. And only
524
- // the team they joined, where the table says which team a row is in:
525
- // their own team's rows, which they own, were refused by a foreign key
526
- // on the way out and left the table untested.
527
- const where = [];
528
- if (owner) where.push(quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'");
529
- for (const name of Object.keys(pointed)) {
530
- where.push(quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'");
531
- }
532
- const notTheirs = where.length ? ' WHERE ' + where.join(' AND ') : '';
533
- const target = quote(schema) + '.' + quote(table.name);
534
- const column = quote(firstWritable(table));
535
- const moves = [
536
- { what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
537
- { what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
538
- ];
539
- // On the members table itself: putting their own id on the first
540
- // person's membership row - taking that member's place, and their role.
541
- // Found on a blind test (HarborLine): a member could update any member
542
- // row of the team, and repointing an admin's row made them admin. A rule
543
- // that lets a role edit memberships does not make this the design, so
544
- // it is asked of every role, listed or not.
545
- if (table.name === member.table.name) {
546
- const firstPersons = [quote(member.person) + " = '" + attack.USER_A + "'"]
547
- .concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
548
- moves.push({
549
- what: 'takeover',
550
- anyRole: true,
551
- statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
552
- values: [],
553
- });
554
- // Their own membership row, given the role at the top. Found on blind
555
- // round 15 (Northstar Ops on Supabase): memberships_update checked only
556
- // user_id = auth.uid(), so a viewer set their own role to 'owner'.
557
- // Before joining they have no row, so only what joining added counts.
558
- if (member.top) {
559
- const ownRow = [quote(member.person) + " = '" + attack.USER_B + "'"]
560
- .concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
561
- moves.push({
562
- what: 'promote',
563
- anyRole: true,
564
- statement: 'UPDATE ' + target + ' SET ' + quote(member.role) + " = '" + String(member.top).split("'").join("''") +
565
- "' WHERE " + ownRow.join(' AND '),
566
- values: [],
567
- });
568
- }
569
- }
570
- // Adding a row to the team, said only of a role whose own name says it
571
- // may only look: a member adding rows is the feature, a viewer adding
572
- // them is not. Found on a blind test (HelixOps): any member could add
573
- // alert subscriptions. Only a table that names the team itself. Written
574
- // under the other person's name: a rule that lets a member add a row
575
- // only as themselves - a note they wrote - is a member speaking for
576
- // themselves, and refuses it (OrbitDesk's internal notes, by design).
577
- if (toTeam && member.ladder.some(lookOnly)) {
578
- try {
579
- const row = await attack.rowFor(client, schema, table, attack.USER_A, 11, pointed, shapeFor.get(table.name));
580
- moves.unshift({
581
- what: 'add',
582
- lookOnly: true,
583
- statement: row.columns.length
584
- ? 'INSERT INTO ' + target + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
585
- row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')'
586
- : 'INSERT INTO ' + target + ' DEFAULT VALUES',
587
- values: row.values,
588
- });
589
- } catch (err) {
590
- // Nothing to point a new row at: there is no row to add.
591
- }
592
- }
593
-
594
- // Every role not in charge, from the lowest up, the way reads are tried:
595
- // found on a blind test (LaunchRail), where production gave the
596
- // "analyst" role its own UPDATE rule on feature flags and only the
597
- // "viewer" below it was ever tried. The first role that can write where
598
- // the app's own rules do not list it is the one named; a role a rule
599
- // lists among those allowed to write is the design, and the roles above
600
- // it are still tried. What each write did before joining is asked once.
601
- const beforeJoining = new Map();
602
- for (const move of moves) {
603
- beforeJoining.set(move.what, await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values));
604
- }
605
- const below = [];
606
- for (const role of member.ladder) {
607
- const key = 'role:' + table.name + ':' + role;
608
- let joined;
609
- try {
610
- joined = await joinAs(role);
611
- } catch (err) {
612
- blocked.push({ table: table.name, key: key, why: as(role) + ': I could not add one: ' + err.message });
613
- break;
614
- }
615
- const can = [];
616
- const changed = {};
617
- let stuck = null;
618
- // Whether this role could write where the rules list it: then it is
619
- // not one that "could not", whatever is said of the roles above it.
620
- let listed = false;
621
- for (const move of moves) {
622
- if (move.lookOnly && !lookOnly(role)) continue;
623
- // A write the rules list this role for is not asked: whatever it
624
- // did would not be reported, and a key refusing it on the way out
625
- // left the table untested (HelixOps' assets, as a dispatcher).
626
- if (!move.anyRole && allowedByRules(rules, table.name, move.what, role, member.roleType)) {
627
- listed = true;
628
- continue;
629
- }
630
- const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, joined);
631
- const was = counted(beforeJoining.get(move.what));
632
- const now = counted(after);
633
- if (was === null || now === null) {
634
- stuck = now === null ? after.why : beforeJoining.get(move.what).why;
635
- } else if (now > was) {
636
- can.push(move.what);
637
- changed[move.what] = now - was;
638
- }
639
- }
640
- if (!can.length && stuck) {
641
- blocked.push({ table: table.name, key: key, why: as(role) + ': ' + stuck });
642
- break;
643
- }
644
- completed.push(key);
645
- if (can.length) {
646
- findings.push(Object.assign({
647
- kind: 'role',
648
- table: table.name,
649
- who: role,
650
- via: member.table.name,
651
- roleColumn: member.role,
652
- parent: parent,
653
- can: can,
654
- changed: changed,
655
- below: below.slice(),
656
- columns: (table.columns || []).map((c) => c.name),
657
- rlsEnabled: table.rlsEnabled,
658
- }, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {},
659
- can.includes('promote') ? { promote: true, toRole: member.top } : {}));
660
- break;
661
- }
662
- if (!listed) below.push(role);
663
- }
664
-
665
- // In charge of their OWN team, reading the first person's. Found on
666
- // blind round 15 (Northstar Ops on Supabase): projects_select let in
667
- // anyone for whom `exists (select 1 from memberships m where m.role =
668
- // 'admin')` - not tied to the project's organization - so an admin of
669
- // any organization read every organization's projects. The second
670
- // person's own membership is given each role in charge, one at a time,
671
- // and the first person's team's rows are asked for; only what the role
672
- // added counts. A table that names neither the team nor a person is
673
- // not asked.
674
- const theirTeam = Object.keys(pointed).length
675
- ? Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'").join(' AND ')
676
- : owner ? quote(owner) + " = '" + attack.USER_A + "'" : null;
677
- if (theirTeam && member.inCharge.length) {
678
- const look = 'SELECT 1 FROM ' + target + ' WHERE ' + theirTeam;
679
- const plain = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
680
- for (const label of member.inCharge) {
681
- const key = 'elevated:' + table.name + ':' + label;
682
- const raise = {
683
- statement: 'UPDATE ' + quote(schema) + '.' + quote(member.table.name) + ' SET ' + quote(member.role) + ' = $1 WHERE ' +
684
- quote(member.person) + ' = $2',
685
- values: [label, attack.USER_B],
686
- };
687
- const now = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, [], raise));
688
- if (plain === null || now === null) {
689
- blocked.push({ table: table.name, key: key, why: 'as "' + label + '" in their own ' + parent + ' row: reading it failed' });
690
- break;
691
- }
692
- completed.push(key);
693
- if (now > plain) {
694
- findings.push({
695
- kind: 'crossed',
696
- table: table.name,
697
- owner: owner || Object.keys(pointed).sort()[0],
698
- tenant: !owner,
699
- readable: now - plain,
700
- asRole: label,
701
- via: member.table.name,
702
- roleColumn: member.role,
703
- parent: parent,
704
- columns: (table.columns || []).map((c) => c.name),
705
- rlsEnabled: table.rlsEnabled,
706
- });
707
- break;
708
- }
709
- }
710
- }
711
-
712
- // Reading what a member should not see, said only of a table holding a
713
- // secret. Every role not in charge, from the lowest up; the first that
714
- // can read is the one named. A member reading their team's ordinary
715
- // rows is the feature.
716
- const secrets = secretColumns(table);
717
- if (!secrets.length) continue;
718
- const look = 'SELECT 1 FROM ' + target + notTheirs;
719
- const before = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
720
- const tried = [];
721
- for (const role of member.ladder) {
722
- const readKey = 'teamread:' + table.name + ':' + role;
723
- let after;
724
- try {
725
- after = await tryWrite(client, 'authenticated', attack.USER_B, look, [], await joinAs(role));
726
- } catch (err) {
727
- after = { ok: false, count: 0, why: err.message };
728
- }
729
- const now = counted(after);
730
- if (before === null || now === null) {
731
- blocked.push({ table: table.name, key: readKey, why: as(role) + ': ' + (now === null ? after.why : 'reading it failed before joining') });
732
- break;
733
- }
734
- completed.push(readKey);
735
- if (now > before) {
736
- findings.push({
737
- kind: 'teamread',
738
- table: table.name,
739
- who: role,
740
- via: member.table.name,
741
- roleColumn: member.role,
742
- parent: parent,
743
- readable: now - before,
744
- secrets: secrets,
745
- below: tried.slice(),
746
- columns: (table.columns || []).map((c) => c.name),
747
- rlsEnabled: table.rlsEnabled,
748
- });
749
- break;
750
- }
751
- tried.push(role);
752
- }
753
- }
754
- }
755
- return { findings: findings, completed: completed, blocked: blocked };
756
- }
757
-
758
- module.exports = {
759
- ACTORS: ACTORS,
760
- tryWrite: tryWrite,
761
- whatHappened: whatHappened,
762
- firstWritable: firstWritable,
763
- writeAsFor: writeAsFor,
764
- tamper: tamper,
765
- lowestRole: lowestRole,
766
- allowedByRules: allowedByRules,
767
- secretColumns: secretColumns,
768
- lookOnly: lookOnly,
769
- roleColumnOf: roleColumnOf,
770
- memberships: memberships,
771
- teammate: teammate,
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
+ };