Skip to content

Commit 0d80b71

Browse files
Merge pull request #9272 from aayushchouhan09/list-fix
Fix - list_buckets allowing unauthorized bucket access
2 parents 8204d2a + 0e5ffbd commit 0d80b71

File tree

5 files changed

+180
-127
lines changed

5 files changed

+180
-127
lines changed

src/server/common_services/auth_server.js

Lines changed: 81 additions & 7 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_bucket_ownership_permission = function(bucket) {
501+
return has_bucket_ownership_permission(bucket, req.account, req.auth && req.auth.role);
502+
};
499503
}
500504

501505
function _get_auth_info(account, system, authorized_by, role, extra) {
@@ -520,6 +524,73 @@ 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>} bucket
530+
* @param {Record<string, any>} account
531+
* @returns {boolean}
532+
*/
533+
function is_system_owner(bucket, account) {
534+
if (!bucket?.system?.owner?.email || !account?.email) return false;
535+
return bucket.system.owner.email.unwrap() === account.email.unwrap();
536+
}
537+
538+
/**
539+
* is_bucket_owner checks if the account is the direct owner of the bucket
540+
* @param {Record<string, any>} bucket
541+
* @param {Record<string, any>} account
542+
* @returns {boolean}
543+
*/
544+
function is_bucket_owner(bucket, account) {
545+
if (!bucket?.owner_account?.email || !account?.email) return false;
546+
return bucket.owner_account.email.unwrap() === account.email.unwrap();
547+
}
548+
549+
/**
550+
* is_bucket_claim_owner checks if the account is the OBC (ObjectBucketClaim) owner of the bucket
551+
* @param {Record<string, any>} bucket
552+
* @param {Record<string, any>} account
553+
* @returns {boolean}
554+
*/
555+
function is_bucket_claim_owner(bucket, account) {
556+
if (!account?.bucket_claim_owner || !bucket?.name) return false;
557+
return account.bucket_claim_owner.name.unwrap() === bucket.name.unwrap();
558+
}
559+
560+
/**
561+
* has_bucket_ownership_permission returns true if the account can list the bucket in ListBuckets operation
562+
*
563+
* aws-compliant behavior:
564+
* - System owner can list all the buckets
565+
* - Operator account (noobaa cli) can list all the buckets
566+
* - Root accounts can list buckets they own
567+
* - OBC owner can list their buckets
568+
* - IAM users can list their owner buckets
569+
*
570+
* @param {Record<string, any>} bucket
571+
* @param {Record<string, any>} account
572+
* @param {string} role
573+
* @returns {Promise<boolean>}
574+
*/
575+
async function has_bucket_ownership_permission(bucket, account, role) {
576+
// system owner can list all the buckets
577+
if (is_system_owner(bucket, account)) return true;
578+
579+
// operator account (noobaa cli) can list all the buckets
580+
if (role === 'operator') return true;
581+
582+
// check direct ownership
583+
if (is_bucket_owner(bucket, account)) return true;
584+
585+
// special case: check bucket claim ownership (OBC)
586+
if (is_bucket_claim_owner(bucket, account)) return true;
587+
588+
// special case: iam user can list the buckets of their owner
589+
// TODO: handle iam user
590+
591+
return false;
592+
}
593+
523594
/**
524595
* has_bucket_action_permission returns true if the requesting account has permission to perform
525596
* the given action on the given bucket.
@@ -538,19 +609,21 @@ function _get_auth_info(account, system, authorized_by, role, extra) {
538609
*/
539610
async function has_bucket_action_permission(bucket, account, action, req_query, bucket_path = "") {
540611
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;
543612

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());
613+
// system owner can access all buckets
614+
if (is_system_owner(bucket, account)) return true;
615+
616+
// check ownership: direct owner or OBC
617+
const has_owner_access = is_bucket_owner(bucket, account) || is_bucket_claim_owner(bucket, account);
618+
546619
const bucket_policy = bucket.s3_policy;
547620

548621
if (!bucket_policy) {
549622
// in case we do not have bucket policy
550-
// we allow IAM account to access a bucket that that is owned by their root account
623+
// we allow IAM account to access a bucket that is owned by their root account
551624
const is_iam_and_same_root_account_owner = account.owner !== undefined &&
552625
account.owner._id.toString() === bucket.owner_account._id.toString();
553-
return is_owner || is_iam_and_same_root_account_owner;
626+
return has_owner_access || is_iam_and_same_root_account_owner;
554627
}
555628
if (!action) {
556629
throw new Error('has_bucket_action_permission: action is required');
@@ -566,7 +639,8 @@ async function has_bucket_action_permission(bucket, account, action, req_query,
566639
);
567640

568641
if (result === 'DENY') return false;
569-
return is_owner || result === 'ALLOW';
642+
643+
return has_owner_access || result === 'ALLOW';
570644
}
571645

572646
/**

src/server/system_services/bucket_server.js

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

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

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

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)