kryptheon-night 0.1.38 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/engine.sql +66 -2
- package/finding.js +41 -0
- package/package.json +1 -1
- package/schema.js +26 -2
- package/tamper.js +46 -0
package/engine.sql
CHANGED
|
@@ -393,7 +393,7 @@ END $$;
|
|
|
393
393
|
* and the first RETURN). `helpers` are { name, nullable } - a yes/no about
|
|
394
394
|
* the caller, or a role helper that answers NULL for a stranger.
|
|
395
395
|
*/
|
|
396
|
-
CREATE OR REPLACE FUNCTION __KN__.door_of(body text, helpers jsonb)
|
|
396
|
+
CREATE OR REPLACE FUNCTION __KN__.door_of(body text, helpers jsonb, args text DEFAULT NULL)
|
|
397
397
|
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
|
|
398
398
|
DECLARE
|
|
399
399
|
text_ text := coalesce(body, '');
|
|
@@ -411,6 +411,16 @@ DECLARE
|
|
|
411
411
|
h jsonb;
|
|
412
412
|
asks boolean;
|
|
413
413
|
slips boolean := false;
|
|
414
|
+
-- schema.js namesSomebody: an argument that could point at somebody else's
|
|
415
|
+
-- row. With none, "is anybody signed in" turns away everyone it needs to.
|
|
416
|
+
names_somebody boolean := args IS NULL OR EXISTS (
|
|
417
|
+
SELECT 1 FROM unnest(string_to_array(args, ',')) a(raw),
|
|
418
|
+
LATERAL (SELECT regexp_split_to_array(btrim(regexp_replace(btrim(a.raw), '^(in|inout|variadic)\s+', '', 'i')), '\s+') AS w) p
|
|
419
|
+
WHERE btrim(a.raw) <> '' AND (
|
|
420
|
+
array_to_string(CASE WHEN array_length(p.w, 1) > 1 THEN p.w[2:] ELSE p.w END, ' ')
|
|
421
|
+
~* '\y(uuid|oid|int[248]?|integer|bigint|smallint|numeric|serial|bigserial)\y'
|
|
422
|
+
OR (array_length(p.w, 1) > 1 AND replace(p.w[1], '"', '')
|
|
423
|
+
~* '(^|_)(ids?|uids?|orgs?|organizations?|tenants?|teams?|workspaces?|accounts?|users?|owners?|members?|projects?|customers?|compan(y|ies))$')));
|
|
414
424
|
BEGIN
|
|
415
425
|
IF text_ ~* writes_re THEN
|
|
416
426
|
first_act := least(first_act, length((regexp_split_to_array(text_, writes_re, 'i'))[1]) + 1);
|
|
@@ -440,6 +450,10 @@ BEGIN
|
|
|
440
450
|
IF refuses_on_no AND cond ~* '\yauth\s*\.\s*(uid|jwt)\s*\(' AND cond ~* 'admin|owner|member|role' THEN
|
|
441
451
|
RETURN jsonb_build_object('guarded', true, 'nullSlips', false);
|
|
442
452
|
END IF;
|
|
453
|
+
IF NOT names_somebody AND (cond ~* '^\(?\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+null\s*\)?$'
|
|
454
|
+
OR cond ~* '^not\s*\(\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+not\s+null\s*\)$') THEN
|
|
455
|
+
RETURN jsonb_build_object('guarded', true, 'nullSlips', false);
|
|
456
|
+
END IF;
|
|
443
457
|
END LOOP;
|
|
444
458
|
RETURN jsonb_build_object('guarded', false, 'nullSlips', slips);
|
|
445
459
|
END $$;
|
|
@@ -497,7 +511,7 @@ BEGIN
|
|
|
497
511
|
body := regexp_replace(coalesce(r.def, ''), '--[^\n]*', ' ', 'g');
|
|
498
512
|
conf := replace(replace(replace(coalesce(r.config, ''), '{', ''), '}', ''), '"', '');
|
|
499
513
|
writes := body ~* writes_re;
|
|
500
|
-
door := __KN__.door_of(body, helpers);
|
|
514
|
+
door := __KN__.door_of(body, helpers, coalesce(r.args, ''));
|
|
501
515
|
out_ := out_ || jsonb_build_array(jsonb_build_object(
|
|
502
516
|
'name', r.name,
|
|
503
517
|
'args', coalesce(r.args, ''),
|
|
@@ -3149,6 +3163,11 @@ DECLARE
|
|
|
3149
3163
|
before_all jsonb;
|
|
3150
3164
|
below jsonb;
|
|
3151
3165
|
listed boolean;
|
|
3166
|
+
team_columns text[];
|
|
3167
|
+
move_sets text;
|
|
3168
|
+
move_elsewhere text;
|
|
3169
|
+
moved_ bigint;
|
|
3170
|
+
move_result jsonb;
|
|
3152
3171
|
their_team text;
|
|
3153
3172
|
plain_ bigint;
|
|
3154
3173
|
label_ text;
|
|
@@ -3218,6 +3237,51 @@ BEGIN
|
|
|
3218
3237
|
CONTINUE;
|
|
3219
3238
|
END IF;
|
|
3220
3239
|
|
|
3240
|
+
-- Their own membership row - the one in their own team - moved into the
|
|
3241
|
+
-- first person's team at the role at the top, before they join
|
|
3242
|
+
-- (tamper.js; blind round 16, Meridian: memberships_update_self checked
|
|
3243
|
+
-- only user_id = auth.uid()). One row only.
|
|
3244
|
+
SELECT coalesce(array_agg(k ORDER BY k), ARRAY[]::text[]) INTO team_columns
|
|
3245
|
+
FROM jsonb_object_keys(team) k WHERE team->>k IS NOT NULL;
|
|
3246
|
+
IF member->>'top' IS NOT NULL AND array_length(team_columns, 1) IS NOT NULL THEN
|
|
3247
|
+
finding_key := 'movein:' || (member->'table'->>'name');
|
|
3248
|
+
SELECT string_agg(__KN__.always_quote(k) || ' = ' || quote_literal(team->>k), ', ' ORDER BY k),
|
|
3249
|
+
string_agg(__KN__.always_quote(k) || ' IS DISTINCT FROM ' || quote_literal(team->>k), ' OR ' ORDER BY k)
|
|
3250
|
+
INTO move_sets, move_elsewhere FROM unnest(team_columns) k;
|
|
3251
|
+
move_sets := move_sets || ', ' || __KN__.always_quote(member->>'role') || ' = ' || quote_literal(member->>'top');
|
|
3252
|
+
move_result := __KN__.try_write('authenticated', __KN__.user_b(),
|
|
3253
|
+
'UPDATE ' || at || ' SET ' || move_sets || ' WHERE ctid = (SELECT ctid FROM ' || at || ' WHERE '
|
|
3254
|
+
|| __KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())
|
|
3255
|
+
|| ' AND (' || move_elsewhere || ') LIMIT 1)');
|
|
3256
|
+
moved_ := __KN__.rows_moved(move_result);
|
|
3257
|
+
IF moved_ IS NULL THEN
|
|
3258
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object(
|
|
3259
|
+
'table', member->'table'->>'name', 'key', finding_key,
|
|
3260
|
+
'why', 'moving their own ' || (member->'table'->>'name') || ' row into another ' || parent || ': '
|
|
3261
|
+
|| (move_result->>'why')));
|
|
3262
|
+
ELSE
|
|
3263
|
+
completed := completed || to_jsonb(finding_key);
|
|
3264
|
+
IF moved_ > 0 THEN
|
|
3265
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
3266
|
+
'kind', 'role',
|
|
3267
|
+
'table', member->'table'->>'name',
|
|
3268
|
+
'who', 'anyone signed in',
|
|
3269
|
+
'via', member->'table'->>'name',
|
|
3270
|
+
'roleColumn', member->>'role',
|
|
3271
|
+
'parent', parent,
|
|
3272
|
+
'can', jsonb_build_array('movein'),
|
|
3273
|
+
'changed', jsonb_build_object('movein', moved_),
|
|
3274
|
+
'below', '[]'::jsonb,
|
|
3275
|
+
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(member->'table'->'columns') c), '[]'::jsonb),
|
|
3276
|
+
'rlsEnabled', coalesce((member->'table'->>'rlsEnabled')::boolean, false),
|
|
3277
|
+
'promote', true,
|
|
3278
|
+
'movein', true,
|
|
3279
|
+
'toRole', member->>'top',
|
|
3280
|
+
'moved', to_jsonb(team_columns)));
|
|
3281
|
+
END IF;
|
|
3282
|
+
END IF;
|
|
3283
|
+
END IF;
|
|
3284
|
+
|
|
3221
3285
|
FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
|
|
3222
3286
|
CONTINUE WHEN NOT EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
|
|
3223
3287
|
WHERE s->>'table' = tab->>'name');
|
package/finding.js
CHANGED
|
@@ -383,6 +383,10 @@ function headlineFor(finding) {
|
|
|
383
383
|
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
|
|
384
384
|
finding.table + ' table - and with it their role.';
|
|
385
385
|
}
|
|
386
|
+
if (finding.kind === 'role' && finding.movein) {
|
|
387
|
+
return 'Anyone signed in can make themselves ' + aRole(finding.toRole) + ' of any ' + oneOf(finding.parent) +
|
|
388
|
+
' by moving their own row in your ' + finding.table + ' table into it.';
|
|
389
|
+
}
|
|
386
390
|
if (finding.kind === 'role' && finding.promote) {
|
|
387
391
|
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can make themselves ' + aRole(finding.toRole) +
|
|
388
392
|
' by changing their own row in your ' + finding.table + ' table.';
|
|
@@ -508,6 +512,19 @@ function bodyFor(finding, contents) {
|
|
|
508
512
|
);
|
|
509
513
|
}
|
|
510
514
|
|
|
515
|
+
if (finding.kind === 'role' && finding.movein) {
|
|
516
|
+
// Never the design: nobody was invited, and the team they moved into is
|
|
517
|
+
// somebody else's.
|
|
518
|
+
const place = oneOf(finding.parent);
|
|
519
|
+
return (
|
|
520
|
+
'Signed in as one of my fake people - not a member of the other one\'s ' + place + ', never invited - I ' +
|
|
521
|
+
'changed their own row in ' + finding.via + ': ' + listOf((finding.moved || []).map((c) => finding.via + '.' + c)) +
|
|
522
|
+
' to the other ' + place + ', and ' + finding.via + '.' + finding.roleColumn + ' to "' + finding.toRole +
|
|
523
|
+
'" - on a copy of your app. It went through, and was undone straight away. Anybody who can sign up and has a ' +
|
|
524
|
+
place + ' of their own can do this to every ' + place + ' whose id they learn, and from then on has every right ' +
|
|
525
|
+
aRole(finding.toRole) + ' has there.'
|
|
526
|
+
);
|
|
527
|
+
}
|
|
511
528
|
if (finding.kind === 'role' && finding.promote) {
|
|
512
529
|
// Never the design: a member who can raise their own role has every
|
|
513
530
|
// right the top role has, in every table that trusts the role.
|
|
@@ -955,6 +972,25 @@ function fixPromptFor(finding) {
|
|
|
955
972
|
'',
|
|
956
973
|
'Then check every function that updates "' + finding.via + '" for the same thing.',
|
|
957
974
|
]
|
|
975
|
+
: finding.kind === 'role' && finding.movein
|
|
976
|
+
? [
|
|
977
|
+
'My app has a security problem - anyone signed in can make themselves ' + aRole(finding.toRole) + ' of any ' +
|
|
978
|
+
oneOf(finding.parent) + '.',
|
|
979
|
+
'',
|
|
980
|
+
'In my database, a signed-in user can update their own row in "' + finding.via + '" and change ' +
|
|
981
|
+
listOf((finding.moved || []).map((c) => '"' + c + '"')) + ' to another ' + oneOf(finding.parent) +
|
|
982
|
+
' - one they were never invited to - and "' + finding.roleColumn + '" to "' + finding.toRole + '". The rule only ' +
|
|
983
|
+
'checks that the row is theirs, not what they change in it. This was proved on a copy of the database.',
|
|
984
|
+
'',
|
|
985
|
+
'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: nobody may change ' +
|
|
986
|
+
listOf((finding.moved || []).map((c) => '"' + c + '"')) + ' on an existing row, and a member must never be ' +
|
|
987
|
+
'able to change "' + finding.roleColumn + '" on their own row. Joining happens only by an invitation that ' +
|
|
988
|
+
'somebody who manages that ' + oneOf(finding.parent) + ' made. Let only the roles that manage the team change ' +
|
|
989
|
+
'membership rows, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
|
|
990
|
+
'" for auth.uid() and the row\'s own ' + listOf((finding.moved || []).map((c) => '"' + c + '"')) + '.',
|
|
991
|
+
'',
|
|
992
|
+
'Then check every function that updates "' + finding.via + '" for the same thing.',
|
|
993
|
+
]
|
|
958
994
|
: finding.kind === 'role' && finding.promote
|
|
959
995
|
? [
|
|
960
996
|
'My app has a security problem - a member can make themselves ' + aRole(finding.toRole) + '.',
|
|
@@ -1371,6 +1407,8 @@ function describe(finding) {
|
|
|
1371
1407
|
takeover: finding.takeover,
|
|
1372
1408
|
personColumn: finding.personColumn,
|
|
1373
1409
|
promote: finding.promote,
|
|
1410
|
+
movein: finding.movein,
|
|
1411
|
+
moved: finding.moved,
|
|
1374
1412
|
toRole: finding.toRole,
|
|
1375
1413
|
nullSlips: finding.nullSlips,
|
|
1376
1414
|
noRule: finding.noRule,
|
|
@@ -1400,6 +1438,9 @@ function describe(finding) {
|
|
|
1400
1438
|
: finding.kind === 'role' && finding.takeover
|
|
1401
1439
|
? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
|
|
1402
1440
|
finding.via + '", which I could not touch before joining. It was undone.'
|
|
1441
|
+
: finding.kind === 'role' && finding.movein
|
|
1442
|
+
? 'Signed in, and not a member, I moved my own row of "' + finding.via + '" into the other ' + oneOf(finding.parent) +
|
|
1443
|
+
' with "' + finding.roleColumn + '" set to "' + finding.toRole + '". It was undone.'
|
|
1403
1444
|
: finding.kind === 'role' && finding.promote
|
|
1404
1445
|
? 'As ' + aRole(finding.who) + ' who had just joined, I set "' + finding.roleColumn + '" on my own row of "' +
|
|
1405
1446
|
finding.via + '" to "' + finding.toRole + '". It was undone.'
|
package/package.json
CHANGED
package/schema.js
CHANGED
|
@@ -242,8 +242,29 @@ function guardedAtTheDoor(body, helpers) {
|
|
|
242
242
|
*
|
|
243
243
|
* `helpers` are names (yes/no helpers about the caller) or { name, nullable }
|
|
244
244
|
* (a role helper, which answers NULL for a stranger).
|
|
245
|
+
*
|
|
246
|
+
* "Is anybody signed in at all" - IF auth.uid() IS NULL THEN RAISE - counts
|
|
247
|
+
* only when the function takes nothing that could point at somebody else's
|
|
248
|
+
* row (`args`, as pg_get_function_identity_arguments says them). Found on a
|
|
249
|
+
* blind test (Meridian): bootstrap_organization(p_name text) opened with it,
|
|
250
|
+
* made a new organization owned by the caller, and was reported as "anyone
|
|
251
|
+
* on the internet can call it". A visitor with no account is turned away, and
|
|
252
|
+
* a signed-in one can only make their own. Given an organization's id, the
|
|
253
|
+
* same line would let any stranger who signed up act on that organization,
|
|
254
|
+
* so there it is still no guard.
|
|
245
255
|
*/
|
|
246
|
-
function
|
|
256
|
+
function namesSomebody(args) {
|
|
257
|
+
if (args === undefined || args === null) return true;
|
|
258
|
+
return String(args).split(',').map((a) => a.trim()).filter(Boolean).some((a) => {
|
|
259
|
+
const words = a.replace(/^(in|inout|variadic)\s+/i, '').split(/\s+/);
|
|
260
|
+
const name = words.length > 1 ? words[0].replace(/"/g, '') : '';
|
|
261
|
+
const type = words.slice(words.length > 1 ? 1 : 0).join(' ');
|
|
262
|
+
return /\b(uuid|oid|int[248]?|integer|bigint|smallint|numeric|serial|bigserial)\b/i.test(type) ||
|
|
263
|
+
/(^|_)(ids?|uids?|orgs?|organizations?|tenants?|teams?|workspaces?|accounts?|users?|owners?|members?|projects?|customers?|compan(y|ies))$/i.test(name);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function doorOf(body, helpers, args) {
|
|
247
268
|
const text = String(body || '');
|
|
248
269
|
const writes = /\b(insert\s+into|update\s+(only\s+)?["\w]+|delete\s+from|truncate|merge\s+into)\b/i.exec(text);
|
|
249
270
|
const returns = /\breturn\b/i.exec(text);
|
|
@@ -267,6 +288,9 @@ function doorOf(body, helpers) {
|
|
|
267
288
|
if (refusesOnNo && /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(cond) && /admin|owner|member|role/i.test(cond)) {
|
|
268
289
|
return { guarded: true, nullSlips: false };
|
|
269
290
|
}
|
|
291
|
+
const signedIn = /^\(?\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+null\s*\)?$/i.test(cond) ||
|
|
292
|
+
/^not\s*\(\s*auth\s*\.\s*uid\s*\(\s*\)\s+is\s+not\s+null\s*\)$/i.test(cond);
|
|
293
|
+
if (signedIn && !namesSomebody(args)) return { guarded: true, nullSlips: false };
|
|
270
294
|
if (list.some((h) => h.nullable && asks(cond, h.name))) {
|
|
271
295
|
const refuses = refusesOnNo || /\bnot\s+in\s*\(/i.test(cond) || /<>|!=/.test(cond);
|
|
272
296
|
const nullSafe = /\bcoalesce\s*\(/i.test(cond) || /\bis\s+null\b/i.test(cond) || /\bis\s+not\s+true\s*$/i.test(cond);
|
|
@@ -351,7 +375,7 @@ async function readAnonDefinerFunctions(client, schema, role) {
|
|
|
351
375
|
// org_role was reported beside the functions it guards.
|
|
352
376
|
const aboutCaller = (row.returns_bool === true || row.returns_enum === true) && !writes &&
|
|
353
377
|
/\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(body);
|
|
354
|
-
const door = doorOf(body, helpers);
|
|
378
|
+
const door = doorOf(body, helpers, row.args || '');
|
|
355
379
|
return {
|
|
356
380
|
name: row.name,
|
|
357
381
|
args: row.args || '',
|
package/tamper.js
CHANGED
|
@@ -460,6 +460,52 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
460
460
|
continue;
|
|
461
461
|
}
|
|
462
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
|
+
|
|
463
509
|
for (const table of tables) {
|
|
464
510
|
if (!shapeFor.has(table.name) || done.has(table.name)) continue;
|
|
465
511
|
done.add(table.name);
|