kryptheon-night 0.1.37 → 0.1.38
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 +122 -7
- package/finding.js +137 -9
- package/outside.js +43 -3
- package/package.json +4 -3
- package/recheck.js +3 -0
- package/scan.js +63 -13
- package/schema.js +12 -1
- package/tamper.js +70 -4
package/engine.sql
CHANGED
|
@@ -368,7 +368,17 @@ BEGIN
|
|
|
368
368
|
FROM pg_policies p
|
|
369
369
|
WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
|
|
370
370
|
AND p.cmd IN ('SELECT', 'ALL')
|
|
371
|
-
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
|
|
371
|
+
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
|
|
372
|
+
-- The rules for putting files in and taking them out (schema.js BUCKETS_QUERY).
|
|
373
|
+
'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
374
|
+
'name', p.policyname::text,
|
|
375
|
+
'roles', to_jsonb(p.roles::text[]),
|
|
376
|
+
'qual', p.qual,
|
|
377
|
+
'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
|
|
378
|
+
FROM pg_policies p
|
|
379
|
+
WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
|
|
380
|
+
AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
|
|
381
|
+
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
|
|
372
382
|
) ORDER BY b.id), '[]'::jsonb)
|
|
373
383
|
FROM storage.buckets b $q$ INTO found;
|
|
374
384
|
RETURN found;
|
|
@@ -516,6 +526,7 @@ DECLARE
|
|
|
516
526
|
rest text;
|
|
517
527
|
narrowing jsonb;
|
|
518
528
|
no_rule boolean;
|
|
529
|
+
writers jsonb;
|
|
519
530
|
found jsonb := '[]'::jsonb;
|
|
520
531
|
BEGIN
|
|
521
532
|
FOR b IN SELECT * FROM jsonb_array_elements(coalesce(buckets, '[]'::jsonb)) LOOP
|
|
@@ -531,12 +542,23 @@ BEGIN
|
|
|
531
542
|
narrowing := narrowing || to_jsonb(rule->>'name');
|
|
532
543
|
END IF;
|
|
533
544
|
END LOOP;
|
|
545
|
+
-- Rules for putting files in or taking them out that only signed-in
|
|
546
|
+
-- people pass (scan.js bucketsOf; blind round 15).
|
|
547
|
+
writers := '[]'::jsonb;
|
|
548
|
+
IF jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0 THEN
|
|
549
|
+
SELECT coalesce(jsonb_agg(w->>'name' ORDER BY ord), '[]'::jsonb) INTO writers
|
|
550
|
+
FROM jsonb_array_elements(coalesce(b->'writeRules', '[]'::jsonb)) WITH ORDINALITY AS e(w, ord)
|
|
551
|
+
WHERE (NOT (coalesce(w->'roles', '[]'::jsonb) ? 'anon' OR coalesce(w->'roles', '[]'::jsonb) ? 'public')
|
|
552
|
+
OR (coalesce(w->>'qual', '') || ' ' || coalesce(w->>'with_check', '')) ~* '\mauth\s*\.\s*(uid|jwt)\s*\(')
|
|
553
|
+
AND (coalesce(w->>'qual', '') || ' ' || coalesce(w->>'with_check', '')) ~* '\mfrom\s+';
|
|
554
|
+
END IF;
|
|
534
555
|
no_rule := jsonb_array_length(coalesce(b->'readRules', '[]'::jsonb)) = 0
|
|
535
|
-
AND (b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)'
|
|
556
|
+
AND ((b->>'name') ~* '(^|[-_.])(exports?|private|documents?|docs|invoices?|contracts?|backups?|reports?|receipts?|statements?|payouts?|evidence|kyc|passports?|medical|records?|confidential|internal|secrets?|attachments?)([-_.]|$)'
|
|
557
|
+
OR jsonb_array_length(writers) > 0);
|
|
536
558
|
IF jsonb_array_length(narrowing) > 0 OR no_rule THEN
|
|
537
559
|
found := found || jsonb_build_array(jsonb_build_object(
|
|
538
560
|
'kind', 'bucket', 'table', b->>'name', 'bucket', b->>'id',
|
|
539
|
-
'rules', narrowing, 'noRule', no_rule, 'columns', '[]'::jsonb));
|
|
561
|
+
'rules', narrowing, 'noRule', no_rule, 'writers', writers, 'columns', '[]'::jsonb));
|
|
540
562
|
END IF;
|
|
541
563
|
END LOOP;
|
|
542
564
|
RETURN found;
|
|
@@ -2902,7 +2924,14 @@ BEGIN
|
|
|
2902
2924
|
jsonb_build_array(lowest) || coalesce((
|
|
2903
2925
|
SELECT jsonb_agg(l ORDER BY pos DESC)
|
|
2904
2926
|
FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
|
|
2905
|
-
WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb)
|
|
2927
|
+
WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb),
|
|
2928
|
+
-- The role at the top: an owner if there is one, else the first in charge.
|
|
2929
|
+
'top', coalesce(
|
|
2930
|
+
(SELECT l FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos) WHERE l ~* 'owner' ORDER BY pos LIMIT 1),
|
|
2931
|
+
(SELECT l FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos) WHERE l ~* 'owner|admin|super|root' ORDER BY pos LIMIT 1)),
|
|
2932
|
+
-- Every role in charge, in the order the list gives them.
|
|
2933
|
+
'inCharge', coalesce((SELECT jsonb_agg(l ORDER BY pos) FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
|
|
2934
|
+
WHERE l ~* 'owner|admin|super|root'), '[]'::jsonb));
|
|
2906
2935
|
END IF;
|
|
2907
2936
|
END LOOP;
|
|
2908
2937
|
RETURN NULL;
|
|
@@ -2968,7 +2997,7 @@ BEGIN
|
|
|
2968
2997
|
CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
|
|
2969
2998
|
found := found || jsonb_build_array(jsonb_build_object(
|
|
2970
2999
|
'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
|
|
2971
|
-
'roleType', role_->>'type', 'ladder', role_->'ladder', 'keys', keys));
|
|
3000
|
+
'roleType', role_->>'type', 'ladder', role_->'ladder', 'top', role_->'top', 'inCharge', role_->'inCharge', 'keys', keys));
|
|
2972
3001
|
END LOOP;
|
|
2973
3002
|
-- A table named for members first: it is the one a team rule asks.
|
|
2974
3003
|
SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
|
|
@@ -3047,7 +3076,8 @@ DECLARE
|
|
|
3047
3076
|
'"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
|
|
3048
3077
|
in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
|
|
3049
3078
|
-- A takeover is an UPDATE too (tamper.js COMMAND).
|
|
3050
|
-
command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE'
|
|
3079
|
+
command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE'
|
|
3080
|
+
WHEN 'takeover' THEN 'UPDATE' WHEN 'promote' THEN 'UPDATE' END;
|
|
3051
3081
|
policy jsonb;
|
|
3052
3082
|
body text;
|
|
3053
3083
|
BEGIN
|
|
@@ -3119,6 +3149,9 @@ DECLARE
|
|
|
3119
3149
|
before_all jsonb;
|
|
3120
3150
|
below jsonb;
|
|
3121
3151
|
listed boolean;
|
|
3152
|
+
their_team text;
|
|
3153
|
+
plain_ bigint;
|
|
3154
|
+
label_ text;
|
|
3122
3155
|
BEGIN
|
|
3123
3156
|
FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
|
|
3124
3157
|
SELECT (s->>'attempt')::integer INTO shape
|
|
@@ -3242,6 +3275,17 @@ BEGIN
|
|
|
3242
3275
|
moves := moves || jsonb_build_array(jsonb_build_object('what', 'takeover', 'anyRole', true,
|
|
3243
3276
|
'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'person') || ' = '
|
|
3244
3277
|
|| quote_literal(__KN__.user_b()) || ' WHERE ' || array_to_string(where_, ' AND ')));
|
|
3278
|
+
-- Their own membership row, given the role at the top (tamper.js;
|
|
3279
|
+
-- blind round 15: a viewer set their own role to 'owner').
|
|
3280
|
+
IF member->>'top' IS NOT NULL THEN
|
|
3281
|
+
where_ := ARRAY[__KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())];
|
|
3282
|
+
FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
|
|
3283
|
+
where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
|
|
3284
|
+
END LOOP;
|
|
3285
|
+
moves := moves || jsonb_build_array(jsonb_build_object('what', 'promote', 'anyRole', true,
|
|
3286
|
+
'statement', 'UPDATE ' || target || ' SET ' || __KN__.always_quote(member->>'role') || ' = '
|
|
3287
|
+
|| quote_literal(member->>'top') || ' WHERE ' || array_to_string(where_, ' AND ')));
|
|
3288
|
+
END IF;
|
|
3245
3289
|
END IF;
|
|
3246
3290
|
|
|
3247
3291
|
-- Adding a row to the team, said only of a role whose own name says it
|
|
@@ -3325,12 +3369,59 @@ BEGIN
|
|
|
3325
3369
|
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false))
|
|
3326
3370
|
|| CASE WHEN can ? 'takeover'
|
|
3327
3371
|
THEN jsonb_build_object('takeover', true, 'personColumn', member->>'person')
|
|
3372
|
+
ELSE '{}'::jsonb END
|
|
3373
|
+
|| CASE WHEN can ? 'promote'
|
|
3374
|
+
THEN jsonb_build_object('promote', true, 'toRole', member->>'top')
|
|
3328
3375
|
ELSE '{}'::jsonb END);
|
|
3329
3376
|
EXIT;
|
|
3330
3377
|
END IF;
|
|
3331
3378
|
IF NOT listed THEN below := below || to_jsonb(role_); END IF;
|
|
3332
3379
|
END LOOP;
|
|
3333
3380
|
|
|
3381
|
+
-- In charge of their OWN team, reading the first person's (tamper.js;
|
|
3382
|
+
-- blind round 15: a read rule's role check not tied to the row's
|
|
3383
|
+
-- organization let an admin of any organization read every one). Only
|
|
3384
|
+
-- what the role added counts.
|
|
3385
|
+
their_team := NULL;
|
|
3386
|
+
IF EXISTS (SELECT 1 FROM jsonb_object_keys(pointed)) THEN
|
|
3387
|
+
SELECT string_agg(__KN__.always_quote(k) || ' = ' || quote_literal(pointed->>k), ' AND ' ORDER BY k)
|
|
3388
|
+
INTO their_team FROM jsonb_object_keys(pointed) k;
|
|
3389
|
+
ELSIF owner IS NOT NULL THEN
|
|
3390
|
+
their_team := __KN__.always_quote(owner) || ' = ' || quote_literal(__KN__.user_a());
|
|
3391
|
+
END IF;
|
|
3392
|
+
IF their_team IS NOT NULL AND jsonb_array_length(coalesce(member->'inCharge', '[]'::jsonb)) > 0 THEN
|
|
3393
|
+
look := 'SELECT 1 FROM ' || target || ' WHERE ' || their_team;
|
|
3394
|
+
plain_ := __KN__.rows_moved(__KN__.try_write('authenticated', __KN__.user_b(), look));
|
|
3395
|
+
FOR label_ IN SELECT jsonb_array_elements_text(member->'inCharge') LOOP
|
|
3396
|
+
finding_key := 'elevated:' || (tab->>'name') || ':' || label_;
|
|
3397
|
+
now_ := __KN__.rows_moved(__KN__.try_write_joined('authenticated', __KN__.user_b(), look,
|
|
3398
|
+
'UPDATE ' || __KN__.always_quote(source) || '.' || __KN__.always_quote(member->'table'->>'name') || ' SET '
|
|
3399
|
+
|| __KN__.always_quote(member->>'role') || ' = ' || quote_literal(label_) || ' WHERE '
|
|
3400
|
+
|| __KN__.always_quote(member->>'person') || ' = ' || quote_literal(__KN__.user_b())));
|
|
3401
|
+
IF plain_ IS NULL OR now_ IS NULL THEN
|
|
3402
|
+
blocked := blocked || jsonb_build_array(jsonb_build_object('table', tab->>'name', 'key', finding_key,
|
|
3403
|
+
'why', 'as "' || label_ || '" in their own ' || parent || ' row: reading it failed'));
|
|
3404
|
+
EXIT;
|
|
3405
|
+
END IF;
|
|
3406
|
+
completed := completed || to_jsonb(finding_key);
|
|
3407
|
+
IF now_ > plain_ THEN
|
|
3408
|
+
findings := findings || jsonb_build_array(jsonb_build_object(
|
|
3409
|
+
'kind', 'crossed',
|
|
3410
|
+
'table', tab->>'name',
|
|
3411
|
+
'owner', coalesce(owner, (SELECT k FROM jsonb_object_keys(pointed) k ORDER BY k LIMIT 1)),
|
|
3412
|
+
'tenant', owner IS NULL,
|
|
3413
|
+
'readable', now_ - plain_,
|
|
3414
|
+
'asRole', label_,
|
|
3415
|
+
'via', member->'table'->>'name',
|
|
3416
|
+
'roleColumn', member->>'role',
|
|
3417
|
+
'parent', parent,
|
|
3418
|
+
'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
|
|
3419
|
+
'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
|
|
3420
|
+
EXIT;
|
|
3421
|
+
END IF;
|
|
3422
|
+
END LOOP;
|
|
3423
|
+
END IF;
|
|
3424
|
+
|
|
3334
3425
|
-- Reading what a member should not see, said only of a table holding a
|
|
3335
3426
|
-- secret. Every role not in charge, from the lowest up; the first that
|
|
3336
3427
|
-- can read is the one named.
|
|
@@ -3855,6 +3946,26 @@ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
|
3855
3946
|
AND m.name IS NOT NULL;
|
|
3856
3947
|
$$;
|
|
3857
3948
|
|
|
3949
|
+
/* Tables nobody can add a row to: every permissive rule for adding rows is a
|
|
3950
|
+
bare INSERT rule, with neither USING nor WITH CHECK, which Postgres reads as
|
|
3951
|
+
"no" (scan.js noInsertsOf; blind round 15's tickets_insert). */
|
|
3952
|
+
CREATE OR REPLACE FUNCTION __KN__.no_insert_findings(plan jsonb)
|
|
3953
|
+
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
3954
|
+
WITH rules AS (
|
|
3955
|
+
SELECT p->>'table_name' AS t, p->>'name' AS n,
|
|
3956
|
+
upper(coalesce(p->>'cmd', 'ALL')) = 'INSERT' AND p->>'qual' IS NULL AND p->>'with_check' IS NULL AS bare
|
|
3957
|
+
FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
|
|
3958
|
+
WHERE upper(coalesce(p->>'permissive', 'PERMISSIVE')) = 'PERMISSIVE'
|
|
3959
|
+
AND upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')
|
|
3960
|
+
), per AS (
|
|
3961
|
+
SELECT t, jsonb_agg(n ORDER BY n COLLATE "C") FILTER (WHERE bare) AS names, bool_and(bare) AS all_bare
|
|
3962
|
+
FROM rules GROUP BY t
|
|
3963
|
+
)
|
|
3964
|
+
SELECT coalesce(jsonb_agg(jsonb_build_object('kind', 'noinsert', 'table', t, 'rules', names, 'columns', '[]'::jsonb)
|
|
3965
|
+
ORDER BY t COLLATE "C"), '[]'::jsonb)
|
|
3966
|
+
FROM per WHERE all_bare;
|
|
3967
|
+
$$;
|
|
3968
|
+
|
|
3858
3969
|
/* The ones of those that still take an empty value. */
|
|
3859
3970
|
CREATE OR REPLACE FUNCTION __KN__.empty_choice_findings(plan jsonb)
|
|
3860
3971
|
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
@@ -4131,6 +4242,7 @@ BEGIN
|
|
|
4131
4242
|
|| __KN__.search_path_findings(plan)
|
|
4132
4243
|
|| refused
|
|
4133
4244
|
|| __KN__.empty_choice_findings(plan)
|
|
4245
|
+
|| __KN__.no_insert_findings(plan)
|
|
4134
4246
|
|| __KN__.bucket_findings(plan->'buckets');
|
|
4135
4247
|
|
|
4136
4248
|
-- Everything genuinely attacked, so a re-check can tell a finding that was
|
|
@@ -4149,7 +4261,10 @@ BEGIN
|
|
|
4149
4261
|
|| coalesce((SELECT jsonb_agg('conflict:' || (f->>'name'))
|
|
4150
4262
|
FROM jsonb_array_elements(coalesce(plan->'functions', '[]'::jsonb)) f), '[]'::jsonb)
|
|
4151
4263
|
|| coalesce((SELECT jsonb_agg('nullcheck:' || (x->>'table') || ':' || (x->'column'->>'name'))
|
|
4152
|
-
FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb)
|
|
4264
|
+
FROM jsonb_array_elements(__KN__.one_of_columns(plan)) x), '[]'::jsonb)
|
|
4265
|
+
|| coalesce((SELECT jsonb_agg(DISTINCT 'noinsert:' || (p->>'table_name'))
|
|
4266
|
+
FROM jsonb_array_elements(coalesce(plan->'policies', '[]'::jsonb)) p
|
|
4267
|
+
WHERE upper(coalesce(p->>'cmd', 'ALL')) IN ('INSERT', 'ALL')), '[]'::jsonb);
|
|
4153
4268
|
|
|
4154
4269
|
-- And everything that was not. Said out loud, never left out: a table
|
|
4155
4270
|
-- nothing could be put into reads exactly like a table nothing got out of.
|
package/finding.js
CHANGED
|
@@ -126,12 +126,15 @@ function severityOf(finding, contents) {
|
|
|
126
126
|
// because the app is broken for whoever it fails for, but not a break-in.
|
|
127
127
|
return 'HIGH';
|
|
128
128
|
}
|
|
129
|
+
// A member who can make themselves the owner: never the design, and the
|
|
130
|
+
// owner's reach is everything - the one role finding that is confirmed.
|
|
131
|
+
if (finding.kind === 'role' && finding.promote) return 'CRITICAL';
|
|
129
132
|
if (finding.kind === 'role' || finding.kind === 'teamread' || finding.kind === 'bucket') {
|
|
130
133
|
// Something to check, never a confirmed break: whether a viewer may edit,
|
|
131
134
|
// or a bucket may be public, is the owner's decision. So never CRITICAL.
|
|
132
135
|
return 'HIGH';
|
|
133
136
|
}
|
|
134
|
-
if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck') {
|
|
137
|
+
if (finding.kind === 'conflict' || finding.kind === 'searchpath' || finding.kind === 'nullcheck' || finding.kind === 'noinsert') {
|
|
135
138
|
// Read from how a function or a column is declared. A refused upsert
|
|
136
139
|
// breaks the app for whoever calls it; an open search_path and an empty
|
|
137
140
|
// value are for the owner to weigh. None of them hands a row to anybody.
|
|
@@ -373,10 +376,17 @@ function headlineFor(finding) {
|
|
|
373
376
|
if (finding.kind === 'nullcheck') {
|
|
374
377
|
return 'Your ' + finding.table + ' table accepts rows with no ' + finding.column + ' at all.';
|
|
375
378
|
}
|
|
379
|
+
if (finding.kind === 'noinsert') {
|
|
380
|
+
return 'Nobody can add a row to your ' + finding.table + ' table: its rule for adding rows checks nothing, and Postgres reads that as no.';
|
|
381
|
+
}
|
|
376
382
|
if (finding.kind === 'role' && finding.takeover) {
|
|
377
383
|
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can take another member\'s place in your ' +
|
|
378
384
|
finding.table + ' table - and with it their role.';
|
|
379
385
|
}
|
|
386
|
+
if (finding.kind === 'role' && finding.promote) {
|
|
387
|
+
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can make themselves ' + aRole(finding.toRole) +
|
|
388
|
+
' by changing their own row in your ' + finding.table + ' table.';
|
|
389
|
+
}
|
|
380
390
|
if (finding.kind === 'role') {
|
|
381
391
|
const order = ['delete', 'change', 'add'];
|
|
382
392
|
const does = order.filter((what) => (finding.can || []).includes(what)).map((what) => WRITE_WORDS[what]);
|
|
@@ -387,6 +397,9 @@ function headlineFor(finding) {
|
|
|
387
397
|
return capital(aRole(finding.who)) + ' in ' + aOne(finding.parent) + ' can read your ' + finding.table +
|
|
388
398
|
' table, which holds ' + listOf(finding.secrets || []) + '.';
|
|
389
399
|
}
|
|
400
|
+
if (finding.kind === 'bucket' && finding.noRule && (finding.writers || []).length) {
|
|
401
|
+
return 'Your ' + finding.table + ' storage bucket is public, though only signed-in members may put files in it.';
|
|
402
|
+
}
|
|
390
403
|
if (finding.kind === 'bucket' && finding.noRule) {
|
|
391
404
|
return 'Your ' + finding.table + ' storage bucket is public, and its name says its files are not for everyone.';
|
|
392
405
|
}
|
|
@@ -399,6 +412,10 @@ function headlineFor(finding) {
|
|
|
399
412
|
// The table name goes in front of the sentence rather than inside it. Put
|
|
400
413
|
// inside, a table called `customers` produced "One customer can read another
|
|
401
414
|
// customer's customers", which is the kind of line that loses a reader.
|
|
415
|
+
if (finding.asRole) {
|
|
416
|
+
return capital(aRole(finding.asRole)) + ' of any ' + oneOf(finding.parent) + ' can read every other ' + oneOf(finding.parent) +
|
|
417
|
+
'\'s rows in your ' + finding.table + ' table.';
|
|
418
|
+
}
|
|
402
419
|
if (finding.tenant) return 'Your ' + finding.table + ' table lets a signed-in user read another organization\'s rows.';
|
|
403
420
|
return 'Your ' + finding.table + ' table lets one customer read another one\'s rows.';
|
|
404
421
|
}
|
|
@@ -491,6 +508,19 @@ function bodyFor(finding, contents) {
|
|
|
491
508
|
);
|
|
492
509
|
}
|
|
493
510
|
|
|
511
|
+
if (finding.kind === 'role' && finding.promote) {
|
|
512
|
+
// Never the design: a member who can raise their own role has every
|
|
513
|
+
// right the top role has, in every table that trusts the role.
|
|
514
|
+
const place = oneOf(finding.parent);
|
|
515
|
+
return (
|
|
516
|
+
'I put one of my fake people into the other one\'s ' + place + ' as ' + aRole(finding.who) +
|
|
517
|
+
' in ' + finding.via + '.' + finding.roleColumn + ' and, signed in as them, changed ' + finding.via + '.' +
|
|
518
|
+
finding.roleColumn + ' on their own membership row to "' + finding.toRole + '" - on a copy of your app. It was ' +
|
|
519
|
+
'undone straight away. From then on they would have every right ' + aRole(finding.toRole) + ' has, in every ' +
|
|
520
|
+
'table and function that trusts that role.'
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
494
524
|
if (finding.kind === 'role') {
|
|
495
525
|
// Said as what was done, and then whose decision it is: a viewer who may
|
|
496
526
|
// edit is a hole in one app and the design of another.
|
|
@@ -535,8 +565,13 @@ function bodyFor(finding, contents) {
|
|
|
535
565
|
return (
|
|
536
566
|
'In Supabase Storage the bucket "' + finding.bucket + '" is set to public, so anyone with a ' +
|
|
537
567
|
'file\'s link can download it without logging in - the rules on storage.objects are not asked. ' +
|
|
538
|
-
|
|
539
|
-
|
|
568
|
+
((finding.writers || []).length
|
|
569
|
+
? 'No rule says who may read it, but ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
|
|
570
|
+
' let only signed-in members put files in or take them out - so the app treats these files as its ' +
|
|
571
|
+
'members\' own, while anyone holding a link can download them. '
|
|
572
|
+
: 'No rule says who may read it, but its name says what it holds, and that does not sound like ' +
|
|
573
|
+
'something for everyone. ') +
|
|
574
|
+
'I only read the bucket\'s setting and its rules; I did not open or list ' +
|
|
540
575
|
'any file. Whether it should be public is yours to say.'
|
|
541
576
|
);
|
|
542
577
|
}
|
|
@@ -587,6 +622,17 @@ function bodyFor(finding, contents) {
|
|
|
587
622
|
'anything; this is read from how the column is declared.'
|
|
588
623
|
);
|
|
589
624
|
}
|
|
625
|
+
if (finding.kind === 'noinsert') {
|
|
626
|
+
return (
|
|
627
|
+
'Row level security is on for ' + finding.table + ', and the only ' + ((finding.rules || []).length === 1 ? 'rule' : 'rules') +
|
|
628
|
+
' for adding rows (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) + ') ' +
|
|
629
|
+
((finding.rules || []).length === 1 ? 'has' : 'have') + ' neither USING nor WITH CHECK. A rule with no ' +
|
|
630
|
+
'expression is not "allow everything": Postgres treats it as false, so every INSERT - from the app, ' +
|
|
631
|
+
'and from any function that runs as the user - is refused with "new row violates row-level security ' +
|
|
632
|
+
'policy". If your app adds ' + finding.table + ' rows, that feature is broken for everyone. This is read ' +
|
|
633
|
+
'from how the rule is written; I did not insert anything.'
|
|
634
|
+
);
|
|
635
|
+
}
|
|
590
636
|
if (finding.kind === 'privileged') {
|
|
591
637
|
// Said as what is true, not as what was done - nothing was executed. The
|
|
592
638
|
// reach is real; whether it is a mistake is the person's to say, so the
|
|
@@ -644,6 +690,17 @@ function bodyFor(finding, contents) {
|
|
|
644
690
|
(holds ? ', including ' + holds : '') + '.'
|
|
645
691
|
);
|
|
646
692
|
}
|
|
693
|
+
if (finding.asRole) {
|
|
694
|
+
// Blind round 15: a role check in a read rule that was never tied to the
|
|
695
|
+
// row's own organization.
|
|
696
|
+
return (
|
|
697
|
+
'I made one of my fake people ' + aRole(finding.asRole) + ' of their own ' + oneOf(finding.parent) + ' (' + finding.via + '.' +
|
|
698
|
+
finding.roleColumn + ') and, signed in as them, asked for the other ' + oneOf(finding.parent) + '\'s rows in ' + finding.table +
|
|
699
|
+
' and got ' + finding.readable + ' back' + (holds ? ', including ' + holds : '') + '. With an ordinary role they got none. ' +
|
|
700
|
+
'So anybody who is ' + aRole(finding.asRole) + ' anywhere - which in most apps means anybody who creates their own ' +
|
|
701
|
+
oneOf(finding.parent) + ' - can read every ' + oneOf(finding.parent) + '\'s rows here.'
|
|
702
|
+
);
|
|
703
|
+
}
|
|
647
704
|
if (finding.tenant) {
|
|
648
705
|
return (
|
|
649
706
|
'Signed in as someone in a different organization, I asked for another organization\'s rows and got ' +
|
|
@@ -898,6 +955,23 @@ function fixPromptFor(finding) {
|
|
|
898
955
|
'',
|
|
899
956
|
'Then check every function that updates "' + finding.via + '" for the same thing.',
|
|
900
957
|
]
|
|
958
|
+
: finding.kind === 'role' && finding.promote
|
|
959
|
+
? [
|
|
960
|
+
'My app has a security problem - a member can make themselves ' + aRole(finding.toRole) + '.',
|
|
961
|
+
'',
|
|
962
|
+
'In my database, a member of ' + aOne(finding.parent) + ' whose role in "' + finding.via + '"."' +
|
|
963
|
+
finding.roleColumn + '" is "' + finding.who + '" can update their own row in "' + finding.via + '" and set "' +
|
|
964
|
+
finding.roleColumn + '" to "' + finding.toRole + '". The rule only checks that the row is theirs, not what ' +
|
|
965
|
+
'they change in it. This was proved on a copy of the database.',
|
|
966
|
+
'',
|
|
967
|
+
'Fix the rules on "' + finding.via + '" that cover UPDATE or ALL: a member must never be able to change "' +
|
|
968
|
+
finding.roleColumn + '" on their own row. Let only the roles that manage the team (usually owner and admin) ' +
|
|
969
|
+
'change roles, checked in WITH CHECK as well as USING - for example with a helper that reads "' + finding.via +
|
|
970
|
+
'" for auth.uid() - and make sure nobody can raise anyone above their own role. If members need to edit ' +
|
|
971
|
+
'something else of their own, allow exactly those columns and not "' + finding.roleColumn + '".',
|
|
972
|
+
'',
|
|
973
|
+
'Then check every function that updates "' + finding.via + '" for the same thing.',
|
|
974
|
+
]
|
|
901
975
|
: finding.kind === 'role'
|
|
902
976
|
? [
|
|
903
977
|
'My app may have a permissions problem - please check it rather than assume it.',
|
|
@@ -943,7 +1017,11 @@ function fixPromptFor(finding) {
|
|
|
943
1017
|
'My app may have a storage problem - please check it rather than assume it.',
|
|
944
1018
|
'',
|
|
945
1019
|
'The Supabase Storage bucket "' + finding.bucket + '" is public. Anyone with a file\'s URL can ' +
|
|
946
|
-
'download it without logging in' + (finding.noRule
|
|
1020
|
+
'download it without logging in' + (finding.noRule && (finding.writers || []).length
|
|
1021
|
+
? ', and no rule on storage.objects says who may read it - though ' +
|
|
1022
|
+
listOf((finding.writers || []).map((r) => '"' + r + '"')) + ' let only signed-in members upload or delete ' +
|
|
1023
|
+
'its files, which suggests they are not meant for everyone.'
|
|
1024
|
+
: finding.noRule
|
|
947
1025
|
? ', and no rule on storage.objects says who may read it - its name suggests its files are not ' +
|
|
948
1026
|
'meant for everyone.'
|
|
949
1027
|
: ', and the storage.objects ' +
|
|
@@ -1019,6 +1097,24 @@ function fixPromptFor(finding) {
|
|
|
1019
1097
|
'the right value is), then ALTER TABLE "' + finding.table + '" ALTER COLUMN "' + finding.column +
|
|
1020
1098
|
'" SET NOT NULL. Change nothing else.',
|
|
1021
1099
|
]
|
|
1100
|
+
: finding.kind === 'noinsert'
|
|
1101
|
+
? [
|
|
1102
|
+
'My app may have a bug - please check it rather than assume it.',
|
|
1103
|
+
'',
|
|
1104
|
+
'In my database, the rule' + ((finding.rules || []).length === 1 ? ' ' : 's ') +
|
|
1105
|
+
listOf((finding.rules || []).map((r) => '"' + r + '"')) + ' on "' + finding.table + '" for INSERT ' +
|
|
1106
|
+
((finding.rules || []).length === 1 ? 'has' : 'have') + ' no WITH CHECK, and there is no other rule that ' +
|
|
1107
|
+
'allows adding rows. Postgres treats a missing check as false, so nobody can add a row to "' + finding.table +
|
|
1108
|
+
'" - not even through a function that runs as the user.',
|
|
1109
|
+
'',
|
|
1110
|
+
'First tell me: is adding rows to "' + finding.table + '" meant to work for signed-in users? If only your ' +
|
|
1111
|
+
'server adds them, change nothing.',
|
|
1112
|
+
'',
|
|
1113
|
+
'If users should be able to add them: give the rule a WITH CHECK that says who may - for example that ' +
|
|
1114
|
+
'the user is a member of the row\'s organization (through your membership table or a helper) and that ' +
|
|
1115
|
+
'the column naming who created it equals auth.uid(). Never use WITH CHECK (true): that lets anyone add ' +
|
|
1116
|
+
'rows anywhere. Then add one row through the app to make sure it works.',
|
|
1117
|
+
]
|
|
1022
1118
|
: finding.kind === 'privileged'
|
|
1023
1119
|
? [
|
|
1024
1120
|
'My app may have a security problem - please check it rather than assume it.',
|
|
@@ -1166,7 +1262,23 @@ function fixPromptFor(finding) {
|
|
|
1166
1262
|
'Then look for the same missing constraint on every other table and fix those too.',
|
|
1167
1263
|
],
|
|
1168
1264
|
)
|
|
1169
|
-
: finding.
|
|
1265
|
+
: finding.asRole
|
|
1266
|
+
? [
|
|
1267
|
+
'My app has a security problem.',
|
|
1268
|
+
'',
|
|
1269
|
+
'In "' + finding.table + '", anyone who is "' + finding.asRole + '" in their own ' + oneOf(finding.parent) + ' can read the rows ' +
|
|
1270
|
+
'of every ' + oneOf(finding.parent) + '. I proved it on a copy: as "' + finding.asRole + '" of one ' + oneOf(finding.parent) +
|
|
1271
|
+
', I read ' + finding.readable + ' row(s) of another.',
|
|
1272
|
+
'',
|
|
1273
|
+
'A rule on "' + finding.table + '" checks the caller\'s role without tying it to the row - for example ' +
|
|
1274
|
+
'exists (select 1 from ' + finding.via + ' m where m.' + finding.roleColumn + ' = \'' + finding.asRole + '\') with no ' +
|
|
1275
|
+
'condition that m is the signed-in user\'s membership of THIS row\'s ' + oneOf(finding.parent) + '. Fix every rule on "' +
|
|
1276
|
+
finding.table + '": a role check must say m.user_id = auth.uid() AND m.<' + oneOf(finding.parent) + ' id> = the row\'s ' +
|
|
1277
|
+
oneOf(finding.parent) + ' id - or call a helper that does. Keep membership as the way in; do not replace it with USING (true).',
|
|
1278
|
+
'',
|
|
1279
|
+
'Then check every other rule and helper that checks a role for the same missing link.',
|
|
1280
|
+
]
|
|
1281
|
+
: finding.isView
|
|
1170
1282
|
? [
|
|
1171
1283
|
'My app has a security problem.',
|
|
1172
1284
|
'',
|
|
@@ -1209,7 +1321,7 @@ function fixPromptFor(finding) {
|
|
|
1209
1321
|
|
|
1210
1322
|
// Found and said, but never claimed as a break: the owner decides. Counted
|
|
1211
1323
|
// apart in the report, never towards the exit code or a clean re-check.
|
|
1212
|
-
const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck'];
|
|
1324
|
+
const TO_CHECK = ['privileged', 'role', 'teamread', 'bucket', 'searchpath', 'nullcheck', 'noinsert'];
|
|
1213
1325
|
// Functions, said by their name rather than a table's.
|
|
1214
1326
|
const ON_A_FUNCTION = ['privileged', 'searchpath', 'conflict'];
|
|
1215
1327
|
|
|
@@ -1223,7 +1335,7 @@ function describe(finding) {
|
|
|
1223
1335
|
// one the tool reasons about without executing, so it says so. The report
|
|
1224
1336
|
// and the re-check both read this: a "verification required" finding is
|
|
1225
1337
|
// never counted towards a clean re-check on its own.
|
|
1226
|
-
status: TO_CHECK.includes(finding.kind) ? 'verification required' : 'confirmed',
|
|
1338
|
+
status: TO_CHECK.includes(finding.kind) && !(finding.kind === 'role' && finding.promote) ? 'verification required' : 'confirmed',
|
|
1227
1339
|
table: ON_A_FUNCTION.includes(finding.kind) ? finding.fn : finding.table,
|
|
1228
1340
|
fn: finding.fn,
|
|
1229
1341
|
args: finding.args,
|
|
@@ -1250,14 +1362,19 @@ function describe(finding) {
|
|
|
1250
1362
|
readable: finding.readable,
|
|
1251
1363
|
secrets: finding.secrets,
|
|
1252
1364
|
bucket: finding.bucket,
|
|
1365
|
+
rules: finding.rules,
|
|
1253
1366
|
// An organization's rows read from outside it, and a member taking over
|
|
1254
1367
|
// another member's row: kept so the re-check and the read-back word them
|
|
1255
1368
|
// the same way the report did.
|
|
1256
1369
|
tenant: finding.tenant,
|
|
1370
|
+
asRole: finding.asRole,
|
|
1257
1371
|
takeover: finding.takeover,
|
|
1258
1372
|
personColumn: finding.personColumn,
|
|
1373
|
+
promote: finding.promote,
|
|
1374
|
+
toRole: finding.toRole,
|
|
1259
1375
|
nullSlips: finding.nullSlips,
|
|
1260
1376
|
noRule: finding.noRule,
|
|
1377
|
+
writers: finding.writers,
|
|
1261
1378
|
// Read from how a function or column is declared (scan.js searchPathsOf,
|
|
1262
1379
|
// conflictsIn, emptyChoicesOf); kept so the read-back says it the same way.
|
|
1263
1380
|
target: finding.target,
|
|
@@ -1272,6 +1389,9 @@ function describe(finding) {
|
|
|
1272
1389
|
') on the copy, and it refused. I did not call the function.'
|
|
1273
1390
|
: finding.kind === 'nullcheck'
|
|
1274
1391
|
? 'I read that "' + finding.table + '.' + finding.column + '" may be NULL and has no default. I did not insert anything.'
|
|
1392
|
+
: finding.kind === 'noinsert'
|
|
1393
|
+
? 'I read that every rule for adding rows to "' + finding.table + '" (' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
|
1394
|
+
') has neither USING nor WITH CHECK. I did not insert anything.'
|
|
1275
1395
|
: finding.kind === 'recursive'
|
|
1276
1396
|
? 'Using ' + listOf((finding.reads || []).map((t) => '"' + t + '"')) + ' stopped with ' +
|
|
1277
1397
|
loopError(finding) + '.'
|
|
@@ -1280,6 +1400,12 @@ function describe(finding) {
|
|
|
1280
1400
|
: finding.kind === 'role' && finding.takeover
|
|
1281
1401
|
? 'As ' + aRole(finding.who) + ' who had just joined, I put my own id on the other person\'s row of "' +
|
|
1282
1402
|
finding.via + '", which I could not touch before joining. It was undone.'
|
|
1403
|
+
: finding.kind === 'role' && finding.promote
|
|
1404
|
+
? 'As ' + aRole(finding.who) + ' who had just joined, I set "' + finding.roleColumn + '" on my own row of "' +
|
|
1405
|
+
finding.via + '" to "' + finding.toRole + '". It was undone.'
|
|
1406
|
+
: finding.kind === 'crossed' && finding.asRole
|
|
1407
|
+
? 'As "' + finding.asRole + '" of my own ' + oneOf(finding.parent) + ', I read ' + finding.readable + ' row(s) of "' +
|
|
1408
|
+
finding.table + '" belonging to another; with an ordinary role, none. My role was put back.'
|
|
1283
1409
|
: finding.kind === 'role'
|
|
1284
1410
|
? 'As ' + aRole(finding.who) + ' who had just joined, I ' +
|
|
1285
1411
|
listOf((finding.can || []).map((what) => {
|
|
@@ -1290,7 +1416,9 @@ function describe(finding) {
|
|
|
1290
1416
|
? 'As ' + aRole(finding.who) + ' who had just joined, I read ' + (finding.readable || 1) +
|
|
1291
1417
|
((finding.readable || 1) === 1 ? ' row' : ' rows') + ' of "' + finding.table + '" that I could not read before joining.'
|
|
1292
1418
|
: finding.kind === 'bucket' && finding.noRule
|
|
1293
|
-
? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it
|
|
1419
|
+
? 'I read that "' + finding.bucket + '" is public and that no rule says who may read it' +
|
|
1420
|
+
((finding.writers || []).length ? ', while ' + listOf((finding.writers || []).map((r) => '"' + r + '"')) +
|
|
1421
|
+
' limit uploads and deletes to signed-in members' : '') + '. I did not open any file.'
|
|
1294
1422
|
: finding.kind === 'bucket'
|
|
1295
1423
|
? 'I read that "' + finding.bucket + '" is public and that ' + listOf((finding.rules || []).map((r) => '"' + r + '"')) +
|
|
1296
1424
|
' says who may read it. I did not open any file.'
|
|
@@ -1398,7 +1526,7 @@ function describeAll(findings) {
|
|
|
1398
1526
|
// Writes above reads: a table somebody emptied is worse than one they read.
|
|
1399
1527
|
// A looping rule sits with the reads: it breaks the app for everyone it
|
|
1400
1528
|
// fails for, which matters more than a duplicate or a stray row.
|
|
1401
|
-
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, nullcheck: 6 };
|
|
1529
|
+
const byKind = { writable: 0, exposed: 1, crossed: 2, recursive: 2.5, conflict: 2.6, duplicated: 3, orphaned: 4, role: 4.5, teamread: 4.6, bucket: 4.7, privileged: 5, searchpath: 5.5, noinsert: 5.7, nullcheck: 6 };
|
|
1402
1530
|
const rank = (d) => (d.severity === 'CRITICAL' ? 0 : 1) * 10 + (byKind[d.kind] === undefined ? 9 : byKind[d.kind]);
|
|
1403
1531
|
return described.sort((a, b) => rank(a) - rank(b));
|
|
1404
1532
|
}
|
package/outside.js
CHANGED
|
@@ -79,12 +79,26 @@ async function discover(input, options) {
|
|
|
79
79
|
if (!appUrl) throw problem('no-app', 'I need the address of your running app, e.g. https://your-app.example.com');
|
|
80
80
|
|
|
81
81
|
const html = await getText(doFetch, appUrl);
|
|
82
|
-
const scripts = scriptUrls(html, appUrl);
|
|
83
82
|
// The HTML itself sometimes carries the values (server-rendered apps do), so
|
|
84
83
|
// it is searched too, before the bundles.
|
|
85
84
|
let text = html;
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// The scripts the page loads, and the app's own modules those import - a
|
|
86
|
+
// page with no build step keeps its address and key in a config.js that
|
|
87
|
+
// app.js imports. Found on blind round 15: src/app.js did
|
|
88
|
+
// `import { SUPABASE_URL, SUPABASE_ANON_KEY } from "./config.js"`, and the
|
|
89
|
+
// URL mode said there was no Supabase here. Only the app's own address is
|
|
90
|
+
// followed, never a CDN, and only so far.
|
|
91
|
+
const origin = new URL(appUrl).origin;
|
|
92
|
+
const queue = scriptUrls(html, appUrl);
|
|
93
|
+
const seen = new Set(queue);
|
|
94
|
+
for (let i = 0; i < queue.length && i < MAX_SCRIPTS; i++) {
|
|
95
|
+
const body = await getText(doFetch, queue[i]).catch(() => '');
|
|
96
|
+
text += '\n' + body;
|
|
97
|
+
for (const next of importUrls(body, queue[i])) {
|
|
98
|
+
if (seen.has(next) || new URL(next).origin !== origin) continue;
|
|
99
|
+
seen.add(next);
|
|
100
|
+
queue.push(next);
|
|
101
|
+
}
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
const supabaseUrl = firstMatch(text, /https?:\/\/[a-z0-9-]+\.supabase\.co/i);
|
|
@@ -242,6 +256,31 @@ function scriptUrls(html, appUrl) {
|
|
|
242
256
|
return dedupe(out);
|
|
243
257
|
}
|
|
244
258
|
|
|
259
|
+
// How many scripts a page and its own imports may add up to. A built app is a
|
|
260
|
+
// handful; this stops a page that imports itself in circles, or a huge
|
|
261
|
+
// unbundled tree, from being read without end.
|
|
262
|
+
const MAX_SCRIPTS = 40;
|
|
263
|
+
|
|
264
|
+
// The modules a script imports by a path - import ... from "./x.js",
|
|
265
|
+
// export ... from "./x.js", import("./x.js") - made absolute against the
|
|
266
|
+
// script's own address. A bare name ("react") is resolved by a bundler or an
|
|
267
|
+
// import map, not by a path, so it is not one.
|
|
268
|
+
function importUrls(source, scriptUrl) {
|
|
269
|
+
const out = [];
|
|
270
|
+
const re = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)["']([^"']+)["']/g;
|
|
271
|
+
let m;
|
|
272
|
+
while ((m = re.exec(String(source || '')))) {
|
|
273
|
+
const spec = m[1];
|
|
274
|
+
if (!/^(\.{1,2}\/|\/|https?:\/\/)/.test(spec)) continue;
|
|
275
|
+
try {
|
|
276
|
+
out.push(new URL(spec, scriptUrl).href);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
/* not a path we can follow */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return dedupe(out);
|
|
282
|
+
}
|
|
283
|
+
|
|
245
284
|
function firstMatch(text, re) {
|
|
246
285
|
const m = String(text).match(re);
|
|
247
286
|
return m ? m[0] : null;
|
|
@@ -403,6 +442,7 @@ module.exports = {
|
|
|
403
442
|
prowl: prowl,
|
|
404
443
|
// exported for the checks
|
|
405
444
|
scriptUrls: scriptUrls,
|
|
445
|
+
importUrls: importUrls,
|
|
406
446
|
tableNames: tableNames,
|
|
407
447
|
anonJwt: anonJwt,
|
|
408
448
|
rangeCount: rangeCount,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
4
4
|
"description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18.0.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node checkfit.check.js && node declared.check.js && node identity.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node identity.check.js",
|
|
35
35
|
"check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
|
|
36
36
|
"check:package": "node package.check.js",
|
|
37
37
|
"check:store": "node store.check.js",
|
|
@@ -79,7 +79,8 @@
|
|
|
79
79
|
"check:tenants": "node tenants.check.js",
|
|
80
80
|
"check:takeover": "node takeover.check.js",
|
|
81
81
|
"check:checkfit": "node checkfit.check.js",
|
|
82
|
-
"check:identity": "node identity.check.js"
|
|
82
|
+
"check:identity": "node identity.check.js",
|
|
83
|
+
"check:elevated": "node elevated.check.js"
|
|
83
84
|
},
|
|
84
85
|
"keywords": [
|
|
85
86
|
"supabase",
|
package/recheck.js
CHANGED
|
@@ -232,6 +232,7 @@ function whatOf(item) {
|
|
|
232
232
|
if (item.kind === 'searchpath') return 'full rights, search_path not pinned';
|
|
233
233
|
if (item.kind === 'conflict') return 'an upsert Postgres refuses';
|
|
234
234
|
if (item.kind === 'nullcheck') return 'a ' + (item.column || 'value') + ' that can be left empty';
|
|
235
|
+
if (item.kind === 'noinsert') return 'no row can be added';
|
|
235
236
|
return item.kind;
|
|
236
237
|
}
|
|
237
238
|
|
|
@@ -315,6 +316,8 @@ function describe(result) {
|
|
|
315
316
|
: item.kind === 'conflict'
|
|
316
317
|
? (elsewhere ? 'I asked Postgres to plan it here, and it accepted it.'
|
|
317
318
|
: 'I asked Postgres to plan it again, and it accepted it.')
|
|
319
|
+
: item.kind === 'noinsert'
|
|
320
|
+
? (elsewhere ? 'I looked here, and a rule for adding rows checks something.' : 'I looked again, and a rule for adding rows now checks something.')
|
|
318
321
|
: item.kind === 'nullcheck'
|
|
319
322
|
? (elsewhere ? 'I looked here, and it cannot be left empty.' : 'I looked again, and it can no longer be left empty.')
|
|
320
323
|
: item.kind === 'recursive'
|
package/scan.js
CHANGED
|
@@ -419,7 +419,11 @@ async function scan(client, sourceSchema, options) {
|
|
|
419
419
|
for (const fn of plan.anonFunctions || []) attempted.push('searchpath:' + fn.name);
|
|
420
420
|
for (const fn of plan.functions || []) attempted.push('conflict:' + fn.name);
|
|
421
421
|
for (const x of oneOfColumns(plan)) attempted.push('nullcheck:' + x.table + ':' + x.column.name);
|
|
422
|
-
|
|
422
|
+
// Only a table that has a rule for adding rows was looked at for this.
|
|
423
|
+
for (const name of new Set((plan.policies || []).filter((p) => /^(INSERT|ALL)$/i.test(String(p.cmd || 'ALL'))).map((p) => p.table_name))) {
|
|
424
|
+
attempted.push('noinsert:' + name);
|
|
425
|
+
}
|
|
426
|
+
const declared = searchPathsOf(plan).concat(refused, emptyChoicesOf(plan), noInsertsOf(plan));
|
|
423
427
|
|
|
424
428
|
// Storage buckets switched to public that also have a rule saying who may
|
|
425
429
|
// read them. Read from the bucket settings and the rules, never a file.
|
|
@@ -573,6 +577,34 @@ const ONE_OF = /^CHECK \(+"?([A-Za-z_][\w$]*)"?\)?(?:::[\w ]+)? = ANY \(/;
|
|
|
573
577
|
* fills the column: no default, not generated, and a CHECK that does not
|
|
574
578
|
* mention NULL itself (a CHECK that does has decided the question).
|
|
575
579
|
*/
|
|
580
|
+
/**
|
|
581
|
+
* Tables nobody can add a row to: every rule for adding rows is an INSERT
|
|
582
|
+
* rule written with neither USING nor WITH CHECK, which Postgres reads as
|
|
583
|
+
* "no" - measured on PostgreSQL 18, a bare `create policy p on t for insert
|
|
584
|
+
* to authenticated;` refuses every insert. A rule for ALL, or one with a
|
|
585
|
+
* check, is a way in, and the table is left alone. Mirrors no_insert_findings
|
|
586
|
+
* in engine.sql.
|
|
587
|
+
*
|
|
588
|
+
* Found on blind round 15 (Northstar Ops on Supabase): tickets_insert was
|
|
589
|
+
* written that way, so create_ticket failed for every user - the app's main
|
|
590
|
+
* feature - and nothing in the report said so.
|
|
591
|
+
*/
|
|
592
|
+
function noInsertsOf(plan) {
|
|
593
|
+
const byTable = new Map();
|
|
594
|
+
for (const p of (plan && plan.policies) || []) {
|
|
595
|
+
if (String(p.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
|
|
596
|
+
const cmd = String(p.cmd || 'ALL').toUpperCase();
|
|
597
|
+
if (cmd !== 'INSERT' && cmd !== 'ALL') continue;
|
|
598
|
+
if (!byTable.has(p.table_name)) byTable.set(p.table_name, { bare: [], open: false });
|
|
599
|
+
const entry = byTable.get(p.table_name);
|
|
600
|
+
if (cmd === 'INSERT' && p.qual == null && p.with_check == null) entry.bare.push(p.name);
|
|
601
|
+
else entry.open = true;
|
|
602
|
+
}
|
|
603
|
+
return Array.from(byTable.keys()).sort()
|
|
604
|
+
.filter((table) => byTable.get(table).bare.length && !byTable.get(table).open)
|
|
605
|
+
.map((table) => ({ kind: 'noinsert', table: table, rules: byTable.get(table).bare.slice().sort(), columns: [] }));
|
|
606
|
+
}
|
|
607
|
+
|
|
576
608
|
function emptyChoicesOf(plan) {
|
|
577
609
|
return oneOfColumns(plan)
|
|
578
610
|
.filter((x) => !x.column.not_null && x.column.default_expr == null && !x.column.generated)
|
|
@@ -773,18 +805,35 @@ function bucketsOf(plan) {
|
|
|
773
805
|
.replace(/\btrue\b|\band\b|[()\s]/gi, '');
|
|
774
806
|
return !toEveryone || rest !== '';
|
|
775
807
|
};
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
808
|
+
// A rule for putting files in or taking them out that only signed-in people
|
|
809
|
+
// pass AND that looks a file up in the app's own data (a subquery over a
|
|
810
|
+
// table: an attachments row, a membership). That is files the app treats as
|
|
811
|
+
// its members' records. A rule that only asks whose folder it is - the
|
|
812
|
+
// usual public avatars bucket - is not this.
|
|
813
|
+
const membersOnly = (rule) => {
|
|
814
|
+
const roles = schema.roleList(rule.roles);
|
|
815
|
+
const text = String(rule.qual || '') + ' ' + String(rule.with_check || '');
|
|
816
|
+
const open = roles.includes('anon') || roles.includes('public');
|
|
817
|
+
return (!open || /\bauth\s*\.\s*(uid|jwt)\s*\(/i.test(text)) && /\bfrom\s+/i.test(text);
|
|
818
|
+
};
|
|
819
|
+
return ((plan && plan.buckets) || []).filter((b) => b.public).map((b) => {
|
|
820
|
+
const writers = (b.writeRules || []).filter(membersOnly).map((rule) => rule.name);
|
|
821
|
+
return {
|
|
822
|
+
kind: 'bucket',
|
|
823
|
+
table: b.name,
|
|
824
|
+
bucket: b.id,
|
|
825
|
+
rules: (b.readRules || []).filter((rule) => narrows(rule, b.id)).map((rule) => rule.name),
|
|
826
|
+
// No read rule at all, and something else saying its files are not for
|
|
827
|
+
// everyone: a name that says what is in it (HarborLine: customer-exports,
|
|
828
|
+
// whose one read rule had failed to load), or rules that let only
|
|
829
|
+
// signed-in members put files in or take them out (blind round 15:
|
|
830
|
+
// workspace-files, written only through an attachments row of the
|
|
831
|
+
// uploader's, and readable by anyone with a link).
|
|
832
|
+
noRule: !(b.readRules || []).length && (PRIVATE_BUCKET.test(String(b.name)) || writers.length > 0),
|
|
833
|
+
writers: !(b.readRules || []).length ? writers : [],
|
|
834
|
+
columns: [],
|
|
835
|
+
};
|
|
836
|
+
}).filter((f) => f.rules.length || f.noRule);
|
|
788
837
|
}
|
|
789
838
|
|
|
790
839
|
// Names that say a bucket's files are somebody's own business. Whole words
|
|
@@ -1243,6 +1292,7 @@ module.exports = {
|
|
|
1243
1292
|
privilegedOf: privilegedOf,
|
|
1244
1293
|
searchPathsOf: searchPathsOf,
|
|
1245
1294
|
emptyChoicesOf: emptyChoicesOf,
|
|
1295
|
+
noInsertsOf: noInsertsOf,
|
|
1246
1296
|
oneOfColumns: oneOfColumns,
|
|
1247
1297
|
conflictTargetsOf: conflictTargetsOf,
|
|
1248
1298
|
partialWhereFor: partialWhereFor,
|
package/schema.js
CHANGED
|
@@ -454,7 +454,18 @@ const BUCKETS_QUERY =
|
|
|
454
454
|
FROM pg_policies p
|
|
455
455
|
WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
|
|
456
456
|
AND p.cmd IN ('SELECT', 'ALL')
|
|
457
|
-
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
|
|
457
|
+
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0),
|
|
458
|
+
-- The rules for putting files in and taking them out: who the app
|
|
459
|
+
-- lets write a bucket says who its files are for (scan.js bucketsOf).
|
|
460
|
+
'writeRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
|
|
461
|
+
'name', p.policyname::text,
|
|
462
|
+
'roles', to_jsonb(p.roles::text[]),
|
|
463
|
+
'qual', p.qual,
|
|
464
|
+
'with_check', p.with_check) ORDER BY p.policyname), '[]'::jsonb)
|
|
465
|
+
FROM pg_policies p
|
|
466
|
+
WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
|
|
467
|
+
AND p.cmd IN ('INSERT', 'UPDATE', 'DELETE', 'ALL')
|
|
468
|
+
AND position(quote_literal(b.id::text) IN coalesce(p.qual, '') || ' ' || coalesce(p.with_check, '')) > 0)
|
|
458
469
|
) ORDER BY b.id), '[]'::jsonb) AS buckets
|
|
459
470
|
FROM storage.buckets b`;
|
|
460
471
|
|
package/tamper.js
CHANGED
|
@@ -297,7 +297,10 @@ function roleColumnOf(table) {
|
|
|
297
297
|
// the order reads are tried in, so the least trusted one that can read a
|
|
298
298
|
// secret is the one named.
|
|
299
299
|
const ladder = [lowest].concat(labels.filter((label) => label !== lowest && !IN_CHARGE.test(label)).reverse());
|
|
300
|
-
|
|
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 };
|
|
301
304
|
}
|
|
302
305
|
return null;
|
|
303
306
|
}
|
|
@@ -334,7 +337,7 @@ function memberships(tables) {
|
|
|
334
337
|
.filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
|
|
335
338
|
if (!keys.length || aloneUnique(table, person)) continue;
|
|
336
339
|
found.push({ table: table, person: person, role: role.column, roleType: role.type, lowest: role.lowest,
|
|
337
|
-
ladder: role.ladder, keys: keys });
|
|
340
|
+
ladder: role.ladder, top: role.top, inCharge: role.inCharge || [], keys: keys });
|
|
338
341
|
}
|
|
339
342
|
// A table named for members first: it is the one a team rule asks.
|
|
340
343
|
const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
|
|
@@ -343,7 +346,7 @@ function memberships(tables) {
|
|
|
343
346
|
|
|
344
347
|
// What each kind of write is called in a rule.
|
|
345
348
|
// A takeover is an UPDATE too: a rule for updating is what it would hide behind.
|
|
346
|
-
const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE' };
|
|
349
|
+
const COMMAND = { add: 'INSERT', change: 'UPDATE', delete: 'DELETE', takeover: 'UPDATE', promote: 'UPDATE' };
|
|
347
350
|
|
|
348
351
|
/**
|
|
349
352
|
* Whether the app's own rules name this role among those allowed to make
|
|
@@ -502,6 +505,21 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
502
505
|
statement: 'UPDATE ' + target + ' SET ' + quote(member.person) + " = '" + attack.USER_B + "' WHERE " + firstPersons.join(' AND '),
|
|
503
506
|
values: [],
|
|
504
507
|
});
|
|
508
|
+
// Their own membership row, given the role at the top. Found on blind
|
|
509
|
+
// round 15 (Northstar Ops on Supabase): memberships_update checked only
|
|
510
|
+
// user_id = auth.uid(), so a viewer set their own role to 'owner'.
|
|
511
|
+
// Before joining they have no row, so only what joining added counts.
|
|
512
|
+
if (member.top) {
|
|
513
|
+
const ownRow = [quote(member.person) + " = '" + attack.USER_B + "'"]
|
|
514
|
+
.concat(Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'"));
|
|
515
|
+
moves.push({
|
|
516
|
+
what: 'promote',
|
|
517
|
+
anyRole: true,
|
|
518
|
+
statement: 'UPDATE ' + target + ' SET ' + quote(member.role) + " = '" + String(member.top).split("'").join("''") +
|
|
519
|
+
"' WHERE " + ownRow.join(' AND '),
|
|
520
|
+
values: [],
|
|
521
|
+
});
|
|
522
|
+
}
|
|
505
523
|
}
|
|
506
524
|
// Adding a row to the team, said only of a role whose own name says it
|
|
507
525
|
// may only look: a member adding rows is the feature, a viewer adding
|
|
@@ -591,12 +609,60 @@ async function teammate(client, schema, tables, seeded, rules) {
|
|
|
591
609
|
below: below.slice(),
|
|
592
610
|
columns: (table.columns || []).map((c) => c.name),
|
|
593
611
|
rlsEnabled: table.rlsEnabled,
|
|
594
|
-
}, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {}
|
|
612
|
+
}, can.includes('takeover') ? { takeover: true, personColumn: member.person } : {},
|
|
613
|
+
can.includes('promote') ? { promote: true, toRole: member.top } : {}));
|
|
595
614
|
break;
|
|
596
615
|
}
|
|
597
616
|
if (!listed) below.push(role);
|
|
598
617
|
}
|
|
599
618
|
|
|
619
|
+
// In charge of their OWN team, reading the first person's. Found on
|
|
620
|
+
// blind round 15 (Northstar Ops on Supabase): projects_select let in
|
|
621
|
+
// anyone for whom `exists (select 1 from memberships m where m.role =
|
|
622
|
+
// 'admin')` - not tied to the project's organization - so an admin of
|
|
623
|
+
// any organization read every organization's projects. The second
|
|
624
|
+
// person's own membership is given each role in charge, one at a time,
|
|
625
|
+
// and the first person's team's rows are asked for; only what the role
|
|
626
|
+
// added counts. A table that names neither the team nor a person is
|
|
627
|
+
// not asked.
|
|
628
|
+
const theirTeam = Object.keys(pointed).length
|
|
629
|
+
? Object.keys(pointed).map((name) => quote(name) + " = '" + String(pointed[name]).split("'").join("''") + "'").join(' AND ')
|
|
630
|
+
: owner ? quote(owner) + " = '" + attack.USER_A + "'" : null;
|
|
631
|
+
if (theirTeam && member.inCharge.length) {
|
|
632
|
+
const look = 'SELECT 1 FROM ' + target + ' WHERE ' + theirTeam;
|
|
633
|
+
const plain = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, []));
|
|
634
|
+
for (const label of member.inCharge) {
|
|
635
|
+
const key = 'elevated:' + table.name + ':' + label;
|
|
636
|
+
const raise = {
|
|
637
|
+
statement: 'UPDATE ' + quote(schema) + '.' + quote(member.table.name) + ' SET ' + quote(member.role) + ' = $1 WHERE ' +
|
|
638
|
+
quote(member.person) + ' = $2',
|
|
639
|
+
values: [label, attack.USER_B],
|
|
640
|
+
};
|
|
641
|
+
const now = counted(await tryWrite(client, 'authenticated', attack.USER_B, look, [], raise));
|
|
642
|
+
if (plain === null || now === null) {
|
|
643
|
+
blocked.push({ table: table.name, key: key, why: 'as "' + label + '" in their own ' + parent + ' row: reading it failed' });
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
completed.push(key);
|
|
647
|
+
if (now > plain) {
|
|
648
|
+
findings.push({
|
|
649
|
+
kind: 'crossed',
|
|
650
|
+
table: table.name,
|
|
651
|
+
owner: owner || Object.keys(pointed).sort()[0],
|
|
652
|
+
tenant: !owner,
|
|
653
|
+
readable: now - plain,
|
|
654
|
+
asRole: label,
|
|
655
|
+
via: member.table.name,
|
|
656
|
+
roleColumn: member.role,
|
|
657
|
+
parent: parent,
|
|
658
|
+
columns: (table.columns || []).map((c) => c.name),
|
|
659
|
+
rlsEnabled: table.rlsEnabled,
|
|
660
|
+
});
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
600
666
|
// Reading what a member should not see, said only of a table holding a
|
|
601
667
|
// secret. Every role not in charge, from the lowest up; the first that
|
|
602
668
|
// can read is the one named. A member reading their team's ordinary
|