Skip to content

Commit 1f53a03

Browse files
Fix (containerized) - list_buckets allowing unauthorized bucket access
Signed-off-by: Aayush Chouhan <[email protected]>
1 parent 7e70923 commit 1f53a03

File tree

5 files changed

+215
-126
lines changed

5 files changed

+215
-126
lines changed

src/server/common_services/auth_server.js

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,10 @@ function _prepare_auth_request(req) {
496496
req.has_bucket_action_permission = async function(bucket, action, bucket_path, req_query) {
497497
return has_bucket_action_permission(bucket, req.account, action, req_query, bucket_path);
498498
};
499+
500+
req.has_list_bucket_permission = function(bucket) {
501+
return has_list_bucket_permission(bucket, req.account, req.system);
502+
};
499503
}
500504

501505
function _get_auth_info(account, system, authorized_by, role, extra) {
@@ -520,6 +524,100 @@ function _get_auth_info(account, system, authorized_by, role, extra) {
520524
return response;
521525
}
522526

527+
/**
528+
* is_system_owner checks if the account is the system owner
529+
* @param {Record<string, any>} system
530+
* @param {Record<string, any>} account
531+
* @returns {boolean}
532+
*/
533+
function is_system_owner(system, account) {
534+
return system?.owner?.email?.unwrap() === account?.email?.unwrap();
535+
}
536+
537+
/**
538+
* is_bucket_owner checks if the account is the direct owner of the bucket
539+
* @param {Record<string, any>} bucket
540+
* @param {Record<string, any>} account
541+
* @returns {boolean}
542+
*/
543+
function is_bucket_owner(bucket, account) {
544+
return bucket?.owner_account?.email?.unwrap() === account?.email?.unwrap();
545+
}
546+
547+
/**
548+
* has_iam_list_buckets_permission checks if the account has IAM policy granting s3:ListAllMyBuckets
549+
* for buckets owned by their root account
550+
* @param {Record<string, any>} bucket
551+
* @param {Record<string, any>} account
552+
* @returns {Promise<boolean>}
553+
*/
554+
async function has_iam_list_buckets_permission(bucket, account) {
555+
if (!account?.owner) return false;
556+
557+
const iam_policies = account.iam_user_policies || [];
558+
if (iam_policies.length === 0) return false;
559+
560+
// check each IAM policy for s3:ListAllMyBuckets permission
561+
for (const iam_policy of iam_policies) {
562+
const result = await s3_bucket_policy_utils.has_bucket_policy_permission(
563+
iam_policy.policy_document,
564+
undefined,
565+
's3:ListAllMyBuckets',
566+
'arn:aws:s3:::*',
567+
undefined,
568+
{ should_pass_principal: false }
569+
);
570+
571+
if (result === 'DENY') return false;
572+
573+
if (result === 'ALLOW') {
574+
const bucket_owner_id = bucket.owner_account?._id;
575+
const account_owner_id = account.owner?._id;
576+
const ownership_match = account_owner_id !== undefined && bucket_owner_id !== undefined &&
577+
String(bucket_owner_id) === String(account_owner_id);
578+
579+
// special case: check if root is OBC account and this is the claimed bucket
580+
const root_claimed_bucket = account.owner?.bucket_claim_owner?.name?.unwrap();
581+
const obc_claim_match = root_claimed_bucket && root_claimed_bucket === bucket.name?.unwrap();
582+
583+
if (ownership_match || obc_claim_match) return true;
584+
}
585+
}
586+
587+
return false;
588+
}
589+
590+
/**
591+
* has_list_bucket_permission returns true if the account can list the bucket in ListBuckets operation
592+
*
593+
* aws-compliant behavior:
594+
* - System owner can list all the buckets
595+
* - Root accounts can list buckets they own
596+
* - IAM users CANNOT inherit ListBuckets visibility from root
597+
* - IAM users need explicit IAM policy with s3:ListAllMyBuckets to list buckets
598+
*
599+
* @param {Record<string, any>} bucket
600+
* @param {Record<string, any>} account
601+
* @param {Record<string, any>} system
602+
* @returns {Promise<boolean>}
603+
*/
604+
async function has_list_bucket_permission(bucket, account, system) {
605+
// system owner can list all the buckets
606+
if (is_system_owner(bucket.system, account)) return true;
607+
608+
// check direct ownership
609+
if (is_bucket_owner(bucket, account)) return true;
610+
611+
// special case: check bucket claim ownership (OBC)
612+
if (account.bucket_claim_owner?.name?.unwrap() === bucket.name.unwrap()) return true;
613+
614+
// check IAM policy for s3:ListAllMyBuckets
615+
// Note: IAM users need this policy to list buckets owned by their root account
616+
if (await has_iam_list_buckets_permission(bucket, account)) return true;
617+
618+
return false;
619+
}
620+
523621
/**
524622
* has_bucket_action_permission returns true if the requesting account has permission to perform
525623
* the given action on the given bucket.
@@ -538,14 +636,22 @@ function _get_auth_info(account, system, authorized_by, role, extra) {
538636
*/
539637
async function has_bucket_action_permission(bucket, account, action, req_query, bucket_path = "") {
540638
dbg.log1('has_bucket_action_permission:', bucket.name, account.email, bucket.owner_account.email);
541-
// If the system owner account wants to access the bucket, allow it
542-
if (bucket.system.owner.email.unwrap() === account.email.unwrap()) return true;
543639

544-
const is_owner = (bucket.owner_account.email.unwrap() === account.email.unwrap()) ||
545-
(account.bucket_claim_owner && account.bucket_claim_owner.name.unwrap() === bucket.name.unwrap());
640+
// system owner can access all buckets
641+
if (is_system_owner(bucket.system, account)) return true;
642+
643+
// check ownership: direct owner, IAM user inheritance, or OBC
644+
const is_owner = is_bucket_owner(bucket, account);
645+
const bucket_owner_id = bucket.owner_account?._id;
646+
const account_owner_id = account.owner?._id;
647+
const has_iam_access = account_owner_id !== undefined && bucket_owner_id !== undefined &&
648+
String(bucket_owner_id) === String(account_owner_id);
649+
const has_obc_access = account.bucket_claim_owner?.name?.unwrap() === bucket.name.unwrap();
650+
const owner = is_owner || has_iam_access || has_obc_access;
651+
546652
const bucket_policy = bucket.s3_policy;
547653

548-
if (!bucket_policy) return is_owner;
654+
if (!bucket_policy) return owner;
549655
if (!action) {
550656
throw new Error('has_bucket_action_permission: action is required');
551657
}
@@ -560,7 +666,8 @@ async function has_bucket_action_permission(bucket, account, action, req_query,
560666
);
561667

562668
if (result === 'DENY') return false;
563-
return is_owner || result === 'ALLOW';
669+
670+
return owner || result === 'ALLOW';
564671
}
565672

566673
/**

src/server/system_services/bucket_server.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,9 +1095,13 @@ async function list_buckets(req) {
10951095
let continuation_token = req.rpc_params?.continuation_token;
10961096
const max_buckets = req.rpc_params?.max_buckets;
10971097

1098-
const accessible_bucket_list = system_store.data.buckets.filter(
1099-
async bucket => await req.has_s3_bucket_permission(bucket, "s3:ListBucket", req) && !bucket.deleting
1100-
);
1098+
// filter buckets based on ownership (async to support IAM policy checks)
1099+
const bucket_permissions = await P.map(system_store.data.buckets, async bucket => {
1100+
if (bucket.deleting) return null;
1101+
const has_permission = await req.has_list_bucket_permission(bucket);
1102+
return has_permission ? bucket : null;
1103+
});
1104+
const accessible_bucket_list = bucket_permissions.filter(bucket => bucket !== null);
11011105

11021106
accessible_bucket_list.sort((a, b) => a.name.unwrap().localeCompare(b.name.unwrap()));
11031107

src/test/integration_tests/api/s3/test_s3_list_buckets.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,53 @@ mocha.describe('s3_ops', function() {
127127
});
128128
});
129129

130+
mocha.describe('list_buckets permissions', function() {
131+
this.timeout(60000);
132+
let s3_account_a;
133+
let s3_account_b;
134+
135+
async function create_account_and_client(name) {
136+
const account = await rpc_client.account.create_account({
137+
name, email: name, has_login: false, s3_access: true,
138+
default_resource: coretest.POOL_LIST[0].name
139+
});
140+
return new S3Client({
141+
...s3_client_params,
142+
credentials: {
143+
accessKeyId: account.access_keys[0].access_key.unwrap(),
144+
secretAccessKey: account.access_keys[0].secret_key.unwrap(),
145+
}
146+
});
147+
}
148+
149+
mocha.before(async function() {
150+
s3_account_a = await create_account_and_client('account-a');
151+
s3_account_b = await create_account_and_client('account-b');
152+
await s3_account_a.send(new CreateBucketCommand({ Bucket: 'bucket-a' }));
153+
await s3_account_b.send(new CreateBucketCommand({ Bucket: 'bucket-b' }));
154+
await s3.send(new CreateBucketCommand({ Bucket: 'admin-buck' }));
155+
});
156+
157+
mocha.after(async function() {
158+
await s3_account_a.send(new DeleteBucketCommand({ Bucket: 'bucket-a' }));
159+
await s3_account_b.send(new DeleteBucketCommand({ Bucket: 'bucket-b' }));
160+
await s3.send(new DeleteBucketCommand({ Bucket: 'admin-buck' }));
161+
await rpc_client.account.delete_account({ email: 'account-a' });
162+
await rpc_client.account.delete_account({ email: 'account-b' });
163+
});
164+
165+
mocha.it('accounts should list only owned buckets', async function() {
166+
const buckets_a = (await s3_account_a.send(new ListBucketsCommand())).Buckets.map(b => b.Name);
167+
const buckets_b = (await s3_account_b.send(new ListBucketsCommand())).Buckets.map(b => b.Name);
168+
assert.deepStrictEqual(buckets_a, ['bucket-a']);
169+
assert.deepStrictEqual(buckets_b, ['bucket-b']);
170+
});
171+
172+
mocha.it('admin should lists all the buckets', async function() {
173+
const buckets = (await s3.send(new ListBucketsCommand())).Buckets.map(b => b.Name);
174+
assert(buckets.includes('bucket-a') && buckets.includes('bucket-b') && buckets.includes('admin-buck'));
175+
});
176+
});
177+
130178
});
131179

src/test/integration_tests/api/sts/test_sts.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ function verify_session_token(session_token, access_key, secret_key, assumed_rol
457457
mocha.describe('Session token tests', function() {
458458
const { rpc_client } = coretest;
459459
const alice2 = 'alice2';
460+
const alice2_buck = 'alice2-test-bucket';
460461
const bob2 = 'bob2';
461462
const charlie2 = 'charlie2';
462463
const accounts = [{ email: alice2 }, { email: bob2 }, { email: charlie2 }];
@@ -466,6 +467,8 @@ mocha.describe('Session token tests', function() {
466467
mocha.after(async function() {
467468
const self = this; // eslint-disable-line no-invalid-this
468469
self.timeout(60000);
470+
471+
await accounts[0].s3.deleteBucket({ Bucket: alice2_buck }).promise();
469472
for (const account of accounts) {
470473
await rpc_client.account.delete_account({ email: account.email });
471474
}
@@ -542,6 +545,10 @@ mocha.describe('Session token tests', function() {
542545
name: 'first.bucket',
543546
policy: s3accesspolicy,
544547
});
548+
549+
// create a bucket owned by alice2 for ListBuckets to work
550+
// Note: bucket policy is not related to ListBuckets operation
551+
await accounts[0].s3.createBucket({ Bucket: alice2_buck }).promise();
545552
});
546553

547554
mocha.it('user b assume role of user a - default expiry - list s3 - should be allowed', async function() {
@@ -564,7 +571,7 @@ mocha.describe('Session token tests', function() {
564571
});
565572

566573
const buckets1 = await temp_s3_with_session_token.listBuckets().promise();
567-
assert.ok(buckets1.Buckets.length > 0);
574+
assert.ok(buckets1.Buckets[0].Name === alice2_buck);
568575
});
569576

570577
mocha.it('user b assume role of user a - valid expiry via durationSeconds - list s3 - should be allowed', async function() {
@@ -589,7 +596,7 @@ mocha.describe('Session token tests', function() {
589596
});
590597

591598
const buckets1 = await temp_s3_with_session_token.listBuckets().promise();
592-
assert.ok(buckets1.Buckets.length > 0);
599+
assert.ok(buckets1.Buckets[0].Name === alice2_buck);
593600
});
594601

595602
mocha.it('user b assume role of user a - invalid expiry via durationSeconds - should be rejected', async function() {

0 commit comments

Comments
 (0)