Skip to content

Commit 2f24fdf

Browse files
Fix (NC | NSFS) - list_buckets allowing unauthorized bucket access
Signed-off-by: Aayush Chouhan <achouhan@redhat.com>
1 parent 1382037 commit 2f24fdf

File tree

3 files changed

+173
-113
lines changed

3 files changed

+173
-113
lines changed

src/sdk/bucketspace_fs.js

Lines changed: 134 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ class BucketSpaceFS extends BucketSpaceSimpleFS {
241241
* permissions
242242
* a. First iteration - map_with_concurrency with concurrency rate of 10 entries at the same time.
243243
* a.1. if entry is dir file/entry is non json - we will return undefined
244-
* a.2. if bucket is unaccessible by bucket policy - we will return undefined
244+
* a.2. if bucket is not owned by the account (or their root account for IAM users) - we will return undefined
245245
* a.3. if underlying storage of the bucket is unaccessible by the account's uid/gid - we will return undefined
246246
* a.4. else - return the bucket config info.
247247
* b. Second iteration - filter empty entries - filter will remove undefined values produced by the map_with_concurrency().
@@ -271,9 +271,9 @@ class BucketSpaceFS extends BucketSpaceSimpleFS {
271271
if (err.rpc_code !== 'NO_SUCH_BUCKET') throw err;
272272
}
273273
if (!bucket) return;
274-
const bucket_policy_accessible = await this.has_bucket_action_permission(bucket, account, 's3:ListBucket');
275-
dbg.log2(`list_buckets: bucket_name ${bucket_name} bucket_policy_accessible`, bucket_policy_accessible);
276-
if (!bucket_policy_accessible) return;
274+
275+
if (!this.has_bucket_ownership_permission(bucket, account)) return;
276+
277277
const fs_accessible = await this.validate_fs_bucket_access(bucket, object_sdk);
278278
if (!fs_accessible) return;
279279
return bucket;
@@ -921,83 +921,6 @@ class BucketSpaceFS extends BucketSpaceSimpleFS {
921921
///// UTILS /////
922922
/////////////////
923923

924-
// TODO: move the following 3 functions - has_bucket_action_permission(), validate_fs_bucket_access(), _has_access_to_nsfs_dir()
925-
// so they can be re-used
926-
async has_bucket_action_permission(bucket, account, action, bucket_path = "") {
927-
dbg.log1('has_bucket_action_permission:', bucket.name.unwrap(), account.name.unwrap(), account._id, bucket.bucket_owner.unwrap());
928-
929-
const is_system_owner = Boolean(bucket.system_owner) && bucket.system_owner.unwrap() === account.name.unwrap();
930-
931-
// If the system owner account wants to access the bucket, allow it
932-
if (is_system_owner) return true;
933-
const is_owner = (bucket.owner_account && bucket.owner_account.id === account._id);
934-
const bucket_policy = bucket.s3_policy;
935-
936-
if (!bucket_policy) {
937-
// in case we do not have bucket policy
938-
// we allow IAM account to access a bucket that that is owned by their root account
939-
const is_iam_and_same_root_account_owner = account.owner !== undefined &&
940-
account.owner === bucket.owner_account.id;
941-
return is_owner || is_iam_and_same_root_account_owner;
942-
}
943-
if (!action) {
944-
throw new Error('has_bucket_action_permission: action is required');
945-
}
946-
947-
let permission_by_name;
948-
const permission_by_id = await bucket_policy_utils.has_bucket_policy_permission(
949-
bucket_policy,
950-
account._id,
951-
action,
952-
`arn:aws:s3:::${bucket.name.unwrap()}${bucket_path}`,
953-
undefined,
954-
{ disallow_public_access: bucket.public_access_block?.restrict_public_buckets }
955-
);
956-
if (permission_by_id === "DENY") return false;
957-
// we (currently) allow account identified to be both id and name,
958-
// so if by-id failed, try also name
959-
if (account.owner === undefined) {
960-
permission_by_name = await bucket_policy_utils.has_bucket_policy_permission(
961-
bucket_policy,
962-
account.name.unwrap(),
963-
action,
964-
`arn:aws:s3:::${bucket.name.unwrap()}${bucket_path}`,
965-
undefined,
966-
{ disallow_public_access: bucket.public_access_block?.restrict_public_buckets }
967-
);
968-
}
969-
if (permission_by_name === 'DENY') return false;
970-
return is_owner || (permission_by_id === 'ALLOW' || permission_by_name === 'ALLOW');
971-
}
972-
973-
async validate_fs_bucket_access(bucket, object_sdk) {
974-
dbg.log1('bucketspace_fs.validate_fs_bucket_access:', bucket.name.unwrap());
975-
const ns = bucket.namespace;
976-
const is_nsfs_bucket = object_sdk.is_nsfs_bucket(ns);
977-
const accessible = is_nsfs_bucket ? await this._has_access_to_nsfs_dir(ns, object_sdk) : false;
978-
dbg.log1('bucketspace_fs.validate_fs_bucket_access:', bucket.name.unwrap(), is_nsfs_bucket, accessible);
979-
return accessible;
980-
}
981-
982-
async _has_access_to_nsfs_dir(ns, object_sdk) {
983-
const account = object_sdk.requesting_account;
984-
dbg.log1('_has_access_to_nsfs_dir: nsr: ', ns, 'account.nsfs_account_config: ', account && account.nsfs_account_config);
985-
// nsfs bucket
986-
if (!account || !account.nsfs_account_config || (account.nsfs_account_config.uid === undefined) ||
987-
(account.nsfs_account_config.gid === undefined)) return false;
988-
try {
989-
dbg.log1('_has_access_to_nsfs_dir: checking access:', ns.write_resource, account.nsfs_account_config.uid, account.nsfs_account_config.gid);
990-
const path_to_check = path.join(ns.write_resource.resource.fs_root_path, ns.write_resource.path || '');
991-
const fs_context = this.prepare_fs_context(object_sdk, ns.write_resource.resource.fs_backend);
992-
await nb_native().fs.checkAccess(fs_context, path_to_check);
993-
return true;
994-
} catch (err) {
995-
dbg.log0('_has_access_to_nsfs_dir: failed', err);
996-
if (err.code === 'ENOENT' || err.code === 'EACCES' || (err.code === 'EPERM' && err.message === 'Operation not permitted')) return false;
997-
throw err;
998-
}
999-
}
1000-
1001924
is_nsfs_containerized_user_anonymous(token) {
1002925
return !token && !process.env.NC_NSFS_NO_DB_ENV;
1003926
}
@@ -1025,10 +948,10 @@ class BucketSpaceFS extends BucketSpaceSimpleFS {
1025948
/**
1026949
* _is_bucket_empty returns true if the given bucket is empty
1027950
*
1028-
* @param {*} ns
1029-
* @param {*} params
1030-
* @param {string} name
1031-
* @param {nb.ObjectSDK} object_sdk
951+
* @param {*} ns
952+
* @param {*} params
953+
* @param {string} name
954+
* @param {nb.ObjectSDK} object_sdk
1032955
*
1033956
* @returns {Promise<boolean>}
1034957
*/
@@ -1055,8 +978,8 @@ class BucketSpaceFS extends BucketSpaceSimpleFS {
1055978
* _generate_reserved_tag_event_args returns the list of reserved
1056979
* bucket tags which have been modified and also returns a variable
1057980
* indicating if there has been any modifications at all.
1058-
* @param {Record<string, string>} prev_tags_objectified
1059-
* @param {Array<{key: string, value: string}>} new_tags
981+
* @param {Record<string, string>} prev_tags_objectified
982+
* @param {Array<{key: string, value: string}>} new_tags
1060983
*/
1061984
static _generate_reserved_tag_event_args(prev_tags_objectified, new_tags) {
1062985
let reserved_tag_modified = false;
@@ -1080,7 +1003,7 @@ class BucketSpaceFS extends BucketSpaceSimpleFS {
10801003
}
10811004

10821005
/**
1083-
* @param {Array<{key: string, value: string}>} tagging
1006+
* @param {Array<{key: string, value: string}>} tagging
10841007
* @returns {Record<string, string>}
10851008
*/
10861009
static _objectify_tagging_arr(tagging) {
@@ -1089,7 +1012,7 @@ class BucketSpaceFS extends BucketSpaceSimpleFS {
10891012

10901013
/**
10911014
* _create_uls creates underylying storage at the given storage_path
1092-
* @param {*} fs_context - root fs_context
1015+
* @param {*} fs_context - root fs_context
10931016
* @param {*} acc_fs_context - fs_context associated with the performing account
10941017
* @param {*} name - bucket name
10951018
* @param {*} storage_path - bucket's storage path
@@ -1106,6 +1029,128 @@ class BucketSpaceFS extends BucketSpaceSimpleFS {
11061029
throw translate_error_codes(error, entity_enum.BUCKET);
11071030
}
11081031
}
1032+
1033+
//////////////////
1034+
// SHARED UTILS //
1035+
//////////////////
1036+
1037+
// TODO: move the below functions to a shared utils file so they can be re-used
1038+
1039+
/**
1040+
* is_bucket_owner checks if the account is the direct owner of the bucket
1041+
* @param {nb.Bucket} bucket
1042+
* @param {nb.Account} account
1043+
* @returns {boolean}
1044+
*/
1045+
is_bucket_owner(bucket, account) {
1046+
if (!bucket?.owner_account?.id || !account?._id) return false;
1047+
return bucket.owner_account.id === account._id;
1048+
}
1049+
1050+
/**
1051+
* is_iam_and_same_root_account_owner checks if the account is an IAM user and the bucket is owned by their root account
1052+
* @param {nb.Account} account
1053+
* @param {nb.Bucket} bucket
1054+
* @returns {boolean}
1055+
*/
1056+
is_iam_and_same_root_account_owner(account, bucket) {
1057+
if (!account?.owner || !bucket?.owner_account?.id) return false;
1058+
return account.owner === bucket.owner_account.id;
1059+
}
1060+
1061+
/**
1062+
* has_bucket_ownership_permission returns true if the account can list the bucket in ListBuckets operation
1063+
*
1064+
* aws-compliant behavior:
1065+
* - Root accounts can list buckets they own
1066+
* - IAM users can list their owner buckets
1067+
*
1068+
* @param {nb.Bucket} bucket
1069+
* @param {nb.Account} account
1070+
* @returns {boolean}
1071+
*/
1072+
has_bucket_ownership_permission(bucket, account) {
1073+
// check direct ownership
1074+
if (this.is_bucket_owner(bucket, account)) return true;
1075+
1076+
// special case: iam user can list the buckets of their owner
1077+
if (this.is_iam_and_same_root_account_owner(account, bucket)) return true;
1078+
1079+
return false;
1080+
}
1081+
1082+
async has_bucket_action_permission(bucket, account, action, bucket_path = "") {
1083+
dbg.log1('has_bucket_action_permission:', bucket.name.unwrap(), account.name.unwrap(), account._id, bucket.bucket_owner.unwrap());
1084+
1085+
// system_owner is deprecated since version 5.18, so we don't need to check it
1086+
1087+
// check ownership: direct owner or IAM user inheritance
1088+
const is_owner = this.is_bucket_owner(bucket, account);
1089+
1090+
const bucket_policy = bucket.s3_policy;
1091+
1092+
if (!bucket_policy) {
1093+
// in case we do not have bucket policy
1094+
// we allow IAM account to access a bucket that that is owned by their root account
1095+
return is_owner || this.is_iam_and_same_root_account_owner(account, bucket);
1096+
}
1097+
if (!action) {
1098+
throw new Error('has_bucket_action_permission: action is required');
1099+
}
1100+
1101+
let permission_by_name;
1102+
const permission_by_id = await bucket_policy_utils.has_bucket_policy_permission(
1103+
bucket_policy,
1104+
account._id,
1105+
action,
1106+
`arn:aws:s3:::${bucket.name.unwrap()}${bucket_path}`,
1107+
undefined,
1108+
{ disallow_public_access: bucket.public_access_block?.restrict_public_buckets }
1109+
);
1110+
if (permission_by_id === "DENY") return false;
1111+
// we (currently) allow account identified to be both id and name,
1112+
// so if by-id failed, try also name
1113+
if (account.owner === undefined) {
1114+
permission_by_name = await bucket_policy_utils.has_bucket_policy_permission(
1115+
bucket_policy,
1116+
account.name.unwrap(),
1117+
action,
1118+
`arn:aws:s3:::${bucket.name.unwrap()}${bucket_path}`,
1119+
undefined,
1120+
{ disallow_public_access: bucket.public_access_block?.restrict_public_buckets }
1121+
);
1122+
}
1123+
if (permission_by_name === 'DENY') return false;
1124+
return is_owner || (permission_by_id === 'ALLOW' || permission_by_name === 'ALLOW');
1125+
}
1126+
1127+
async validate_fs_bucket_access(bucket, object_sdk) {
1128+
dbg.log1('bucketspace_fs.validate_fs_bucket_access:', bucket.name.unwrap());
1129+
const ns = bucket.namespace;
1130+
const is_nsfs_bucket = object_sdk.is_nsfs_bucket(ns);
1131+
const accessible = is_nsfs_bucket ? await this._has_access_to_nsfs_dir(ns, object_sdk) : false;
1132+
dbg.log1('bucketspace_fs.validate_fs_bucket_access:', bucket.name.unwrap(), is_nsfs_bucket, accessible);
1133+
return accessible;
1134+
}
1135+
1136+
async _has_access_to_nsfs_dir(ns, object_sdk) {
1137+
const account = object_sdk.requesting_account;
1138+
dbg.log1('_has_access_to_nsfs_dir: nsr: ', ns, 'account.nsfs_account_config: ', account && account.nsfs_account_config);
1139+
// nsfs bucket
1140+
if (!account || !account.nsfs_account_config || (account.nsfs_account_config.uid === undefined) ||
1141+
(account.nsfs_account_config.gid === undefined)) return false;
1142+
try {
1143+
dbg.log1('_has_access_to_nsfs_dir: checking access:', ns.write_resource, account.nsfs_account_config.uid, account.nsfs_account_config.gid);
1144+
const path_to_check = path.join(ns.write_resource.resource.fs_root_path, ns.write_resource.path || '');
1145+
const fs_context = this.prepare_fs_context(object_sdk, ns.write_resource.resource.fs_backend);
1146+
await nb_native().fs.checkAccess(fs_context, path_to_check);
1147+
return true;
1148+
} catch (err) {
1149+
dbg.log0('_has_access_to_nsfs_dir: failed', err);
1150+
if (err.code === 'ENOENT' || err.code === 'EACCES' || (err.code === 'EPERM' && err.message === 'Operation not permitted')) return false;
1151+
throw err;
1152+
}
1153+
}
11091154
}
11101155

11111156
module.exports = BucketSpaceFS;

src/sdk/nb.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ interface System extends Base {
6363

6464
interface Account extends Base {
6565
_id: ID;
66+
owner?: ID;
6667
name: string;
6768
system: System;
6869
email: SensitiveString;
@@ -179,6 +180,7 @@ interface MirrorStatus {
179180

180181
interface Bucket extends Base {
181182
_id: ID;
183+
owner_account?: ID;
182184
deleted?: Date;
183185
name: SensitiveString;
184186
system: System;

src/test/unit_tests/nsfs/test_bucketspace_fs.js

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -519,28 +519,6 @@ mocha.describe('bucketspace_fs', function() {
519519
const res = await bucketspace_fs.list_buckets({}, dummy_object_sdk_for_iam_account);
520520
assert.equal(res.buckets.length, 0);
521521
});
522-
mocha.it('list buckets - different account with bucket policy (principal by name)', async function() {
523-
// another user created a bucket
524-
// with bucket policy account_user3 can list it
525-
const policy = generate_s3_policy(account_user4.name, test_bucket, ['s3:*']).policy;
526-
const param = { name: test_bucket, policy: policy };
527-
await bucketspace_fs.put_bucket_policy(param);
528-
const dummy_object_sdk_for_iam_account = make_dummy_object_sdk_for_account(dummy_object_sdk, account_user4);
529-
const res = await bucketspace_fs.list_buckets({}, dummy_object_sdk_for_iam_account);
530-
assert.equal(res.buckets.length, 1);
531-
assert.equal(res.buckets[0].name.unwrap(), test_bucket);
532-
});
533-
mocha.it('list buckets - different account with bucket policy (principal by id)', async function() {
534-
// another user created a bucket
535-
// with bucket policy account_user3 can list it
536-
const policy = generate_s3_policy(account_user4._id, test_bucket, ['s3:*']).policy;
537-
const param = { name: test_bucket, policy: policy };
538-
await bucketspace_fs.put_bucket_policy(param);
539-
const dummy_object_sdk_for_iam_account = make_dummy_object_sdk_for_account(dummy_object_sdk, account_user4);
540-
const res = await bucketspace_fs.list_buckets({}, dummy_object_sdk_for_iam_account);
541-
assert.equal(res.buckets.length, 1);
542-
assert.equal(res.buckets[0].name.unwrap(), test_bucket);
543-
});
544522
mocha.afterEach(async function() {
545523
await fs_utils.folder_delete(`${new_buckets_path}/${test_bucket}`);
546524
const file_path = get_config_file_path(CONFIG_SUBDIRS.BUCKETS, test_bucket);
@@ -928,19 +906,54 @@ mocha.describe('bucketspace_fs', function() {
928906
});
929907

930908
mocha.describe('bucket tagging operations', function() {
909+
const test_bucket_tagging = 'test-bucket-tagging';
910+
mocha.before(async function() {
911+
await create_bucket(test_bucket_tagging);
912+
});
913+
931914
mocha.it('put_bucket_tagging', async function() {
932-
const param = { name: test_bucket, tagging: [{ key: 'k1', value: 'v1' }] };
915+
const param = { name: test_bucket_tagging, tagging: [{ key: 'k1', value: 'v1' }] };
933916
await bucketspace_fs.put_bucket_tagging(param, dummy_object_sdk);
934917
const tag = await bucketspace_fs.get_bucket_tagging(param);
935918
assert.deepEqual(tag, { tagging: param.tagging });
936919
});
937920

938921
mocha.it('delete_bucket_tagging', async function() {
939-
const param = { name: test_bucket };
922+
const param = { name: test_bucket_tagging };
940923
await bucketspace_fs.delete_bucket_tagging(param);
941924
const tag = await bucketspace_fs.get_bucket_tagging(param);
942925
assert.deepEqual(tag, { tagging: [] });
943926
});
927+
928+
mocha.it('put_bucket_tagging - different account with bucket policy (principal by name)', async function() {
929+
const policy = generate_s3_policy(account_user4.name, test_bucket_tagging, ['s3:PutBucketTagging']).policy;
930+
const param = { name: test_bucket_tagging, policy: policy };
931+
await bucketspace_fs.put_bucket_policy(param);
932+
const dummy_object_sdk_for_account = make_dummy_object_sdk_for_account(dummy_object_sdk, account_user4);
933+
const tagging_param = { name: test_bucket_tagging, tagging: [{ key: 'test-key', value: 'test-value' }] };
934+
await bucketspace_fs.put_bucket_tagging(tagging_param, dummy_object_sdk_for_account);
935+
const tagging = await bucketspace_fs.get_bucket_tagging({ name: test_bucket_tagging });
936+
assert.equal(tagging.tagging.length, 1);
937+
assert.equal(tagging.tagging[0].key, 'test-key');
938+
});
939+
940+
mocha.it('put_bucket_tagging - different account with bucket policy (principal by id)', async function() {
941+
const policy = generate_s3_policy(account_user4._id, test_bucket_tagging, ['s3:PutBucketTagging']).policy;
942+
const param = { name: test_bucket_tagging, policy: policy };
943+
await bucketspace_fs.put_bucket_policy(param);
944+
const dummy_object_sdk_for_account = make_dummy_object_sdk_for_account(dummy_object_sdk, account_user4);
945+
const tagging_param = { name: test_bucket_tagging, tagging: [{ key: 'test-key', value: 'test-value' }] };
946+
await bucketspace_fs.put_bucket_tagging(tagging_param, dummy_object_sdk_for_account);
947+
const tagging = await bucketspace_fs.get_bucket_tagging({ name: test_bucket_tagging });
948+
assert.equal(tagging.tagging.length, 1);
949+
assert.equal(tagging.tagging[0].key, 'test-key');
950+
});
951+
952+
mocha.after(async function() {
953+
await fs_utils.folder_delete(`${new_buckets_path}/${test_bucket_tagging}`);
954+
const file_path = get_config_file_path(CONFIG_SUBDIRS.BUCKETS, test_bucket_tagging);
955+
await fs_utils.file_delete(file_path);
956+
});
944957
});
945958

946959
mocha.describe('bucket lifecycle operations', function() {

0 commit comments

Comments
 (0)