Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions lib/api/apiUtils/authorization/permissionChecks.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,8 @@ function _checkPrincipals(canonicalID, arn, principal, bucketOwnerCanonicalID) {
// └───────┼──────────────────┘ │
// └──────────────────────────────────────────┘
//
function checkBucketPolicy(policy, requestType, canonicalID, arn, bucketOwner, log, request, actionImplicitDenies) {
function checkBucketPolicy(policy, requestType, canonicalID, arn, bucketOwner, log, request, actionImplicitDenies,
policyTarget) {
let permission = checkBucketPolicyResult.DEFAULT_DENY;
// if requester is user within bucket owner account, actions should be
// allowed unless explicitly denied (assumes allowed by IAM policy)
Expand All @@ -454,10 +455,20 @@ function checkBucketPolicy(policy, requestType, canonicalID, arn, bucketOwner, l
}
let copiedStatement = JSON.parse(JSON.stringify(policy.Statement));

// The policy target, when set, is the resource being authorized when it
// differs from the one the request targets (e.g. the source of a copy)
let bucketName = request?.bucketName;
let objectKey = request?.objectKey;
let query = request?.query;
if (policyTarget) {
bucketName = policyTarget.bucketName;
objectKey = policyTarget.objectKey;
query = Object.assign({}, query, { versionId: policyTarget.versionId });

@leif-scality leif-scality Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4. s3:VersionId is checked against the internal version ID (low severity)

Context:

  • policyTarget.versionId is the decoded internal ID (parseCopySource.js:29).
  • A direct GetObject checks s3:VersionId against the external ID the client sent, so a version-specific Deny never matches on a copy.
  • The new test (bucketPolicyAuth.js:642) passes a raw string, so it passes without reflecting production.
  • In practice this is hard to reach: PutBucketPolicy rejects s3:VersionId, although it only validates the first key in each condition operator.
  • The same decoded ID is sent to Vault (prepareRequestContexts.js:83-84), so IAM policies using s3:VersionId are probably affected too. Not reproduced, since that depends on Vault.

Reproduce (unit level): Write a policy that denies s3:GetObject when s3:VersionId equals the external version ID. Call isObjAuthorized with policyTarget.versionId = VersionID.decode(external), which is what api.js passes. The Deny doesn't apply.

Fix: Have parseCopySource also return the version ID exactly as the client sent it. Use that for policyTarget.versionId and for the Vault request, and keep the decoded ID for the metadata lookup. Update the test to use a real encoded ID.

}
Comment thread
leif-scality marked this conversation as resolved.
const ip = request ? requestUtils.getClientIp(request, config) : undefined;
const isSecure = request ? requestUtils.getHttpProtocolSecurity(request, config) : undefined;
const requestContext = request ? new RequestContext(request.headers, request.query,
request.bucketName, request.objectKey, ip,
const requestContext = request ? new RequestContext(request.headers, query,
bucketName, objectKey, ip,
isSecure, request.resourceType, 's3', null, null,
null, null, null, null, null, null, null, null, null,
request.objectLockRetentionDays) : undefined;
Expand Down Expand Up @@ -506,7 +517,7 @@ function checkBucketPolicy(policy, requestType, canonicalID, arn, bucketOwner, l
}

function processBucketPolicy(requestType, bucket, canonicalID, arn, bucketOwner, log,
request, aclPermission, results, actionImplicitDenies) {
request, aclPermission, results, actionImplicitDenies, policyTarget) {
const bucketPolicy = bucket.getBucketPolicy();
let processedResult = results[requestType];
let aclRequired = false;
Expand All @@ -515,7 +526,7 @@ function processBucketPolicy(requestType, bucket, canonicalID, arn, bucketOwner,
aclRequired = true;
} else {
const bucketPolicyPermission = checkBucketPolicy(bucketPolicy, requestType, canonicalID, arn,
bucketOwner, log, request, actionImplicitDenies);
bucketOwner, log, request, actionImplicitDenies, policyTarget);

if (bucketPolicyPermission === checkBucketPolicyResult.EXPLICIT_DENY) {
processedResult = false;
Expand All @@ -534,7 +545,7 @@ function processBucketPolicy(requestType, bucket, canonicalID, arn, bucketOwner,
}

function isBucketAuthorized(bucket, requestTypesInput, canonicalID, authInfo, log, request,
actionImplicitDeniesInput = {}, isWebsite = false) {
actionImplicitDeniesInput = {}, isWebsite = false, policyTarget = undefined) {
const requestTypes = Array.isArray(requestTypesInput) ? requestTypesInput : [requestTypesInput];
const actionImplicitDenies = !actionImplicitDeniesInput ? {} : actionImplicitDeniesInput;
const mainApiCall = requestTypes[0];
Expand Down Expand Up @@ -568,7 +579,7 @@ function isBucketAuthorized(bucket, requestTypesInput, canonicalID, authInfo, lo
actionImplicitDenies.objectGet = actionImplicitDenies.objectGet || false;
}
const { allowed, aclRequired } = processBucketPolicy(_requestType, bucket, canonicalID, arn,
bucket.getOwner(), log, request, aclPermission, results, actionImplicitDenies);
bucket.getOwner(), log, request, aclPermission, results, actionImplicitDenies, policyTarget);
if (aclRequired && request?.serverAccessLog) {
// eslint-disable-next-line no-param-reassign
request.serverAccessLog.aclRequired = 'Yes';
Expand Down Expand Up @@ -597,7 +608,7 @@ function evaluateBucketPolicyWithIAM(bucket, requestTypesInput, canonicalID, aut
}

function isObjAuthorized(bucket, objectMD, requestTypesInput, canonicalID, authInfo, log, request,
actionImplicitDeniesInput = {}, isWebsite = false) {
actionImplicitDeniesInput = {}, isWebsite = false, policyTarget = undefined) {
const requestTypes = Array.isArray(requestTypesInput) ? requestTypesInput : [requestTypesInput];
const actionImplicitDenies = !actionImplicitDeniesInput ? {} : actionImplicitDeniesInput;
const results = {};
Expand All @@ -617,7 +628,7 @@ function isObjAuthorized(bucket, objectMD, requestTypesInput, canonicalID, authI
permission = 'objectPut';
}
results[_requestType] = isBucketAuthorized(bucket, permission, canonicalID, authInfo, log, request,
actionImplicitDenies, isWebsite);
actionImplicitDenies, isWebsite, policyTarget);
// User is already authorized on the bucket for FULL_CONTROL or WRITE or
// bucket has canned ACL public-read-write
if ((parsedMethodName === 'objectPut' || parsedMethodName === 'objectDelete')
Expand Down Expand Up @@ -651,7 +662,7 @@ function isObjAuthorized(bucket, objectMD, requestTypesInput, canonicalID, authI
const aclPermission = checkObjectAcls(bucket, objectMD, parsedMethodName,
canonicalID, requesterIsNotUser, isUserUnauthenticated, mainApiCall);
const { allowed, aclRequired } = processBucketPolicy(_requestType, bucket, canonicalID, arn, bucketOwner,
log, request, aclPermission, results, actionImplicitDenies);
log, request, aclPermission, results, actionImplicitDenies, policyTarget);
if (aclRequired && request?.serverAccessLog) {
// eslint-disable-next-line no-param-reassign
request.serverAccessLog.aclRequired = 'Yes';
Expand Down
3 changes: 3 additions & 0 deletions lib/api/objectCopy.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ function objectCopy(authInfo, request, sourceBucket, sourceObject, sourceVersion
*/
checkQuota: true,
request,
// The request targets the destination, the bucket policy must be
// evaluated against the source
policyTarget: { bucketName: sourceBucket, objectKey: sourceObject, versionId: sourceVersionId },

@leif-scality leif-scality Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. A cross-account versioned copy ignores the requester's IAM policy (caused by this PR)

Root cause, shared by issues 1 to 3:

  • With a versioned copy source (x-amz-copy-source: bucket/key?versionId=...), Vault is asked about objectGetVersion (prepareRequestContexts.js:81).
  • This source check still uses requestType: 'objectGet' (objectCopy.js:235, objectPutCopyPart.js:50).
  • No IAM result is stored under objectGet, and permissionChecks.js:556/619 fill a missing result with false, meaning "IAM allowed". Vault's implicit deny is dropped.
  • The bucket policy is also checked for s3:GetObject, not s3:GetObjectVersion.
  • ?versionId=null needs no real version ID: for objects in an unversioned bucket it returns the current object (metadataUtils.js:99).

Context: In cross-account access, the bucket owner's policy must allow it and the requester's own account (its IAM) must allow it too. Before this PR, the source bucket's policy never matched on cross-bucket copies, so this was denied (the false deny tracked in S3C-11363, which this PR probably fixes). Now its Allow statements match, and the cross-account branch (permissionChecks.js:535) also requires an IAM allow, which it wrongly sees as granted.

Status: Reproduced: development/9.3 denies, this PR allows (tested with Principal: "*").

Reproduce:

  1. Account A: create bucket src, put secret, and set a bucket policy that allows account B to read (Allow, Principal: {"AWS": "arn:aws:iam::<B>:root"}, s3:GetObject, arn:aws:s3:::src/*).
  2. Account B: create bucket dst, and create IAM user U whose only permission is s3:PutObject on arn:aws:s3:::dst/*.
  3. As U:
    • aws s3api get-object --bucket src --key secret out → AccessDenied (correct)
    • aws s3api copy-object --bucket dst --key copy --copy-source src/secret → AccessDenied (correct)
    • aws s3api copy-object --bucket dst --key copy --copy-source 'src/secret?versionId=null' → succeeds

Fix:

  • At objectCopy.js:235 and objectPutCopyPart.js:50, use requestType: sourceVersionId ? 'objectGetVersion' : 'objectGet' (reqVersionId in objectPutCopyPart.js).
  • The quota check only recognizes objectGet (quotaUtils.js:257, via arsenal's actionNeedQuotaCheckCopy). Make it accept objectGetVersion too, otherwise versioned copies skip quota checks.
  • Add unit tests in objectCopy.js and objectCopyPart.js with sourceVersionId: 'null' and actionImplicitDenies: { objectGetVersion: true, objectPut: false }, expecting AccessDenied.
  • Add a functional test with ?versionId=null.
  • Optional hardening, separate ticket: when Vault returned results, treat a missing action as an implicit deny instead of an allow (permissionChecks.js:556/599/619).

@leif-scality leif-scality Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. A bucket-policy Deny on s3:GetObjectVersion can be bypassed with a copy (already broken before this PR, not fixed by it)

Context: Same root cause as issue 1. The PR description says the copy source is authorized "the same as a direct GetObject". For a versioned source it isn't: GetObject is checked against s3:GetObjectVersion, the copy against s3:GetObject. A common use is a policy that hides older versions.

Status: Reproduced with ?versionId=null in an unversioned bucket, on both development/9.3 and this PR: GetObject is denied, CopyObject succeeds. The versioned-bucket variant below wasn't reproduced.

Reproduce:

  1. Enable versioning on bucket b, put doc twice (v1, then v2).
  2. Set a bucket policy: Deny, Principal: "*", s3:GetObjectVersion, arn:aws:s3:::b/*.
  3. As an IAM user allowed s3:GetObject, s3:GetObjectVersion and s3:PutObject on b/*:
    • aws s3api get-object --bucket b --key doc --version-id <v1> out → AccessDenied (correct)
    • aws s3api copy-object --bucket b --key leak --copy-source 'b/doc?versionId=<v1>' → succeeds, and leak contains v1

Fix: Same as issue 1. Add a test with a policy that denies s3:GetObjectVersion.

@leif-scality leif-scality Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. A same-account IAM user with no read permission can read any object (already broken before this PR, most severe)

Context: Same root cause as issue 1. Vault's implicit deny is dropped, and for an IAM user in the bucket owner's account, no bucket policy plus default ACLs means the copy is allowed. Anyone who can write somewhere can read everything in their own account.

Status: Reproduced on both development/9.3 and this PR.

Reproduce:

  1. Account A: unversioned bucket b, no bucket policy, put secret.
  2. IAM user U in account A, allowed only s3:PutObject and s3:GetObject on arn:aws:s3:::b/mine/*.
  3. As U:
    • get-object --key secret → AccessDenied (correct)
    • copy-object --key mine/copy --copy-source b/secret → AccessDenied (correct)
    • copy-object --key mine/copy --copy-source 'b/secret?versionId=null' → succeeds, and get-object --key mine/copy returns the secret

Fix: Same as issue 1. This is the most important test to add.

};
const valPutParams = {

@leif-scality leif-scality Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5. CopyObject never checks the ACL and tagging actions on the destination (already broken before this PR)

x-amz-acl and x-amz-tagging are applied without checking s3:PutObjectAcl/s3:PutObjectTagging, so a policy that forbids them is bypassed by a copy. Created CLDSRV-1026, reproduced against Vault and checked against AWS.

authInfo,
Expand Down
3 changes: 3 additions & 0 deletions lib/api/objectPutCopyPart.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ function objectPutCopyPart(authInfo, request, sourceBucket,
*/
checkQuota: true,
request,
// The request targets the destination, the bucket policy must be
// evaluated against the source
policyTarget: { bucketName: sourceBucket, objectKey: sourceObject, versionId: reqVersionId },
};

const partNumber = Number.parseInt(request.query.partNumber, 10);
Expand Down
16 changes: 14 additions & 2 deletions lib/metadata/metadataUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ function metadataGetObjects(bucketName, objectsKeys, log, cb) {
* @param {string} params.requestType - type of request
* @param {string} [params.preciseRequestType] - precise type of request
* @param {object} params.request - http request object
* @param {object} [params.policyTarget] - resource to evaluate the bucket
* policy against when it differs from the request target
* @param {RequestLogger} log - request logger
* @param {object} actionImplicitDenies - identity authorization results
* @return {ArsenalError|null} returns a validation error, or null if validation OK
Expand All @@ -212,7 +214,7 @@ function metadataGetObjects(bucketName, objectsKeys, log, cb) {
* - AccessDenied: bucket is not authorized
*/
function validateBucket(bucket, params, log, actionImplicitDenies = {}) {
const { authInfo, preciseRequestType, request } = params;
const { authInfo, preciseRequestType, request, policyTarget } = params;
let requestType = params.requestType;
if (bucketShield(bucket, requestType)) {
log.debug('bucket is shielded from request', {
Expand Down Expand Up @@ -249,6 +251,8 @@ function validateBucket(bucket, params, log, actionImplicitDenies = {}) {
log,
request,
actionImplicitDenies,
false,
policyTarget,
)
) {
log.debug('access denied for user on bucket', { requestType });
Expand Down Expand Up @@ -349,13 +353,19 @@ function checkRateLimitIfNeeded(request, authInfo, bucketMD, log, callback) {
* @param {object} [params.serverAccessLogOptions] - options for server access logging
* @param {boolean} [params.serverAccessLogOptions.copySource] - if true, stores source bucket
* information in request.sourceServerAccessLog for copy operations
* @param {object} [params.policyTarget] - resource to evaluate the bucket policy against
* when it differs from the request target, e.g. the source of a copy
* @param {string} params.policyTarget.bucketName - name of bucket
* @param {string} params.policyTarget.objectKey - name of object
* @param {string} [params.policyTarget.versionId] - version id
* @param {boolean} actionImplicitDenies - identity authorization results
* @param {RequestLogger} log - request logger
* @param {function} callback - callback
* @return {undefined} - and call callback with params err, bucket md
*/
function standardMetadataValidateBucketAndObj(params, actionImplicitDenies, log, callback) {
const { authInfo, bucketName, objectKey, versionId, getDeleteMarker, request, withVersionId } = params;
const { authInfo, bucketName, objectKey, versionId, getDeleteMarker, request, withVersionId, policyTarget } =
params;
let requestType = params.requestType;
if (!Array.isArray(requestType)) {
requestType = [requestType];
Expand Down Expand Up @@ -433,6 +443,8 @@ function standardMetadataValidateBucketAndObj(params, actionImplicitDenies, log,
log,
request,
actionImplicitDenies,
false,
policyTarget,
)
) {
log.debug('access denied for user on object', { requestType });
Expand Down
203 changes: 203 additions & 0 deletions tests/functional/aws-node-sdk/test/object/copySourceBucketPolicy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
const assert = require('assert');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
CopyObjectCommand,
CreateMultipartUploadCommand,
UploadPartCopyCommand,
AbortMultipartUploadCommand,
PutBucketPolicyCommand,
DeleteBucketPolicyCommand,
} = require('@aws-sdk/client-s3');
const {
IAMClient,
CreateUserCommand,
CreateAccessKeyCommand,
DeleteAccessKeyCommand,
DeleteUserCommand,
} = require('@aws-sdk/client-iam');
const { v4: uuid } = require('uuid');

const getConfig = require('../support/config');
const BucketUtility = require('../../lib/utility/bucket-util');
const { config } = require('../../../../../lib/Config');

const bucketName = `copy-source-bp-${uuid()}`;
const userName = `copy-source-bp-user-${uuid()}`;
const forbiddenKey = 'forbidden/myfile';
const allowedKey = 'allowed/myfile';
const mpuKey = 'allowed/mpu-copy';
const allowedContent = 'allowed content';

// this test needs a real vault to create an IAM user
const isVaultScality = config.backends.auth !== 'mem';
const vaultHost = config.vaultd?.host || 'localhost';

const describeWithVault = isVaultScality ? describe : describe.skip;

describeWithVault('Copy source with a bucket policy scoped to key prefixes', () => {
const bucketUtilAccount = new BucketUtility('default');
const s3ClientAccount = bucketUtilAccount.s3;

const iamConfig = getConfig('default', { region: 'us-east-1' });
iamConfig.endpoint = `http://${vaultHost}:8600`; // define outside of getConfig for Integration
const iamClient = new IAMClient(iamConfig);

let userS3Client;
let accessKeyId;
let uploadId;

async function assertAccessDenied(promise) {
await assert.rejects(promise, { name: 'AccessDenied' });
}

before(async () => {
await bucketUtilAccount.createOne(bucketName);
await s3ClientAccount.send(
new PutObjectCommand({
Bucket: bucketName,
Key: forbiddenKey,
Body: 'forbidden content',
}),
);
await s3ClientAccount.send(
new PutObjectCommand({
Bucket: bucketName,
Key: allowedKey,
Body: allowedContent,
}),
);
await s3ClientAccount.send(
new PutBucketPolicyCommand({
Bucket: bucketName,
Policy: JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Sid: 'AllowReadWriteOnAllowedPrefix',
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject', 's3:PutObject'],
Resource: `arn:aws:s3:::${bucketName}/allowed/*`,
},
{
Sid: 'DenyReadOnForbiddenPrefix',
Effect: 'Deny',
Principal: '*',
Action: 's3:GetObject',
Resource: `arn:aws:s3:::${bucketName}/forbidden/*`,
},
],
}),
}),
);

// IAM user without any identity policy
await iamClient.send(new CreateUserCommand({ UserName: userName }));
const accessKeyResponse = await iamClient.send(new CreateAccessKeyCommand({ UserName: userName }));
accessKeyId = accessKeyResponse.AccessKey.AccessKeyId;
userS3Client = new S3Client(
getConfig('default', {
credentials: {
accessKeyId,
secretAccessKey: accessKeyResponse.AccessKey.SecretAccessKey,
},
}),
);

const mpuResponse = await userS3Client.send(
new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: mpuKey,
}),
);
uploadId = mpuResponse.UploadId;
});

after(async () => {
if (uploadId) {
await s3ClientAccount.send(
new AbortMultipartUploadCommand({
Bucket: bucketName,
Key: mpuKey,
UploadId: uploadId,
}),
);
}
await s3ClientAccount.send(new DeleteBucketPolicyCommand({ Bucket: bucketName }));
await bucketUtilAccount.empty(bucketName);
await bucketUtilAccount.deleteOne(bucketName);
if (accessKeyId) {
await iamClient.send(new DeleteAccessKeyCommand({ UserName: userName, AccessKeyId: accessKeyId }));
}
await iamClient.send(new DeleteUserCommand({ UserName: userName }));
});

it('should deny GetObject on a source denied by the bucket policy', async () => {
await assertAccessDenied(
userS3Client.send(
new GetObjectCommand({
Bucket: bucketName,
Key: forbiddenKey,
}),
),
);
});

it('should deny CopyObject from a source denied by the bucket policy', async () => {
await assertAccessDenied(
userS3Client.send(
new CopyObjectCommand({
Bucket: bucketName,
Key: 'allowed/myfile-copy',
CopySource: `${bucketName}/${forbiddenKey}`,
}),
),
);
});

it('should deny UploadPartCopy from a source denied by the bucket policy', async () => {
await assertAccessDenied(
userS3Client.send(
new UploadPartCopyCommand({
Bucket: bucketName,
Key: mpuKey,
UploadId: uploadId,
PartNumber: 1,
CopySource: `${bucketName}/${forbiddenKey}`,
}),
),
);
});

it('should allow CopyObject from a source allowed by the bucket policy', async () => {
await userS3Client.send(
new CopyObjectCommand({
Bucket: bucketName,
Key: 'allowed/allowed-copy',
CopySource: `${bucketName}/${allowedKey}`,
}),
);
const getResponse = await userS3Client.send(
new GetObjectCommand({
Bucket: bucketName,
Key: 'allowed/allowed-copy',
}),
);
assert.strictEqual(await getResponse.Body.transformToString(), allowedContent);
});

it('should allow UploadPartCopy from a source allowed by the bucket policy', async () => {
const res = await userS3Client.send(
new UploadPartCopyCommand({
Bucket: bucketName,
Key: mpuKey,
UploadId: uploadId,
PartNumber: 2,
CopySource: `${bucketName}/${allowedKey}`,
}),
);
assert(res.CopyPartResult.ETag, 'Expected a part ETag');
});
});
Loading
Loading