kryptheon-night 0.1.22 → 0.1.26
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 +172 -29
- package/bin/kryptheon-night.js +2 -2
- package/engine.sql +3235 -2628
- package/finding.js +129 -8
- package/intro.js +5 -1
- package/orphan.js +5 -37
- package/package.json +8 -3
- package/recheck.js +4 -0
- package/scan.js +111 -11
- package/schema.js +184 -9
- package/sqlengine.js +12 -1
- package/tamper.js +218 -1
- package/trouble.js +13 -1
package/schema.js
CHANGED
|
@@ -322,6 +322,82 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
322
322
|
});
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/**
|
|
326
|
+
* The app's own SQL and PL/pgSQL functions, with their full definitions.
|
|
327
|
+
*
|
|
328
|
+
* Read so the helpers a rule calls can be built inside the copy. Found on a
|
|
329
|
+
* blind test: a rule called has_org_role(org_id, ARRAY[...]::org_role[]). The
|
|
330
|
+
* copy has its own org_role, the helper stayed in the original schema taking
|
|
331
|
+
* the original's, and no rule could be created - the scan stopped before it
|
|
332
|
+
* began. Left in the original, a helper also answers from the original's
|
|
333
|
+
* tables rather than from the copy's.
|
|
334
|
+
*/
|
|
335
|
+
async function readFunctions(client, schema) {
|
|
336
|
+
const { rows } = await client.query(
|
|
337
|
+
`SELECT p.proname AS name,
|
|
338
|
+
l.lanname AS language,
|
|
339
|
+
p.prosrc AS src,
|
|
340
|
+
pg_get_functiondef(p.oid) AS def,
|
|
341
|
+
pg_get_function_identity_arguments(p.oid) AS args,
|
|
342
|
+
p.proacl IS NULL AS default_acl,
|
|
343
|
+
(SELECT coalesce(array_agg(g ORDER BY g), '{}'::text[])
|
|
344
|
+
FROM (SELECT DISTINCT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee)::text END AS g
|
|
345
|
+
FROM aclexplode(p.proacl) a
|
|
346
|
+
WHERE a.privilege_type = 'EXECUTE' AND a.grantee <> p.proowner) x) AS executors
|
|
347
|
+
FROM pg_proc p
|
|
348
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
349
|
+
JOIN pg_language l ON l.oid = p.prolang
|
|
350
|
+
WHERE n.nspname = $1
|
|
351
|
+
AND p.prokind = 'f'
|
|
352
|
+
AND p.prorettype <> 'pg_catalog.trigger'::regtype
|
|
353
|
+
ORDER BY p.proname, p.oid`,
|
|
354
|
+
[schema],
|
|
355
|
+
);
|
|
356
|
+
return rows;
|
|
357
|
+
}
|
|
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
|
+
|
|
325
401
|
async function readPolicies(client, schema) {
|
|
326
402
|
const { rows } = await client.query(
|
|
327
403
|
`SELECT tablename AS table_name,
|
|
@@ -597,6 +673,8 @@ async function readSchema(client, schema) {
|
|
|
597
673
|
viewGrants: await readViewGrants(client, schema),
|
|
598
674
|
external: external,
|
|
599
675
|
anonFunctions: await readAnonDefinerFunctions(client, schema),
|
|
676
|
+
functions: await readFunctions(client, schema),
|
|
677
|
+
buckets: await readBuckets(client),
|
|
600
678
|
unsupported: unsupported,
|
|
601
679
|
};
|
|
602
680
|
}
|
|
@@ -656,18 +734,19 @@ function rewriteSchemaRefs(expr, fromSchema, toSchema) {
|
|
|
656
734
|
* since source and copy printed the same words.
|
|
657
735
|
*
|
|
658
736
|
* Not rewriteSchemaRefs, which moves every "kn_app." it finds: a rule calling
|
|
659
|
-
* one of the app's functions (kn_app.whoami()
|
|
660
|
-
*
|
|
661
|
-
* CREATE POLICY fail. Only
|
|
662
|
-
* A name followed by more identifier characters
|
|
663
|
-
* members_log is not members - so the match stops at a
|
|
737
|
+
* one of the app's functions that is not copied (kn_app.whoami() in another
|
|
738
|
+
* language) has to keep calling it, and pointing it at the copy makes the
|
|
739
|
+
* CREATE POLICY fail. Only tables, views, and the functions the copy was
|
|
740
|
+
* given (copiedFunctions) move. A name followed by more identifier characters
|
|
741
|
+
* is a different name - members_log is not members - so the match stops at a
|
|
742
|
+
* word boundary.
|
|
664
743
|
*/
|
|
665
|
-
function rewriteOwnTableRefs(expr, plan, target) {
|
|
744
|
+
function rewriteOwnTableRefs(expr, plan, target, copiedFunctions) {
|
|
666
745
|
if (expr === null || expr === undefined) return expr;
|
|
667
746
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
668
747
|
const simple = (s) => /^[a-z_][a-z0-9_$]*$/.test(s);
|
|
669
748
|
const schemaForms = [quote(plan.schema)].concat(simple(plan.schema) ? [plan.schema] : []);
|
|
670
|
-
const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name));
|
|
749
|
+
const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name), copiedFunctions || []);
|
|
671
750
|
let out = String(expr);
|
|
672
751
|
for (const name of own) {
|
|
673
752
|
const nameForms = [quote(name)].concat(simple(name) ? [name] : []);
|
|
@@ -681,6 +760,76 @@ function rewriteOwnTableRefs(expr, plan, target) {
|
|
|
681
760
|
return out;
|
|
682
761
|
}
|
|
683
762
|
|
|
763
|
+
/**
|
|
764
|
+
* Which of the app's functions the copy needs: every SQL or PL/pgSQL function
|
|
765
|
+
* a rule or a view calls, and every one those call in turn. Matched by name
|
|
766
|
+
* followed by "(", bare or schema-qualified; every overload of a name comes
|
|
767
|
+
* along. Functions in other languages stay where they are.
|
|
768
|
+
*/
|
|
769
|
+
function functionsToCopy(plan) {
|
|
770
|
+
const copyable = (plan.functions || []).filter((f) => f.language === 'sql' || f.language === 'plpgsql');
|
|
771
|
+
const names = Array.from(new Set(copyable.map((f) => f.name)));
|
|
772
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
773
|
+
const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
|
|
774
|
+
const seeds = []
|
|
775
|
+
.concat((plan.policies || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')))
|
|
776
|
+
.concat((plan.views || []).map((v) => v.definition));
|
|
777
|
+
const wanted = new Set(names.filter((name) => seeds.some((text) => calls(text, name))));
|
|
778
|
+
// And whatever those call, until nothing new turns up.
|
|
779
|
+
let grew = true;
|
|
780
|
+
while (grew) {
|
|
781
|
+
grew = false;
|
|
782
|
+
for (const f of copyable) {
|
|
783
|
+
if (!wanted.has(f.name)) continue;
|
|
784
|
+
for (const name of names) {
|
|
785
|
+
if (!wanted.has(name) && calls(f.src, name)) {
|
|
786
|
+
wanted.add(name);
|
|
787
|
+
grew = true;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return copyable.filter((f) => wanted.has(f.name));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* One of the app's functions, rebuilt in the copy: the same body and settings,
|
|
797
|
+
* its name in the copy, its references to the app's own tables, views and
|
|
798
|
+
* copied functions pointed at the copy, and a pinned search_path that looks in
|
|
799
|
+
* the copy first and the original second (for what the copy does not have,
|
|
800
|
+
* such as an extension's functions).
|
|
801
|
+
*/
|
|
802
|
+
function copyFunctionStatement(fn, plan, target, copiedNames) {
|
|
803
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
804
|
+
const schemaForms = [quote(plan.schema), plan.schema].map(escape).join('|');
|
|
805
|
+
let def = String(fn.def).replace(
|
|
806
|
+
new RegExp('^CREATE OR REPLACE FUNCTION\\s+(?:' + schemaForms + ')\\.'),
|
|
807
|
+
() => 'CREATE FUNCTION ' + quote(target) + '.',
|
|
808
|
+
);
|
|
809
|
+
// The settings line, e.g. SET search_path TO 'public', 'storage'
|
|
810
|
+
const pinned = /^\s*SET search_path (?:TO|=) /m.test(def);
|
|
811
|
+
def = def.replace(/^(\s*SET search_path (?:TO|=) )(.*)$/m, (all, head, list) =>
|
|
812
|
+
head + list.split(',').map((part) => {
|
|
813
|
+
const bare = part.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
|
|
814
|
+
return bare === plan.schema ? "'" + target + "', " + part.trim() : part.trim();
|
|
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
|
+
}
|
|
830
|
+
return rewriteOwnTableRefs(def, plan, target, copiedNames);
|
|
831
|
+
}
|
|
832
|
+
|
|
684
833
|
/**
|
|
685
834
|
* Points a reference with no schema on it at the copy.
|
|
686
835
|
*
|
|
@@ -940,6 +1089,29 @@ async function writeSchema(client, plan, target) {
|
|
|
940
1089
|
statements.push(rewriteSchemaRefs(index.definition, plan.schema, target));
|
|
941
1090
|
}
|
|
942
1091
|
|
|
1092
|
+
// The helpers the rules and views call, after the tables their bodies read
|
|
1093
|
+
// and before anything that calls them.
|
|
1094
|
+
const copied = functionsToCopy(plan);
|
|
1095
|
+
const copiedNames = Array.from(new Set(copied.map((f) => f.name)));
|
|
1096
|
+
// The app's own types move with them: a copied helper taking app.role_t[]
|
|
1097
|
+
// has to take the copy's role_t, or the copy's columns cannot be compared
|
|
1098
|
+
// with its arguments.
|
|
1099
|
+
const movedNames = copiedNames.concat((plan.types || []).map((t) => t.name));
|
|
1100
|
+
for (const fn of copied) {
|
|
1101
|
+
statements.push(copyFunctionStatement(fn, plan, target, movedNames));
|
|
1102
|
+
// Who may call it, as in the original. Found by the blocked-read check: a
|
|
1103
|
+
// helper the original had closed to anon and authenticated came out of
|
|
1104
|
+
// the copy open to everyone, and a rule that cannot be evaluated was
|
|
1105
|
+
// reported as evaluated.
|
|
1106
|
+
if (!fn.default_acl) {
|
|
1107
|
+
const signature = quote(target) + '.' + quote(fn.name) + '(' + rewriteOwnTableRefs(fn.args, plan, target, movedNames) + ')';
|
|
1108
|
+
statements.push('REVOKE ALL ON FUNCTION ' + signature + ' FROM PUBLIC');
|
|
1109
|
+
for (const who of fn.executors || []) {
|
|
1110
|
+
statements.push('GRANT EXECUTE ON FUNCTION ' + signature + ' TO ' + (who === 'PUBLIC' ? 'PUBLIC' : quote(who)));
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
943
1115
|
// Views last, because they read from the tables above. Copied rather than
|
|
944
1116
|
// skipped because a view is a way into a table: it runs with its creator
|
|
945
1117
|
// rights unless it says security_invoker, so a view over a protected table
|
|
@@ -1011,8 +1183,8 @@ async function writeSchema(client, plan, target) {
|
|
|
1011
1183
|
'FOR ' + policy.cmd,
|
|
1012
1184
|
'TO ' + roles,
|
|
1013
1185
|
];
|
|
1014
|
-
if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target) + ')');
|
|
1015
|
-
if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target) + ')');
|
|
1186
|
+
if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target, movedNames) + ')');
|
|
1187
|
+
if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target, movedNames) + ')');
|
|
1016
1188
|
statements.push(parts.join(' '));
|
|
1017
1189
|
}
|
|
1018
1190
|
|
|
@@ -1269,6 +1441,7 @@ module.exports = {
|
|
|
1269
1441
|
writeSchema: writeSchema,
|
|
1270
1442
|
diffSchemas: diffSchemas,
|
|
1271
1443
|
readPolicies: readPolicies,
|
|
1444
|
+
readBuckets: readBuckets,
|
|
1272
1445
|
readTypes: readTypes,
|
|
1273
1446
|
readSequenceGrants: readSequenceGrants,
|
|
1274
1447
|
readIndexes: readIndexes,
|
|
@@ -1277,6 +1450,8 @@ module.exports = {
|
|
|
1277
1450
|
referenceIn: referenceIn,
|
|
1278
1451
|
rewriteSchemaRefs: rewriteSchemaRefs,
|
|
1279
1452
|
qualifyOwnRefs: qualifyOwnRefs,
|
|
1453
|
+
functionsToCopy: functionsToCopy,
|
|
1454
|
+
copyFunctionStatement: copyFunctionStatement,
|
|
1280
1455
|
stubNameFor: stubNameFor,
|
|
1281
1456
|
IDENTITIES: IDENTITIES,
|
|
1282
1457
|
quote: quote,
|
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,215 @@ 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
|
+
|
|
248
|
+
/** The role a viewer has, out of a team's list of roles, or null. */
|
|
249
|
+
function lowestRole(labels) {
|
|
250
|
+
for (const pattern of LEAST_TRUSTED) {
|
|
251
|
+
const found = labels.find((label) => pattern.test(label));
|
|
252
|
+
if (found) return found;
|
|
253
|
+
}
|
|
254
|
+
const plain = labels.filter((label) => !IN_CHARGE.test(label));
|
|
255
|
+
return plain.length ? plain[plain.length - 1] : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The column holding a member's role, and its lowest value: named for a role,
|
|
260
|
+
* with a fixed list of values (an enum, or a CHECK listing them).
|
|
261
|
+
*/
|
|
262
|
+
function roleColumnOf(table) {
|
|
263
|
+
for (const column of table.columns || []) {
|
|
264
|
+
if (!/role/i.test(column.name)) continue;
|
|
265
|
+
const labels = Array.isArray(column.enum_labels) && column.enum_labels.length
|
|
266
|
+
? column.enum_labels
|
|
267
|
+
: attack.allowedByCheck(table, column.name);
|
|
268
|
+
if (labels.length < 2) continue;
|
|
269
|
+
const lowest = lowestRole(labels);
|
|
270
|
+
if (lowest) return { column: column.name, lowest: lowest };
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether a column is a key on its own: one row per person, at most. */
|
|
276
|
+
function aloneUnique(table, column) {
|
|
277
|
+
return (table.constraints || []).some((constraint) => {
|
|
278
|
+
if (constraint.kind !== 'p' && constraint.kind !== 'u') return false;
|
|
279
|
+
const match = /(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)/i.exec(String(constraint.definition || ''));
|
|
280
|
+
if (!match) return false;
|
|
281
|
+
const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
|
|
282
|
+
return columns.length === 1 && columns[0] === column;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The tables saying who is in which team, and as what: a person, a role, and
|
|
288
|
+
* a key to a table of the app's own that is not the person. A table where the
|
|
289
|
+
* person can appear only once (profiles.org_id) is not one: nobody joins a
|
|
290
|
+
* second team there, so there is no one to add.
|
|
291
|
+
*/
|
|
292
|
+
function memberships(tables) {
|
|
293
|
+
const found = [];
|
|
294
|
+
const names = new Set((tables || []).map((table) => table.name));
|
|
295
|
+
for (const table of tables || []) {
|
|
296
|
+
const person = attack.ownerColumn(table);
|
|
297
|
+
// Whoever made the row is not who is in the team. Found on a blind test:
|
|
298
|
+
// workspace_invites (created_by, role, workspace_id) was taken for the
|
|
299
|
+
// members table, and an invitation made nobody a member of anything.
|
|
300
|
+
if (!person || CREATOR.test(person)) continue;
|
|
301
|
+
const role = roleColumnOf(table);
|
|
302
|
+
if (!role) continue;
|
|
303
|
+
const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
|
|
304
|
+
.filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
|
|
305
|
+
if (!keys.length || aloneUnique(table, person)) continue;
|
|
306
|
+
found.push({ table: table, person: person, role: role.column, lowest: role.lowest, keys: keys });
|
|
307
|
+
}
|
|
308
|
+
// A table named for members first: it is the one a team rule asks.
|
|
309
|
+
const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
|
|
310
|
+
return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* For every team table: can a viewer change it?
|
|
315
|
+
*
|
|
316
|
+
* The other fake person joins the first one's team as its lowest role, and
|
|
317
|
+
* every write is tried twice - before joining and after - so only what joining
|
|
318
|
+
* added is counted: a hole open to every customer is already reported above,
|
|
319
|
+
* and a person changing their own rows is the feature. Both rolled back, the
|
|
320
|
+
* joining with them.
|
|
321
|
+
*/
|
|
322
|
+
async function teammate(client, schema, tables, seeded) {
|
|
323
|
+
const findings = [];
|
|
324
|
+
const completed = [];
|
|
325
|
+
const blocked = [];
|
|
326
|
+
const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
|
|
327
|
+
const done = new Set();
|
|
328
|
+
|
|
329
|
+
for (const member of memberships(tables)) {
|
|
330
|
+
if (!shapeFor.has(member.table.name)) continue;
|
|
331
|
+
const at = quote(schema) + '.' + quote(member.table.name);
|
|
332
|
+
let theirs = null;
|
|
333
|
+
try {
|
|
334
|
+
theirs = (await client.query('SELECT * FROM ' + at + ' WHERE ' + quote(member.person) + ' = $1 LIMIT 1',
|
|
335
|
+
[attack.USER_A])).rows[0] || null;
|
|
336
|
+
} catch (err) {
|
|
337
|
+
theirs = null;
|
|
338
|
+
}
|
|
339
|
+
if (!theirs) continue;
|
|
340
|
+
|
|
341
|
+
// Their team, as the first person's own membership names it.
|
|
342
|
+
const overrides = {};
|
|
343
|
+
overrides[member.role] = member.lowest;
|
|
344
|
+
for (const key of member.keys) {
|
|
345
|
+
key.columns.forEach((name) => { if (name !== member.person) overrides[name] = theirs[name]; });
|
|
346
|
+
}
|
|
347
|
+
const parent = member.keys[0].refTable;
|
|
348
|
+
const as = 'as a "' + member.lowest + '" in ' + member.table.name;
|
|
349
|
+
let join = null;
|
|
350
|
+
let refused = null;
|
|
351
|
+
try {
|
|
352
|
+
const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
|
|
353
|
+
join = {
|
|
354
|
+
statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
|
|
355
|
+
row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
|
|
356
|
+
values: row.values,
|
|
357
|
+
};
|
|
358
|
+
// Whether they can be put in the team at all, tried once on its own.
|
|
359
|
+
await client.query('BEGIN');
|
|
360
|
+
try {
|
|
361
|
+
await client.query(join.statement, join.values);
|
|
362
|
+
} finally {
|
|
363
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
refused = err.message;
|
|
367
|
+
}
|
|
368
|
+
if (refused) {
|
|
369
|
+
// Said once, on the table that would not take them: every team table
|
|
370
|
+
// went untested for the same reason.
|
|
371
|
+
blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
|
|
372
|
+
why: as + ': I could not add one: ' + refused });
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const table of tables) {
|
|
377
|
+
if (!shapeFor.has(table.name) || done.has(table.name)) continue;
|
|
378
|
+
done.add(table.name);
|
|
379
|
+
const key = 'role:' + table.name + ':' + member.lowest;
|
|
380
|
+
const owner = attack.ownerColumn(table);
|
|
381
|
+
// Never their own rows: changing what you made yourself is not a role
|
|
382
|
+
// question, and the row that put them in the team is theirs.
|
|
383
|
+
const notTheirs = owner ? ' WHERE ' + quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'" : '';
|
|
384
|
+
const target = quote(schema) + '.' + quote(table.name);
|
|
385
|
+
const column = quote(firstWritable(table));
|
|
386
|
+
const moves = [
|
|
387
|
+
{ what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs },
|
|
388
|
+
{ what: 'delete', statement: 'DELETE FROM ' + target + notTheirs },
|
|
389
|
+
];
|
|
390
|
+
const can = [];
|
|
391
|
+
const changed = {};
|
|
392
|
+
let stuck = null;
|
|
393
|
+
for (const move of moves) {
|
|
394
|
+
const before = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, []);
|
|
395
|
+
const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, [], join);
|
|
396
|
+
const counted = (result) => {
|
|
397
|
+
const outcome = whatHappened(result);
|
|
398
|
+
return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
|
|
399
|
+
};
|
|
400
|
+
const was = counted(before);
|
|
401
|
+
const now = counted(after);
|
|
402
|
+
if (was === null || now === null) {
|
|
403
|
+
stuck = now === null ? after.why : before.why;
|
|
404
|
+
} else if (now > was) {
|
|
405
|
+
can.push(move.what);
|
|
406
|
+
changed[move.what] = now - was;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!can.length && stuck) {
|
|
410
|
+
blocked.push({ table: table.name, key: key, why: as + ': ' + stuck });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
completed.push(key);
|
|
414
|
+
if (can.length) {
|
|
415
|
+
findings.push({
|
|
416
|
+
kind: 'role',
|
|
417
|
+
table: table.name,
|
|
418
|
+
who: member.lowest,
|
|
419
|
+
via: member.table.name,
|
|
420
|
+
roleColumn: member.role,
|
|
421
|
+
parent: parent,
|
|
422
|
+
can: can,
|
|
423
|
+
changed: changed,
|
|
424
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
425
|
+
rlsEnabled: table.rlsEnabled,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { findings: findings, completed: completed, blocked: blocked };
|
|
431
|
+
}
|
|
432
|
+
|
|
220
433
|
module.exports = {
|
|
221
434
|
ACTORS: ACTORS,
|
|
222
435
|
tryWrite: tryWrite,
|
|
@@ -224,4 +437,8 @@ module.exports = {
|
|
|
224
437
|
firstWritable: firstWritable,
|
|
225
438
|
writeAsFor: writeAsFor,
|
|
226
439
|
tamper: tamper,
|
|
440
|
+
lowestRole: lowestRole,
|
|
441
|
+
roleColumnOf: roleColumnOf,
|
|
442
|
+
memberships: memberships,
|
|
443
|
+
teammate: teammate,
|
|
227
444
|
};
|
package/trouble.js
CHANGED
|
@@ -365,7 +365,7 @@ function supabaseDirectRef(raw) {
|
|
|
365
365
|
* survives is the stack: the person cannot use it and it makes the tool look
|
|
366
366
|
* like it broke rather than like it has something to tell them.
|
|
367
367
|
*/
|
|
368
|
-
function explain(err, raw) {
|
|
368
|
+
function explain(err, raw, options) {
|
|
369
369
|
const code = err && err.code ? String(err.code) : '';
|
|
370
370
|
|
|
371
371
|
// Checked before the general ENOTFOUND, because for this one address the
|
|
@@ -448,6 +448,18 @@ function explain(err, raw) {
|
|
|
448
448
|
|
|
449
449
|
// Nothing matched. Say what happened and where, without pretending to know
|
|
450
450
|
// why, and without the stack.
|
|
451
|
+
//
|
|
452
|
+
// After the connection was made, "I could not connect" is untrue - found on
|
|
453
|
+
// a blind test, where it was printed over a copy that could not be built.
|
|
454
|
+
if (options && options.connected) {
|
|
455
|
+
return [
|
|
456
|
+
'I connected, but the check stopped part-way.',
|
|
457
|
+
'',
|
|
458
|
+
'The server said: ' + message,
|
|
459
|
+
'',
|
|
460
|
+
'Nothing was left behind: the temporary copy is deleted whenever a check stops.',
|
|
461
|
+
];
|
|
462
|
+
}
|
|
451
463
|
const lines = [
|
|
452
464
|
'I could not connect to the database.',
|
|
453
465
|
'',
|