kryptheon-night 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/attack.js +63 -2
- package/bin/kryptheon-night.js +3 -1
- package/engine.sql +571 -3
- package/finding.js +163 -3
- package/intro.js +5 -1
- package/package.json +5 -3
- package/recheck.js +138 -13
- package/scan.js +75 -6
- package/schema.js +59 -0
- package/sqlengine.js +12 -1
- package/tamper.js +334 -1
package/scan.js
CHANGED
|
@@ -128,7 +128,7 @@ function suggest(schemas) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* The engine, as
|
|
131
|
+
* The engine, as seven operations, so the scan does not know which one it has.
|
|
132
132
|
*
|
|
133
133
|
* `scan.js` named `schema.js`, `attack.js`, `tamper.js` and `orphan.js`
|
|
134
134
|
* directly, which meant the SQL engine could only ever be reached by the twin
|
|
@@ -153,6 +153,7 @@ function nodeEngine() {
|
|
|
153
153
|
tamper: (client, into, tables, seeded, policies) =>
|
|
154
154
|
tamper.tamper(client, into, tables, seeded, policies),
|
|
155
155
|
orphan: (client, into, tables, seeded) => orphan.orphan(client, into, tables, seeded),
|
|
156
|
+
teammate: (client, into, tables, seeded) => tamper.teammate(client, into, tables, seeded),
|
|
156
157
|
};
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -302,6 +303,14 @@ async function scan(client, sourceSchema, options) {
|
|
|
302
303
|
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
// What the least trusted member of a team can change there. Rolled back
|
|
307
|
+
// like the writes above, and the joining with them.
|
|
308
|
+
const ranked = await engine.teammate(client, copyName, theirs, sown.seeded);
|
|
309
|
+
for (const key of ranked.completed) attempted.push(key);
|
|
310
|
+
for (const stuck of ranked.blocked) {
|
|
311
|
+
notChecked.push({ table: stuck.table, key: stuck.key, why: stuck.why });
|
|
312
|
+
}
|
|
313
|
+
|
|
305
314
|
// Can a half-finished write survive? Rolled back like the writes above.
|
|
306
315
|
say(' Looking for rows that could point at nothing ...');
|
|
307
316
|
const stranded = await engine.orphan(client, copyName, theirs, sown.seeded);
|
|
@@ -395,18 +404,38 @@ async function scan(client, sourceSchema, options) {
|
|
|
395
404
|
for (const fn of plan.anonFunctions || []) attempted.push('privileged:' + fn.name);
|
|
396
405
|
const privileged = privilegedOf(plan);
|
|
397
406
|
|
|
407
|
+
// Storage buckets switched to public that also have a rule saying who may
|
|
408
|
+
// read them. Read from the bucket settings and the rules, never a file.
|
|
409
|
+
// Every bucket read counts as examined, so one made private comes back
|
|
410
|
+
// from a re-check as fixed.
|
|
411
|
+
for (const bucket of plan.buckets || []) attempted.push('bucket:' + bucket.name);
|
|
412
|
+
const buckets = bucketsOf(plan);
|
|
413
|
+
|
|
398
414
|
// Ties read from the original schema: the copy points auth.users keys at
|
|
399
415
|
// a stand-in, and would call every table untied. Owners read from the
|
|
400
416
|
// copy, the way seeding did.
|
|
401
417
|
const tagged = taggerFor(plan, theirs);
|
|
402
418
|
|
|
419
|
+
// Where this run was, so a re-check can tell a database it saw before from
|
|
420
|
+
// another one entirely (recheck.elsewhereOf). The name and address only.
|
|
421
|
+
const at = (await client.query(
|
|
422
|
+
'SELECT current_database() AS database, host(inet_server_addr()) AS host, inet_server_port() AS port',
|
|
423
|
+
)).rows[0];
|
|
424
|
+
|
|
403
425
|
return {
|
|
404
426
|
stopped: null,
|
|
427
|
+
where: {
|
|
428
|
+
database: at.database,
|
|
429
|
+
host: at.host || null,
|
|
430
|
+
port: at.port || null,
|
|
431
|
+
schema: sourceSchema,
|
|
432
|
+
tables: plan.tables.map((table) => table.name).sort(),
|
|
433
|
+
},
|
|
405
434
|
attacksRun: attempted.length,
|
|
406
435
|
notChecked: notChecked,
|
|
407
436
|
attempted: attempted,
|
|
408
437
|
findings: finding.describeAll(
|
|
409
|
-
impersonation.findings.concat(writes.findings, stranded.findings, raced, privileged, recursive).map(tagged),
|
|
438
|
+
impersonation.findings.concat(writes.findings, ranked.findings, stranded.findings, raced, privileged, buckets, recursive).map(tagged),
|
|
410
439
|
),
|
|
411
440
|
};
|
|
412
441
|
} finally {
|
|
@@ -477,6 +506,37 @@ function privilegedOf(plan) {
|
|
|
477
506
|
}));
|
|
478
507
|
}
|
|
479
508
|
|
|
509
|
+
/**
|
|
510
|
+
* A public bucket whose read rule says it was meant to be private.
|
|
511
|
+
*
|
|
512
|
+
* Found on two blind tests (OrbitDesk, AtlasPay): a bucket of exports and one
|
|
513
|
+
* of settlement evidence were switched to public. A public bucket hands any
|
|
514
|
+
* file to whoever has its link, and the rules on storage.objects are not asked.
|
|
515
|
+
* A rule that only lets in some people is how the app says the files are not
|
|
516
|
+
* for everyone - so a public bucket with one is worth a look. A rule that lets
|
|
517
|
+
* in everyone anyway (anon or public, and nothing but the bucket's name) says
|
|
518
|
+
* the opposite, and is not reported.
|
|
519
|
+
*/
|
|
520
|
+
function bucketsOf(plan) {
|
|
521
|
+
const escape = (x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
522
|
+
const narrows = (rule, id) => {
|
|
523
|
+
const roles = schema.roleList(rule.roles);
|
|
524
|
+
const toEveryone = roles.includes('anon') || roles.includes('public');
|
|
525
|
+
const rest = String(rule.qual || '')
|
|
526
|
+
.replace(new RegExp("bucket_id\\s*=\\s*'" + escape(id) + "'(::text)?", 'gi'), '')
|
|
527
|
+
.replace(new RegExp("'" + escape(id) + "'(::text)?\\s*=\\s*bucket_id", 'gi'), '')
|
|
528
|
+
.replace(/\btrue\b|\band\b|[()\s]/gi, '');
|
|
529
|
+
return !toEveryone || rest !== '';
|
|
530
|
+
};
|
|
531
|
+
return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => ({
|
|
532
|
+
kind: 'bucket',
|
|
533
|
+
table: b.name,
|
|
534
|
+
bucket: b.id,
|
|
535
|
+
rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
|
|
536
|
+
columns: [],
|
|
537
|
+
})).filter((f) => f.rules.length);
|
|
538
|
+
}
|
|
539
|
+
|
|
480
540
|
/**
|
|
481
541
|
* What each finding needs to know about its table for its fix to be right:
|
|
482
542
|
* whether the app signs people in through Supabase, whether a row says whose
|
|
@@ -567,7 +627,7 @@ function nightlyRecord(latest, readAt, plan) {
|
|
|
567
627
|
const at = f.kind === 'recursive' && !f.table ? (loops.length ? loops : ['one of your tables']) : [f.table];
|
|
568
628
|
for (const table of at) night.push(tag(Object.assign({}, f, { table: table, fromNight: true })));
|
|
569
629
|
}
|
|
570
|
-
const now = plan ? privilegedOf(plan).map(tag) : [];
|
|
630
|
+
const now = plan ? privilegedOf(plan).concat(bucketsOf(plan)).map(tag) : [];
|
|
571
631
|
return Object.assign(record, {
|
|
572
632
|
ranAt: at(run.ran_at),
|
|
573
633
|
source: run.source,
|
|
@@ -737,8 +797,14 @@ function report(result) {
|
|
|
737
797
|
line(' ' + confirmed.length + (confirmed.length === 1 ? ' problem' : ' problems') + ' found.');
|
|
738
798
|
}
|
|
739
799
|
if (toVerify.length) {
|
|
740
|
-
|
|
741
|
-
|
|
800
|
+
// A viewer who can edit was really tried; a function or a bucket was only
|
|
801
|
+
// read. Either way it is the owner's call, and the line says which.
|
|
802
|
+
const tried = toVerify.some((f) => f.kind === 'role' || f.kind === 'teamread');
|
|
803
|
+
const spotted = toVerify.some((f) => f.kind !== 'role' && f.kind !== 'teamread');
|
|
804
|
+
const it = toVerify.length === 1 ? 'it' : 'them';
|
|
805
|
+
line(' ' + toVerify.length + ' thing' + (toVerify.length === 1 ? '' : 's') + ' to check - ' +
|
|
806
|
+
(spotted && !tried ? 'I did not attack ' + it + ', only spotted the risk.'
|
|
807
|
+
: 'whether ' + (toVerify.length === 1 ? 'it is a problem' : 'each is a problem') + ' is your decision.'));
|
|
742
808
|
}
|
|
743
809
|
|
|
744
810
|
for (const item of result.findings) {
|
|
@@ -859,7 +925,9 @@ async function main() {
|
|
|
859
925
|
if (result.findings.length) report(result);
|
|
860
926
|
else notTestedLines(result).forEach(line);
|
|
861
927
|
saveRun(file, result);
|
|
862
|
-
|
|
928
|
+
// Two different databases: the verdict is a comparison, so the exit code
|
|
929
|
+
// is this run's own answer, the same as a first run.
|
|
930
|
+
process.exitCode = result.stopped ? 2 : verdict.elsewhere ? exitCodeFor(result) : verdict.allClear ? 0 : 1;
|
|
863
931
|
} finally {
|
|
864
932
|
await client.end();
|
|
865
933
|
}
|
|
@@ -887,6 +955,7 @@ module.exports = {
|
|
|
887
955
|
scheduleTimes: scheduleTimes,
|
|
888
956
|
notCheckedFromNight: notCheckedFromNight,
|
|
889
957
|
privilegedOf: privilegedOf,
|
|
958
|
+
bucketsOf: bucketsOf,
|
|
890
959
|
loopingTables: loopingTables,
|
|
891
960
|
taggerFor: taggerFor,
|
|
892
961
|
saveNightly: saveNightly,
|
package/schema.js
CHANGED
|
@@ -356,6 +356,48 @@ async function readFunctions(client, schema) {
|
|
|
356
356
|
return rows;
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Supabase Storage's buckets: whether each is public, and the rules on
|
|
361
|
+
* storage.objects that say who may read it (a rule naming the bucket's id).
|
|
362
|
+
*
|
|
363
|
+
* The settings and the rules only - never a file, never a file's name. A
|
|
364
|
+
* database without Supabase Storage, or one this connection cannot read it in,
|
|
365
|
+
* has none. Mirrors read_buckets in engine.sql, query for query.
|
|
366
|
+
*
|
|
367
|
+
* Asked first rather than left to fail: a failed query inside a caller's
|
|
368
|
+
* transaction would abort everything after it.
|
|
369
|
+
*/
|
|
370
|
+
async function readBuckets(client) {
|
|
371
|
+
try {
|
|
372
|
+
const here = await client.query(
|
|
373
|
+
`SELECT to_regclass('storage.buckets') IS NOT NULL
|
|
374
|
+
AND EXISTS (SELECT 1 FROM information_schema.columns
|
|
375
|
+
WHERE table_schema = 'storage' AND table_name = 'buckets' AND column_name = 'public') AS found`,
|
|
376
|
+
);
|
|
377
|
+
if (!here.rows[0].found) return [];
|
|
378
|
+
const { rows } = await client.query(BUCKETS_QUERY);
|
|
379
|
+
return rows[0].buckets;
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const BUCKETS_QUERY =
|
|
386
|
+
`SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
387
|
+
'id', b.id::text,
|
|
388
|
+
'name', b.name::text,
|
|
389
|
+
'public', coalesce(b.public, false),
|
|
390
|
+
'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
391
|
+
'name', p.policyname::text,
|
|
392
|
+
'roles', to_jsonb(p.roles::text[]),
|
|
393
|
+
'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
|
|
394
|
+
FROM pg_policies p
|
|
395
|
+
WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
|
|
396
|
+
AND p.cmd IN ('SELECT', 'ALL')
|
|
397
|
+
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
|
|
398
|
+
) ORDER BY b.id), '[]'::jsonb) AS buckets
|
|
399
|
+
FROM storage.buckets b`;
|
|
400
|
+
|
|
359
401
|
async function readPolicies(client, schema) {
|
|
360
402
|
const { rows } = await client.query(
|
|
361
403
|
`SELECT tablename AS table_name,
|
|
@@ -632,6 +674,7 @@ async function readSchema(client, schema) {
|
|
|
632
674
|
external: external,
|
|
633
675
|
anonFunctions: await readAnonDefinerFunctions(client, schema),
|
|
634
676
|
functions: await readFunctions(client, schema),
|
|
677
|
+
buckets: await readBuckets(client),
|
|
635
678
|
unsupported: unsupported,
|
|
636
679
|
};
|
|
637
680
|
}
|
|
@@ -764,11 +807,26 @@ function copyFunctionStatement(fn, plan, target, copiedNames) {
|
|
|
764
807
|
() => 'CREATE FUNCTION ' + quote(target) + '.',
|
|
765
808
|
);
|
|
766
809
|
// The settings line, e.g. SET search_path TO 'public', 'storage'
|
|
810
|
+
const pinned = /^\s*SET search_path (?:TO|=) /m.test(def);
|
|
767
811
|
def = def.replace(/^(\s*SET search_path (?:TO|=) )(.*)$/m, (all, head, list) =>
|
|
768
812
|
head + list.split(',').map((part) => {
|
|
769
813
|
const bare = part.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
|
|
770
814
|
return bare === plan.schema ? "'" + target + "', " + part.trim() : part.trim();
|
|
771
815
|
}).join(', '));
|
|
816
|
+
// None pinned, so a bare name in the body is looked up on the caller's path
|
|
817
|
+
// when it runs - and the caller's path has never heard of the copy. Found on
|
|
818
|
+
// a blind test (OrbitDesk): member_of and has_role read `workspace_members`
|
|
819
|
+
// bare, so the copy's helpers read the original's members, with the
|
|
820
|
+
// original's workspace_role, against the copy's. Every rule calling has_role
|
|
821
|
+
// failed with "operator does not exist", 13 attacks never ran, and a loop
|
|
822
|
+
// through member_of never fired because the original's table was empty.
|
|
823
|
+
// So the copy's helper is pinned the way the copy itself was built: the copy
|
|
824
|
+
// first, then where the original's bare names would have gone. A BEGIN
|
|
825
|
+
// ATOMIC body has no AS line and needs none - it was bound when it was made.
|
|
826
|
+
if (!pinned) {
|
|
827
|
+
const path = Array.from(new Set([target, plan.schema, 'public', 'extensions']));
|
|
828
|
+
def = def.replace(/\nAS /, '\n SET search_path TO ' + path.map((s) => "'" + s + "'").join(', ') + '\nAS ');
|
|
829
|
+
}
|
|
772
830
|
return rewriteOwnTableRefs(def, plan, target, copiedNames);
|
|
773
831
|
}
|
|
774
832
|
|
|
@@ -1383,6 +1441,7 @@ module.exports = {
|
|
|
1383
1441
|
writeSchema: writeSchema,
|
|
1384
1442
|
diffSchemas: diffSchemas,
|
|
1385
1443
|
readPolicies: readPolicies,
|
|
1444
|
+
readBuckets: readBuckets,
|
|
1386
1445
|
readTypes: readTypes,
|
|
1387
1446
|
readSequenceGrants: readSequenceGrants,
|
|
1388
1447
|
readIndexes: readIndexes,
|
package/sqlengine.js
CHANGED
|
@@ -114,6 +114,15 @@ async function tamper(client, target, into, tables, seeded, policies) {
|
|
|
114
114
|
return rows[0].answer;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** What the least trusted member of a team can change. Rolled back inside the engine. */
|
|
118
|
+
async function teammate(client, target, into, tables, seeded) {
|
|
119
|
+
const { rows } = await client.query(
|
|
120
|
+
'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb) AS answer',
|
|
121
|
+
[into, JSON.stringify(tables), JSON.stringify(seeded)],
|
|
122
|
+
);
|
|
123
|
+
return rows[0].answer;
|
|
124
|
+
}
|
|
125
|
+
|
|
117
126
|
/** Can a half-finished row survive? */
|
|
118
127
|
async function orphan(client, target, into, tables, seeded) {
|
|
119
128
|
const { rows } = await client.query(
|
|
@@ -141,7 +150,7 @@ async function collide(client, target, into, tables, indexes) {
|
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
/**
|
|
144
|
-
* The same
|
|
153
|
+
* The same seven operations `scan.js` asks of an engine, done in SQL.
|
|
145
154
|
*
|
|
146
155
|
* Handed an engine schema that is already installed, because installing and
|
|
147
156
|
* removing it is the caller's business - `withEngine` below - and a scan
|
|
@@ -162,6 +171,7 @@ function adapterFor(target) {
|
|
|
162
171
|
tamper: (client, into, tables, sown, policies) =>
|
|
163
172
|
tamper(client, target, into, tables, sown, policies),
|
|
164
173
|
orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
|
|
174
|
+
teammate: (client, into, tables, sown) => teammate(client, target, into, tables, sown),
|
|
165
175
|
};
|
|
166
176
|
}
|
|
167
177
|
|
|
@@ -194,6 +204,7 @@ module.exports = {
|
|
|
194
204
|
impersonate: impersonate,
|
|
195
205
|
tamper: tamper,
|
|
196
206
|
orphan: orphan,
|
|
207
|
+
teammate: teammate,
|
|
197
208
|
collide: collide,
|
|
198
209
|
adapterFor: adapterFor,
|
|
199
210
|
withEngine: withEngine,
|
package/tamper.js
CHANGED
|
@@ -47,9 +47,13 @@ const ACTORS = [
|
|
|
47
47
|
* The rollback is not a tidy-up, it is the safety property. Nothing this
|
|
48
48
|
* attack does survives the statement that did it.
|
|
49
49
|
*/
|
|
50
|
-
async function tryWrite(client, role, identity, statement, values) {
|
|
50
|
+
async function tryWrite(client, role, identity, statement, values, first) {
|
|
51
51
|
await client.query('BEGIN');
|
|
52
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 || []);
|
|
53
57
|
await client.query("SET LOCAL statement_timeout = '15s'");
|
|
54
58
|
await client.query('SET LOCAL role TO ' + role);
|
|
55
59
|
await client.query('SELECT set_config($1, $2, true)', [
|
|
@@ -217,6 +221,329 @@ function firstWritable(table) {
|
|
|
217
221
|
return (plain || usable[0] || { name: 'id' }).name;
|
|
218
222
|
}
|
|
219
223
|
|
|
224
|
+
/*
|
|
225
|
+
* What the least trusted member of a team can change.
|
|
226
|
+
*
|
|
227
|
+
* Everything above is one customer against another. Found on two blind tests
|
|
228
|
+
* (OrbitDesk, AtlasPay): a rule written "members of this workspace may change
|
|
229
|
+
* it" lets a viewer change it too, and an UPDATE rule that checks the role is
|
|
230
|
+
* undone by a second one beside it that does not - Postgres lets a write
|
|
231
|
+
* through if any permissive rule allows it. Five planted holes of this shape
|
|
232
|
+
* went unreported, because both fake people were the owner of their own team
|
|
233
|
+
* and nobody else was ever in it.
|
|
234
|
+
*
|
|
235
|
+
* So one of them joins the other's team with the lowest role there is, for
|
|
236
|
+
* one attempt at a time, and tries to change what is in it. Whether a viewer
|
|
237
|
+
* may edit is the owner's decision, so this is reported as something to
|
|
238
|
+
* check, never as a confirmed break.
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
// Labels that say "may only look", tried in this order before falling back
|
|
242
|
+
// to the last label that is not plainly in charge.
|
|
243
|
+
const LEAST_TRUSTED = [/view/i, /read/i, /guest/i, /observ/i, /audit/i];
|
|
244
|
+
const IN_CHARGE = /owner|admin|super|root/i;
|
|
245
|
+
// Owner columns that name who made a row rather than who is in it.
|
|
246
|
+
const CREATOR = /^(created_by|author_id|owner|owner_id)$/i;
|
|
247
|
+
// Columns that hold something a member should not simply be able to read:
|
|
248
|
+
// a token, a secret or a key, a password, or the payload a webhook carried.
|
|
249
|
+
// Found on a blind test (HelixOps): integration_tokens.token_value and
|
|
250
|
+
// webhook_deliveries.payload readable by every member, and api_clients
|
|
251
|
+
// (client_key, secret_hash) by a dispatcher. A word on its own between
|
|
252
|
+
// underscores, so tokens - a count - and question_key are not taken.
|
|
253
|
+
// AtlasPay's webhook deliveries kept theirs as request_body and response_body.
|
|
254
|
+
const SECRET_COLUMN = /(^|_)(token|secret|password|passwd|pwd|payload|credentials?|(api|private|access|client|signing|service|encryption|secret|license)_?key|(request|response)_body)(_|$)/i;
|
|
255
|
+
|
|
256
|
+
/** The columns of a table that look like they hold a secret. */
|
|
257
|
+
function secretColumns(table) {
|
|
258
|
+
return (table.columns || []).map((c) => c.name).filter((name) => SECRET_COLUMN.test(name));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Whether a role's own name says it may only look. */
|
|
262
|
+
function lookOnly(role) {
|
|
263
|
+
return LEAST_TRUSTED.some((pattern) => pattern.test(role));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The role a viewer has, out of a team's list of roles, or null. */
|
|
267
|
+
function lowestRole(labels) {
|
|
268
|
+
for (const pattern of LEAST_TRUSTED) {
|
|
269
|
+
const found = labels.find((label) => pattern.test(label));
|
|
270
|
+
if (found) return found;
|
|
271
|
+
}
|
|
272
|
+
const plain = labels.filter((label) => !IN_CHARGE.test(label));
|
|
273
|
+
return plain.length ? plain[plain.length - 1] : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The column holding a member's role, and its lowest value: named for a role,
|
|
278
|
+
* with a fixed list of values (an enum, or a CHECK listing them).
|
|
279
|
+
*/
|
|
280
|
+
function roleColumnOf(table) {
|
|
281
|
+
for (const column of table.columns || []) {
|
|
282
|
+
if (!/role/i.test(column.name)) continue;
|
|
283
|
+
const labels = Array.isArray(column.enum_labels) && column.enum_labels.length
|
|
284
|
+
? column.enum_labels
|
|
285
|
+
: attack.allowedByCheck(table, column.name);
|
|
286
|
+
if (labels.length < 2) continue;
|
|
287
|
+
const lowest = lowestRole(labels);
|
|
288
|
+
// Every role not plainly in charge, the lowest first and then upwards:
|
|
289
|
+
// the order reads are tried in, so the least trusted one that can read a
|
|
290
|
+
// secret is the one named.
|
|
291
|
+
const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
|
|
292
|
+
if (lowest) return { column: column.name, lowest: lowest, ladder: ladder };
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Whether a column is a key on its own: one row per person, at most. */
|
|
298
|
+
function aloneUnique(table, column) {
|
|
299
|
+
return (table.constraints || []).some((constraint) => {
|
|
300
|
+
if (constraint.kind !== 'p' && constraint.kind !== 'u') return false;
|
|
301
|
+
const match = /(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)/i.exec(String(constraint.definition || ''));
|
|
302
|
+
if (!match) return false;
|
|
303
|
+
const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
|
|
304
|
+
return columns.length === 1 && columns[0] === column;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The tables saying who is in which team, and as what: a person, a role, and
|
|
310
|
+
* a key to a table of the app's own that is not the person. A table where the
|
|
311
|
+
* person can appear only once (profiles.org_id) is not one: nobody joins a
|
|
312
|
+
* second team there, so there is no one to add.
|
|
313
|
+
*/
|
|
314
|
+
function memberships(tables) {
|
|
315
|
+
const found = [];
|
|
316
|
+
const names = new Set((tables || []).map((table) => table.name));
|
|
317
|
+
for (const table of tables || []) {
|
|
318
|
+
const person = attack.ownerColumn(table);
|
|
319
|
+
// Whoever made the row is not who is in the team. Found on a blind test:
|
|
320
|
+
// workspace_invites (created_by, role, workspace_id) was taken for the
|
|
321
|
+
// members table, and an invitation made nobody a member of anything.
|
|
322
|
+
if (!person || CREATOR.test(person)) continue;
|
|
323
|
+
const role = roleColumnOf(table);
|
|
324
|
+
if (!role) continue;
|
|
325
|
+
const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
326
|
+
.filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
|
|
327
|
+
if (!keys.length || aloneUnique(table, person)) continue;
|
|
328
|
+
found.push({ table: table, person: person, role: role.column, lowest: role.lowest, ladder: role.ladder, keys: keys });
|
|
329
|
+
}
|
|
330
|
+
// A table named for members first: it is the one a team rule asks.
|
|
331
|
+
const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
|
|
332
|
+
return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* For every team table: can a viewer change it?
|
|
337
|
+
*
|
|
338
|
+
* The other fake person joins the first one's team as its lowest role, and
|
|
339
|
+
* every write is tried twice - before joining and after - so only what joining
|
|
340
|
+
* added is counted: a hole open to every customer is already reported above,
|
|
341
|
+
* and a person changing their own rows is the feature. Both rolled back, the
|
|
342
|
+
* joining with them.
|
|
343
|
+
*/
|
|
344
|
+
async function teammate(client, schema, tables, seeded) {
|
|
345
|
+
const findings = [];
|
|
346
|
+
const completed = [];
|
|
347
|
+
const blocked = [];
|
|
348
|
+
const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
|
|
349
|
+
const done = new Set();
|
|
350
|
+
// What a write or a read achieved, or null when it decided nothing.
|
|
351
|
+
const counted = (result) => {
|
|
352
|
+
const outcome = whatHappened(result);
|
|
353
|
+
return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
for (const member of memberships(tables)) {
|
|
357
|
+
if (!shapeFor.has(member.table.name)) continue;
|
|
358
|
+
const at = quote(schema) + '.' + quote(member.table.name);
|
|
359
|
+
let theirs = null;
|
|
360
|
+
try {
|
|
361
|
+
theirs = (await client.query('SELECT * FROM ' + at + ' WHERE ' + quote(member.person) + ' = $1 LIMIT 1',
|
|
362
|
+
[attack.USER_A])).rows[0] || null;
|
|
363
|
+
} catch (err) {
|
|
364
|
+
theirs = null;
|
|
365
|
+
}
|
|
366
|
+
if (!theirs) continue;
|
|
367
|
+
|
|
368
|
+
// Their team, as the first person's own membership names it.
|
|
369
|
+
const team = {};
|
|
370
|
+
for (const key of member.keys) {
|
|
371
|
+
key.columns.forEach((name) => { if (name !== member.person) team[name] = theirs[name]; });
|
|
372
|
+
}
|
|
373
|
+
const parentKey = member.keys[0];
|
|
374
|
+
const parent = parentKey.refTable;
|
|
375
|
+
// Putting the other person in that team, as one role. Built once a role.
|
|
376
|
+
const joins = new Map();
|
|
377
|
+
const joinAs = async (role) => {
|
|
378
|
+
if (joins.has(role)) return joins.get(role);
|
|
379
|
+
const overrides = Object.assign({}, team);
|
|
380
|
+
overrides[member.role] = role;
|
|
381
|
+
const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
|
|
382
|
+
const join = {
|
|
383
|
+
statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
384
|
+
row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
|
|
385
|
+
values: row.values,
|
|
386
|
+
};
|
|
387
|
+
joins.set(role, join);
|
|
388
|
+
return join;
|
|
389
|
+
};
|
|
390
|
+
const as = (role) => 'as a "' + role + '" in ' + member.table.name;
|
|
391
|
+
let join = null;
|
|
392
|
+
let refused = null;
|
|
393
|
+
try {
|
|
394
|
+
join = await joinAs(member.lowest);
|
|
395
|
+
// Whether they can be put in the team at all, tried once on its own.
|
|
396
|
+
await client.query('BEGIN');
|
|
397
|
+
try {
|
|
398
|
+
await client.query(join.statement, join.values);
|
|
399
|
+
} finally {
|
|
400
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
401
|
+
}
|
|
402
|
+
} catch (err) {
|
|
403
|
+
refused = err.message;
|
|
404
|
+
}
|
|
405
|
+
if (refused) {
|
|
406
|
+
// Said once, on the table that would not take them: every team table
|
|
407
|
+
// went untested for the same reason.
|
|
408
|
+
blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
|
|
409
|
+
why: as(member.lowest) + ': I could not add one: ' + refused });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const table of tables) {
|
|
414
|
+
if (!shapeFor.has(table.name) || done.has(table.name)) continue;
|
|
415
|
+
done.add(table.name);
|
|
416
|
+
const key = 'role:' + table.name + ':' + member.lowest;
|
|
417
|
+
const owner = attack.ownerColumn(table);
|
|
418
|
+
// A table that names the team itself, and the team it is to name.
|
|
419
|
+
const toTeam = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
420
|
+
.find((k) => k.refTable === parent && k.columns.length === parentKey.refColumns.length);
|
|
421
|
+
const pointed = {};
|
|
422
|
+
if (toTeam) {
|
|
423
|
+
toTeam.columns.forEach((name, i) => {
|
|
424
|
+
pointed[name] = team[parentKey.columns[parentKey.refColumns.indexOf(toTeam.refColumns[i])]];
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
// Never their own rows: changing what you made yourself is not a role
|
|
428
|
+
// question, and the row that put them in the team is theirs. And only
|
|
429
|
+
// the team they joined, where the table says which team a row is in:
|
|
430
|
+
// their own team's rows, which they own, were refused by a foreign key
|
|
431
|
+
// on the way out and left the table untested.
|
|
432
|
+
const where = [];
|
|
433
|
+
if (owner) where.push(quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'");
|
|
434
|
+
for (const name of Object.keys(pointed)) {
|
|
435
|
+
where.push(quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'");
|
|
436
|
+
}
|
|
437
|
+
const notTheirs = where.length ? ' WHERE ' + where.join(' AND ') : '';
|
|
438
|
+
const target = quote(schema) + '.' + quote(table.name);
|
|
439
|
+
const column = quote(firstWritable(table));
|
|
440
|
+
const moves = [
|
|
441
|
+
{ what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs, values: [] },
|
|
442
|
+
{ what: 'delete', statement: 'DELETE FROM ' + target + notTheirs, values: [] },
|
|
443
|
+
];
|
|
444
|
+
// Adding a row to the team, said only of a role whose own name says it
|
|
445
|
+
// may only look: a member adding rows is the feature, a viewer adding
|
|
446
|
+
// them is not. Found on a blind test (HelixOps): any member could add
|
|
447
|
+
// alert subscriptions. Only a table that names the team itself. Written
|
|
448
|
+
// under the other person's name: a rule that lets a member add a row
|
|
449
|
+
// only as themselves - a note they wrote - is a member speaking for
|
|
450
|
+
// themselves, and refuses it (OrbitDesk's internal notes, by design).
|
|
451
|
+
if (lookOnly(member.lowest) && toTeam) {
|
|
452
|
+
try {
|
|
453
|
+
const row = await attack.rowFor(client, schema, table, attack.USER_A, 11, pointed, shapeFor.get(table.name));
|
|
454
|
+
moves.unshift({
|
|
455
|
+
what: 'add',
|
|
456
|
+
statement: row.columns.length
|
|
457
|
+
? 'INSERT INTO ' + target + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
458
|
+
row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')'
|
|
459
|
+
: 'INSERT INTO ' + target + ' DEFAULT VALUES',
|
|
460
|
+
values: row.values,
|
|
461
|
+
});
|
|
462
|
+
} catch (err) {
|
|
463
|
+
// Nothing to point a new row at: there is no row to add.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const can = [];
|
|
467
|
+
const changed = {};
|
|
468
|
+
let stuck = null;
|
|
469
|
+
for (const move of moves) {
|
|
470
|
+
const before = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values);
|
|
471
|
+
const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, move.values, join);
|
|
472
|
+
const was = counted(before);
|
|
473
|
+
const now = counted(after);
|
|
474
|
+
if (was === null || now === null) {
|
|
475
|
+
stuck = now === null ? after.why : before.why;
|
|
476
|
+
} else if (now > was) {
|
|
477
|
+
can.push(move.what);
|
|
478
|
+
changed[move.what] = now - was;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (!can.length && stuck) {
|
|
482
|
+
blocked.push({ table: table.name, key: key, why: as(member.lowest) + ': ' + stuck });
|
|
483
|
+
} else {
|
|
484
|
+
completed.push(key);
|
|
485
|
+
if (can.length) {
|
|
486
|
+
findings.push({
|
|
487
|
+
kind: 'role',
|
|
488
|
+
table: table.name,
|
|
489
|
+
who: member.lowest,
|
|
490
|
+
via: member.table.name,
|
|
491
|
+
roleColumn: member.role,
|
|
492
|
+
parent: parent,
|
|
493
|
+
can: can,
|
|
494
|
+
changed: changed,
|
|
495
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
496
|
+
rlsEnabled: table.rlsEnabled,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Reading what a member should not see, said only of a table holding a
|
|
502
|
+
// secret. Every role not in charge, from the lowest up; the first that
|
|
503
|
+
// can read is the one named. A member reading their team's ordinary
|
|
504
|
+
// rows is the feature.
|
|
505
|
+
const secrets = secretColumns(table);
|
|
506
|
+
if (!secrets.length) continue;
|
|
507
|
+
const look = 'SELECT 1 FROM ' + target + notTheirs;
|
|
508
|
+
const before = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
|
|
509
|
+
const tried = [];
|
|
510
|
+
for (const role of member.ladder) {
|
|
511
|
+
const readKey = 'teamread:' + table.name + ':' + role;
|
|
512
|
+
let after;
|
|
513
|
+
try {
|
|
514
|
+
after = await tryWrite(client, 'authenticated', attack.USER_B, look, [], await joinAs(role));
|
|
515
|
+
} catch (err) {
|
|
516
|
+
after = { ok: false, count: 0, why: err.message };
|
|
517
|
+
}
|
|
518
|
+
const now = counted(after);
|
|
519
|
+
if (before === null || now === null) {
|
|
520
|
+
blocked.push({ table: table.name, key: readKey, why: as(role) + ': ' + (now === null ? after.why : 'reading it failed before joining') });
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
completed.push(readKey);
|
|
524
|
+
if (now > before) {
|
|
525
|
+
findings.push({
|
|
526
|
+
kind: 'teamread',
|
|
527
|
+
table: table.name,
|
|
528
|
+
who: role,
|
|
529
|
+
via: member.table.name,
|
|
530
|
+
roleColumn: member.role,
|
|
531
|
+
parent: parent,
|
|
532
|
+
readable: now - before,
|
|
533
|
+
secrets: secrets,
|
|
534
|
+
below: tried.slice(),
|
|
535
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
536
|
+
rlsEnabled: table.rlsEnabled,
|
|
537
|
+
});
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
tried.push(role);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return { findings: findings, completed: completed, blocked: blocked };
|
|
545
|
+
}
|
|
546
|
+
|
|
220
547
|
module.exports = {
|
|
221
548
|
ACTORS: ACTORS,
|
|
222
549
|
tryWrite: tryWrite,
|
|
@@ -224,4 +551,10 @@ module.exports = {
|
|
|
224
551
|
firstWritable: firstWritable,
|
|
225
552
|
writeAsFor: writeAsFor,
|
|
226
553
|
tamper: tamper,
|
|
554
|
+
lowestRole: lowestRole,
|
|
555
|
+
secretColumns: secretColumns,
|
|
556
|
+
lookOnly: lookOnly,
|
|
557
|
+
roleColumnOf: roleColumnOf,
|
|
558
|
+
memberships: memberships,
|
|
559
|
+
teammate: teammate,
|
|
227
560
|
};
|