Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cli): S3 asset uploads are rejected by commonly referenced encryption SCP (introduces bootstrap stack v9) #17668

Merged
merged 9 commits into from
Nov 26, 2021
3 changes: 2 additions & 1 deletion packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ Resources:
- Action:
- s3:GetObject*
- s3:GetBucket*
- s3:GetEncryptionConfiguration
- s3:List*
- s3:DeleteObject*
- s3:PutObject*
Expand Down Expand Up @@ -490,7 +491,7 @@ Resources:
Type: String
Name:
Fn::Sub: '/cdk-bootstrap/${Qualifier}/version'
Value: '8'
Value: '9'
Outputs:
BucketName:
Description: The name of the S3 bucket owned by the CDK toolkit stack
Expand Down
130 changes: 117 additions & 13 deletions packages/cdk-assets/lib/private/handlers/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ export class FileAssetHandler implements IAssetHandler {
public async publish(): Promise<void> {
const destination = await replaceAwsPlaceholders(this.asset.destination, this.host.aws);
const s3Url = `s3://${destination.bucketName}/${destination.objectKey}`;

const s3 = await this.host.aws.s3Client(destination);
this.host.emitMessage(EventType.CHECK, `Check ${s3Url}`);

const bucketInfo = BucketInformation.for(this.host);

// A thunk for describing the current account. Used when we need to format an error
// message, not in the success case.
const account = async () => (await this.host.aws.discoverCurrentAccount())?.accountId;
switch (await bucketOwnership(s3, destination.bucketName)) {
switch (await bucketInfo.bucketOwnership(s3, destination.bucketName)) {
case BucketOwnership.MINE:
break;
case BucketOwnership.DOES_NOT_EXIST:
Expand All @@ -44,17 +45,42 @@ export class FileAssetHandler implements IAssetHandler {
return;
}

// Identify the the bucket encryption type to set the header on upload
// required for SCP rules denying uploads without encryption header
let paramsEncryption: {[index: string]:any}= {};
const encryption2 = await bucketInfo.bucketEncryption(s3, destination.bucketName);
switch (encryption2) {
case BucketEncryption.NO_ENCRYPTION:
break;
case BucketEncryption.SSEAlgorithm_AES256:
paramsEncryption = { ServerSideEncryption: 'AES256' };
break;
case BucketEncryption.SSEAlgorithm_aws_kms:
paramsEncryption = { ServerSideEncryption: 'aws:kms' };
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we potentially need to include the correct KMS key ARN?

If the user has configured a custom key ARN as "DefaultEncryption" and we just pass ServerSideEncryption: 'aws:kms', which key is going to be used? The user's key or aws/s3 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tested the following setups

  • encryption key type: a) Amazon S3 key (SSE-S3) -> yields to { ServerSideEncryption: 'AES256' };, no key configurable
  • encryption key type: b) AWS Key Management Service key (SSE-KMS)
    • b1) AWS managed key (aws/s3)
    • b2) custom AWS KMS key

see details below for the configurations.

In summary:
We set the SSEAlgorithm, and the bucket automatically use whatever encryption is currently set up as default in the bucket. The bucket stores which is the default key to be used for default encryption. In fact the bucket would use the default encryption anyway but we need the header to get through the SCP rules.
I tried all the below scenarios below and deploy works. It would not make sense for default encryption to use another key than configured in the bucket.

a)

{
    "ServerSideEncryptionConfiguration": {
        "Rules": [
            {
                "ApplyServerSideEncryptionByDefault": {
                    "SSEAlgorithm": "AES256"
                },
                "BucketKeyEnabled": false
            }
        ]
    }
}

b1)

{
    "ServerSideEncryptionConfiguration": {
        "Rules": [
            {
                "ApplyServerSideEncryptionByDefault": {
                    "SSEAlgorithm": "aws:kms",
                    "KMSMasterKeyID": "arn:aws:kms:us-east-1:703956386855:alias/aws/s3"
                },
                "BucketKeyEnabled": true
            }
        ]
    }
}

b2)

{
    "ServerSideEncryptionConfiguration": {
        "Rules": [
            {
                "ApplyServerSideEncryptionByDefault": {
                    "SSEAlgorithm": "aws:kms",
                    "KMSMasterKeyID": "arn:aws:kms:us-east-1:703956386855:key/833b292e-bc1f-47ec-af3f-1c1b6504815b"
                },
                "BucketKeyEnabled": true
            }
        ]
    }
}

Screenshot 2021-11-24 at 15 13 44

Copy link

Choose a reason for hiding this comment

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

Hmm that's odd -- the behaviour we are seeing is that when CDK only includes the x-amz-server-side-encryption: aws:kms header, the objects end up being encrypted with aws/s3 key, which currently is not permitted by our bucket policy.

Copy link
Contributor Author

@ArlindNocaj ArlindNocaj Jan 5, 2022

Choose a reason for hiding this comment

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

@vludax
Do you have default bucket encryption enabled for the bucket (with specific KMS Key)?
Did you use cdk bootstrap to reinitialize the cdk resources?
Can you also provide the bucket policy, so that I can reproduce it?

Copy link

Choose a reason for hiding this comment

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

image
The following bucket policy statement blocks the uploads -- when we remove this statement, the files get uploaded successfully and encrypted with aws/s3:

{
    "Sid": "Enforce default bucket encryption by rejecting any overrides",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:PutObject",
    "Resource": "arn:aws:s3:::<bucket-name>/*",
    "Condition": {
        "Null": {
            "s3:x-amz-server-side-encryption": "false"
        },
        "StringNotLikeIfExists": {
            "s3:x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:ap-southeast-2:XXX:key/YYY"
        }
    }
}

We did not run the bootstrap as we are using a custom synthesizer in our CDK -- that doesn't seem to specify encryption options anywhere. I'd also note that our uploads are working as expected with v1.118.0 of the CDK -- the files are successfully uploaded and encrypted with the right KMS key with the above policy statement in place.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@vludax please note that this is already being addressed and fixed here: #18262

break;
case BucketEncryption.DOES_NOT_EXIST:
this.host.emitMessage(EventType.DEBUG, `No bucket named '${destination.bucketName}'. Is account ${await account()} bootstrapped?`);
break;
case BucketEncryption.ACCES_DENIED:
this.host.emitMessage(EventType.DEBUG, `ACCES_DENIED for getting encryption of bucket '${destination.bucketName}'. Either wrong account ${await account()} or s3:GetEncryptionConfiguration not set for cdk role. Try "cdk bootstrap" again.`);
break;
}

if (this.host.aborted) { return; }
const publishFile = this.asset.source.executable ?
await this.externalPackageFile(this.asset.source.executable) : await this.packageFile(this.asset.source);

this.host.emitMessage(EventType.UPLOAD, `Upload ${s3Url}`);
await s3.upload({

const params = Object.assign({}, {
Bucket: destination.bucketName,
Key: destination.objectKey,
Body: createReadStream(publishFile.packagedPath),
ContentType: publishFile.contentType,
}).promise();
},
paramsEncryption);

await s3.upload(params).promise();
}

private async packageFile(source: FileSource): Promise<PackagedFileAsset> {
Expand Down Expand Up @@ -100,15 +126,12 @@ enum BucketOwnership {
SOMEONE_ELSES_OR_NO_ACCESS
}

async function bucketOwnership(s3: AWS.S3, bucket: string): Promise<BucketOwnership> {
try {
await s3.getBucketLocation({ Bucket: bucket }).promise();
return BucketOwnership.MINE;
} catch (e) {
if (e.code === 'NoSuchBucket') { return BucketOwnership.DOES_NOT_EXIST; }
if (['AccessDenied', 'AllAccessDisabled'].includes(e.code)) { return BucketOwnership.SOMEONE_ELSES_OR_NO_ACCESS; }
throw e;
}
enum BucketEncryption {
NO_ENCRYPTION,
SSEAlgorithm_AES256,
SSEAlgorithm_aws_kms,
ACCES_DENIED,
DOES_NOT_EXIST
}

async function objectExists(s3: AWS.S3, bucket: string, key: string) {
Expand Down Expand Up @@ -144,3 +167,84 @@ interface PackagedFileAsset {
*/
readonly contentType?: string;
}


/**
* Cache for bucket information, so we don't have to keep doing the same calls again and again
*
* We scope the lifetime of the cache to the lifetime of the host, so that we don't have to do
* anything special for tests and yet the cache will live for the entire lifetime of the asset
* upload session when used by the CLI.
*/
class BucketInformation {
public static for(host: IHandlerHost) {
const existing = BucketInformation.caches.get(host);
if (existing) { return existing; }

const fresh = new BucketInformation();
BucketInformation.caches.set(host, fresh);
return fresh;
}

private static readonly caches = new WeakMap<IHandlerHost, BucketInformation>();

private readonly ownerships = new Map<string, BucketOwnership>();
private readonly encryptions = new Map<string, BucketEncryption>();

private constructor() {
}

public async bucketOwnership(s3: AWS.S3, bucket: string): Promise<BucketOwnership> {
return cached(this.ownerships, bucket, () => this._bucketOwnership(s3, bucket));
}

public async bucketEncryption(s3: AWS.S3, bucket: string): Promise<BucketEncryption> {
return cached(this.encryptions, bucket, () => this._bucketEncryption(s3, bucket));
}

private async _bucketOwnership(s3: AWS.S3, bucket: string): Promise<BucketOwnership> {
try {
await s3.getBucketLocation({ Bucket: bucket }).promise();
return BucketOwnership.MINE;
} catch (e) {
if (e.code === 'NoSuchBucket') { return BucketOwnership.DOES_NOT_EXIST; }
if (['AccessDenied', 'AllAccessDisabled'].includes(e.code)) { return BucketOwnership.SOMEONE_ELSES_OR_NO_ACCESS; }
throw e;
}
}

private async _bucketEncryption(s3: AWS.S3, bucket: string): Promise<BucketEncryption> {
try {
const encryption = await s3.getBucketEncryption({ Bucket: bucket }).promise();
const l = encryption?.ServerSideEncryptionConfiguration?.Rules?.length ?? 0;
if (l > 0) {
let ssealgo = encryption?.ServerSideEncryptionConfiguration?.Rules[0]?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm;
if (ssealgo === 'AES256') return BucketEncryption.SSEAlgorithm_AES256;
if (ssealgo === 'aws:kms') return BucketEncryption.SSEAlgorithm_aws_kms;
}
return BucketEncryption.NO_ENCRYPTION;
} catch (e) {
if (e.code === 'NoSuchBucket') {
return BucketEncryption.DOES_NOT_EXIST;
}
if (e.code === 'ServerSideEncryptionConfigurationNotFoundError') {
return BucketEncryption.NO_ENCRYPTION;
}

if (['AccessDenied', 'AllAccessDisabled'].includes(e.code)) {
return BucketEncryption.ACCES_DENIED;
}
return BucketEncryption.NO_ENCRYPTION;
}
}
}

async function cached<A, B>(cache: Map<A, B>, key: A, factory: (x: A) => Promise<B>): Promise<B> {
if (cache.has(key)) {
return cache.get(key)!;
}

const fresh = await factory(key);
cache.set(key, fresh);
return fresh;
}
13 changes: 8 additions & 5 deletions packages/cdk-assets/lib/publishing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AssetManifest, IManifestEntry } from './asset-manifest';
import { IAws } from './aws';
import { IHandlerHost } from './private/asset-handler';
import { makeAssetHandler } from './private/handlers';
import { EventType, IPublishProgress, IPublishProgressListener } from './progress';

Expand Down Expand Up @@ -67,18 +68,20 @@ export class AssetPublishing implements IPublishProgress {
public async publish(): Promise<void> {
const self = this;

const handlerHost: IHandlerHost = {
aws: this.options.aws,
get aborted() { return self.aborted; },
emitMessage(t, m) { self.progressEvent(t, m); },
};

for (const asset of this.assets) {
if (this.aborted) { break; }
this.currentAsset = asset;

try {
if (this.progressEvent(EventType.START, `Publishing ${asset.id}`)) { break; }

const handler = makeAssetHandler(this.manifest, asset, {
aws: this.options.aws,
get aborted() { return self.aborted; },
emitMessage(t, m) { self.progressEvent(t, m); },
});
const handler = makeAssetHandler(this.manifest, asset, handlerHost);
await handler.publish();

if (this.aborted) {
Expand Down
97 changes: 95 additions & 2 deletions packages/cdk-assets/test/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ jest.mock('child_process');
import { Manifest } from '@aws-cdk/cloud-assembly-schema';
import * as mockfs from 'mock-fs';
import { AssetManifest, AssetPublishing } from '../lib';
import { mockAws, mockedApiResult, mockUpload } from './mock-aws';
import { mockAws, mockedApiFailure, mockedApiResult, mockUpload } from './mock-aws';
import { mockSpawn } from './mock-child_process';

const ABS_PATH = '/simple/cdk.out/some_external_file';
Expand Down Expand Up @@ -126,7 +126,6 @@ test('Do nothing if file already exists', async () => {
const pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws });

aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: [{ Key: 'some_key' }] });

await pub.publish();

expect(aws.mockS3.listObjectsV2).toHaveBeenCalledWith(expect.objectContaining({
Expand All @@ -153,6 +152,100 @@ test('upload file if new (list returns other key)', async () => {
// We'll just have to assume the contents are correct
});

test('upload with server side encryption AES256 header', async () => {
const pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws });

aws.mockS3.getBucketEncryption = mockedApiResult({
ServerSideEncryptionConfiguration: {
Rules: [
{
ApplyServerSideEncryptionByDefault: {
SSEAlgorithm: 'AES256',
},
BucketKeyEnabled: false,
},
],
},
});
aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: [{ Key: 'some_key.but_not_the_one' }] });
aws.mockS3.upload = mockUpload('FILE_CONTENTS');

await pub.publish();

expect(aws.mockS3.upload).toHaveBeenCalledWith(expect.objectContaining({
Bucket: 'some_bucket',
Key: 'some_key',
ContentType: 'application/octet-stream',
ServerSideEncryption: 'AES256',
}));

// We'll just have to assume the contents are correct
});

test('upload with server side encryption aws:kms header', async () => {
const pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws });

aws.mockS3.getBucketEncryption = mockedApiResult({
ServerSideEncryptionConfiguration: {
Rules: [
{
ApplyServerSideEncryptionByDefault: {
SSEAlgorithm: 'aws:kms',
},
BucketKeyEnabled: false,
},
],
},
});

aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: [{ Key: 'some_key.but_not_the_one' }] });
aws.mockS3.upload = mockUpload('FILE_CONTENTS');

await pub.publish();

expect(aws.mockS3.upload).toHaveBeenCalledWith(expect.objectContaining({
Bucket: 'some_bucket',
Key: 'some_key',
ContentType: 'application/octet-stream',
ServerSideEncryption: 'aws:kms',
}));

// We'll just have to assume the contents are correct
});

test('will only read bucketEncryption once even for multiple assets', async () => {
const pub = new AssetPublishing(AssetManifest.fromPath('/types/cdk.out'), { aws });

aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: [{ Key: 'some_key.but_not_the_one' }] });
aws.mockS3.upload = mockUpload('FILE_CONTENTS');

await pub.publish();

expect(aws.mockS3.upload).toHaveBeenCalledTimes(2);
expect(aws.mockS3.getBucketEncryption).toHaveBeenCalledTimes(1);
});

test('no server side encryption header if access denied for bucket encryption', async () => {
const pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws });

aws.mockS3.getBucketEncryption = mockedApiFailure('AccessDenied', 'Access Denied');

aws.mockS3.listObjectsV2 = mockedApiResult({ Contents: [{ Key: 'some_key.but_not_the_one' }] });
aws.mockS3.upload = mockUpload('FILE_CONTENTS');

await pub.publish();

expect(aws.mockS3.upload).toHaveBeenCalledWith(expect.not.objectContaining({
ServerSideEncryption: 'aws:kms',
}));

expect(aws.mockS3.upload).toHaveBeenCalledWith(expect.not.objectContaining({
ServerSideEncryption: 'AES256',
}));

// We'll just have to assume the contents are correct
});

test('correctly looks up content type', async () => {
const pub = new AssetPublishing(AssetManifest.fromPath('/types/cdk.out'), { aws });

Expand Down
1 change: 1 addition & 0 deletions packages/cdk-assets/test/mock-aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function mockAws() {

// Sane defaults which can be overridden
mockS3.getBucketLocation = mockedApiResult({});
mockS3.getBucketEncryption = mockedApiResult({});
mockEcr.describeRepositories = mockedApiResult({
repositories: [
{
Expand Down