Skip to content

Commit ac7a24c

Browse files
rix0rrriankhougithub-actions
authored
fix(bootstrap): disallow AssumeRole with ExternalId by default (#811)
By default, CDK Bootstrap roles are not designed to be deputized. (Deputized means that you give an external entity access to assume roles on your behalf. They will supply an ExternalId to avoid [Confused Deputy attacks](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html)) If a deputy system (i.e., a system that assumes IAM Roles on behalf of its tenants) is using CDK and its policies are not configured carefully, it can be tricked into assuming its own CDK roles. Because CDK Roles are not intended to be used in this way, we are adding a default security control that will make this misconfiguration less likely: AssumeRole calls with ExternalIds will be denied by default. What if I do want to use ExternalIds? ------------------------------------- If you are currently passing `ExternalId`s in an `AssumeRole` call to CDK bootstrap roles *inside your own trusted organization* (expecting the ExternalId to be present but ignored), this protection can be disabled by calling: ``` $ cdk bootstrap --no-deny-external-id ``` If you want to give permissions for other organizations to assume your CDK bootstrap roles in a deputized way, customize the bootstrap template and add a proper `ExternalId` condition. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <github-actions@github.com> Co-authored-by: Ian Hou <45278651+iankhou@users.noreply.github.com> Co-authored-by: github-actions <github-actions@github.com>
1 parent 80d4d15 commit ac7a24c

File tree

14 files changed

+474
-69
lines changed

14 files changed

+474
-69
lines changed

packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ export interface CdkModernBootstrapCommandOptions extends CommonCdkBootstrapComm
312312
*/
313313
readonly customPermissionsBoundary?: string;
314314

315+
/**
316+
* @default true
317+
*/
318+
readonly denyExternalId?: boolean;
319+
315320
/**
316321
* @default undefined
317322
*/
@@ -509,6 +514,10 @@ export class TestFixture extends ShellHelper {
509514
} else if (options.examplePermissionsBoundary !== undefined) {
510515
args.push('--example-permissions-boundary');
511516
}
517+
if (options.denyExternalId !== undefined) {
518+
args.push(options.denyExternalId ? '--deny-external-id' : '--no-deny-external-id');
519+
}
520+
512521
if (options.usePreviousParameters === false) {
513522
args.push('--no-previous-parameters');
514523
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { AssumeRoleCommand } from '@aws-sdk/client-sts';
2+
import { integTest, withoutBootstrap } from '../../../lib';
3+
4+
jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime
5+
6+
integTest('deploy role cannot be assumed with external id', withoutBootstrap(async (fixture) => {
7+
const bootstrapStackName = fixture.bootstrapStackName;
8+
9+
await fixture.cdkBootstrapModern({
10+
toolkitStackName: bootstrapStackName,
11+
cfnExecutionPolicy: 'arn:aws:iam::aws:policy/AdministratorAccess',
12+
});
13+
14+
const account = await fixture.aws.account();
15+
const deployRoleArn = `arn:aws:iam::${account}:role/cdk-${fixture.qualifier}-deploy-role-${account}-${fixture.aws.region}`;
16+
17+
// Attempt to assume the deploy role with an external ID should fail
18+
await expect(
19+
fixture.aws.sts.send(new AssumeRoleCommand({
20+
RoleArn: deployRoleArn,
21+
RoleSessionName: 'test-external-id-failure',
22+
ExternalId: 'some-external-id',
23+
})),
24+
).rejects.toThrow();
25+
}));
26+
27+
integTest('deploy role can be assumed with ExternalId if protection is switched off', withoutBootstrap(async (fixture) => {
28+
const bootstrapStackName = fixture.bootstrapStackName;
29+
30+
await fixture.cdkBootstrapModern({
31+
toolkitStackName: bootstrapStackName,
32+
cfnExecutionPolicy: 'arn:aws:iam::aws:policy/AdministratorAccess',
33+
denyExternalId: false,
34+
});
35+
36+
const account = await fixture.aws.account();
37+
const deployRoleArn = `arn:aws:iam::${account}:role/cdk-${fixture.qualifier}-deploy-role-${account}-${fixture.aws.region}`;
38+
39+
// Attempt to assume the deploy role with an external ID should fail
40+
await expect(
41+
fixture.aws.sts.send(new AssumeRoleCommand({
42+
RoleArn: deployRoleArn,
43+
RoleSessionName: 'test-external-id-failure',
44+
ExternalId: 'some-external-id',
45+
})),
46+
).resolves.toBeTruthy();
47+
}));

packages/@aws-cdk/toolkit-lib/lib/api/bootstrap/bootstrap-environment.ts

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -213,27 +213,37 @@ export class Bootstrapper {
213213
}
214214
}
215215

216-
return current.update(
217-
bootstrapTemplate,
218-
{
219-
FileAssetsBucketName: params.bucketName,
220-
FileAssetsBucketKmsKeyId: kmsKeyId,
221-
// Empty array becomes empty string
222-
TrustedAccounts: trustedAccounts.join(','),
223-
TrustedAccountsForLookup: trustedAccountsForLookup.join(','),
224-
CloudFormationExecutionPolicies: cloudFormationExecutionPolicies.join(','),
225-
Qualifier: params.qualifier,
226-
PublicAccessBlockConfiguration:
227-
params.publicAccessBlockConfiguration || params.publicAccessBlockConfiguration === undefined
228-
? 'true'
229-
: 'false',
230-
InputPermissionsBoundary: policyName,
231-
},
232-
{
233-
...options,
234-
terminationProtection: options.terminationProtection ?? current.terminationProtection,
235-
},
236-
);
216+
const bootstrapTemplateParameters: Record<string, string | undefined> = {
217+
FileAssetsBucketName: params.bucketName,
218+
FileAssetsBucketKmsKeyId: kmsKeyId,
219+
// Empty array becomes empty string
220+
TrustedAccounts: trustedAccounts.join(','),
221+
TrustedAccountsForLookup: trustedAccountsForLookup.join(','),
222+
CloudFormationExecutionPolicies: cloudFormationExecutionPolicies.join(','),
223+
Qualifier: params.qualifier,
224+
PublicAccessBlockConfiguration:
225+
params.publicAccessBlockConfiguration || params.publicAccessBlockConfiguration === undefined
226+
? 'true'
227+
: 'false',
228+
InputPermissionsBoundary: policyName,
229+
};
230+
231+
const templateParameters = await this.templateParameters();
232+
233+
// Conditionally set these parameters: only set these parameters if they are accepted by the template.
234+
// If we pass them unconditionally, older customized templates that don't know about these
235+
// parameters yet will fail to deploy.
236+
if (params.denyExternalId !== undefined) {
237+
if (!templateParameters.includes('DenyExternalId')) {
238+
throw new ToolkitError('The selected bootstrap template does not accept the DenyExternalId parameter');
239+
}
240+
bootstrapTemplateParameters.DenyExternalId = `${params.denyExternalId}`;
241+
}
242+
243+
return current.update(bootstrapTemplate, bootstrapTemplateParameters, {
244+
...options,
245+
terminationProtection: options.terminationProtection ?? current.terminationProtection,
246+
});
237247
}
238248

239249
private async getPolicyName(
@@ -368,14 +378,23 @@ export class Bootstrapper {
368378
}
369379
}
370380

371-
private async loadTemplate(params: BootstrappingParameters = {}): Promise<any> {
381+
/**
382+
* Return the set of parameter names accepted by the current bootstrapping template
383+
*/
384+
private async templateParameters(legacyParams: BootstrappingParameters = {}): Promise<string[]> {
385+
const template = await this.loadTemplate(legacyParams);
386+
387+
return Object.keys(template.Parameters ?? {});
388+
}
389+
390+
private async loadTemplate(legacyParams: BootstrappingParameters = {}): Promise<any> {
372391
switch (this.source.source) {
373392
case 'custom':
374393
return loadStructuredFile(this.source.templateFile);
375394
case 'default':
376395
return loadStructuredFile(path.join(bundledPackageRootDir(__dirname), 'lib', 'api', 'bootstrap', 'bootstrap-template.yaml'));
377396
case 'legacy':
378-
return legacyBootstrapTemplate(params);
397+
return legacyBootstrapTemplate(legacyParams);
379398
}
380399
}
381400
}

packages/@aws-cdk/toolkit-lib/lib/api/bootstrap/bootstrap-props.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,11 @@ export interface BootstrappingParameters {
146146
* @default - No value, optional argument
147147
*/
148148
readonly customPermissionsBoundary?: string;
149+
150+
/**
151+
* Whether to deny AssumeRole calls with an ExternalId
152+
*
153+
* @default - template default (true)
154+
*/
155+
readonly denyExternalId?: boolean;
149156
}

packages/@aws-cdk/toolkit-lib/test/api/bootstrap/bootstrap2.test.ts

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,86 @@ describe('Bootstrapping v2', () => {
645645
);
646646
});
647647

648+
describe('ExternalId protection', () => {
649+
test('denyExternalId parameter is not present by default', async () => {
650+
// GIVEN
651+
const mockSdk = new MockSdkProvider();
652+
(ToolkitInfo as any).lookup = jest.fn().mockResolvedValue(ToolkitInfo.fromStack(mockBootstrapStack({
653+
Outputs: [
654+
{ OutputKey: 'BootstrapVersion', OutputValue: '1' },
655+
],
656+
})));
657+
658+
// WHEN
659+
await bootstrapper.bootstrapEnvironment(env, mockSdk, {
660+
parameters: {},
661+
});
662+
663+
// THEN
664+
expect(mockDeployStack).toHaveBeenCalledWith(
665+
expect.objectContaining({
666+
parameters: expect.not.objectContaining({
667+
DenyExternalId: 'true',
668+
}),
669+
}),
670+
expect.anything(),
671+
);
672+
});
673+
674+
test.each([false, true])('denyExternalId parameter can be set to %p', async (param) => {
675+
// GIVEN
676+
const mockSdk2 = new MockSdkProvider();
677+
(ToolkitInfo as any).lookup = jest.fn().mockResolvedValue(ToolkitInfo.fromStack(mockBootstrapStack({
678+
Outputs: [
679+
{ OutputKey: 'BootstrapVersion', OutputValue: '1' },
680+
],
681+
})));
682+
683+
// WHEN
684+
await bootstrapper.bootstrapEnvironment(env, mockSdk2, {
685+
parameters: {
686+
denyExternalId: param,
687+
},
688+
});
689+
690+
// THEN
691+
expect(mockDeployStack).toHaveBeenCalledWith(
692+
expect.objectContaining({
693+
parameters: expect.objectContaining({
694+
DenyExternalId: `${param}`,
695+
}),
696+
}),
697+
expect.anything(),
698+
);
699+
});
700+
701+
test('bootstrap template contains ExternalId conditions', async () => {
702+
// GIVEN
703+
const testBootstrapper = new Bootstrapper({ source: 'default' }, ioHelper);
704+
705+
// WHEN
706+
const template = await (testBootstrapper as any).loadTemplate();
707+
708+
// THEN
709+
expect(template.Parameters.DenyExternalId).toBeDefined();
710+
expect(template.Parameters.DenyExternalId.Default).toBe('true');
711+
expect(template.Conditions.ShouldDenyExternalId).toBeDefined();
712+
713+
// Check that roles have the ExternalId condition
714+
const filePublishingRole = template.Resources.FilePublishingRole;
715+
expect(filePublishingRole.Properties.AssumeRolePolicyDocument.Statement).toEqual(
716+
expect.arrayContaining([
717+
expect.objectContaining({
718+
Action: 'sts:AssumeRole',
719+
Condition: expect.objectContaining({
720+
'Fn::If': expect.arrayContaining(['ShouldDenyExternalId']),
721+
}),
722+
}),
723+
]),
724+
);
725+
});
726+
});
727+
648728
describe('contains sts:TagSession on trusted accounts', () => {
649729
let template: Template;
650730

@@ -659,7 +739,7 @@ describe('Bootstrapping v2', () => {
659739
'Fn::If': Match.arrayWith([
660740
conditionName,
661741
Match.objectLike({
662-
Action: Match.arrayWith(['sts:AssumeRole', 'sts:TagSession']),
742+
Action: Match.arrayWith(['sts:TagSession']),
663743
}),
664744
]),
665745
});
@@ -680,23 +760,14 @@ describe('Bootstrapping v2', () => {
680760
template = Template.fromJSON(rawTemplate);
681761
});
682762

683-
test('in the FilePublishingRole', async () => {
684-
template.hasResource('AWS::IAM::Role', {
685-
Properties: {
686-
RoleName: iamRoleName('file-publishing-role'),
687-
AssumeRolePolicyDocument: {
688-
Statement: Match.arrayWith([
689-
statementWithCondition('HasTrustedAccounts'),
690-
]),
691-
},
692-
},
693-
});
694-
});
695-
696-
test('in the ImagePublishingRole', async () => {
763+
test.each([
764+
['FilePublishingRole', 'file-publishing-role'],
765+
['ImagePublishingRole', 'image-publishing-role'],
766+
['DeploymentActionRole', 'deploy-role'],
767+
])('in the %p', async (_, roleName) => {
697768
template.hasResource('AWS::IAM::Role', {
698769
Properties: {
699-
RoleName: iamRoleName('image-publishing-role'),
770+
RoleName: iamRoleName(roleName),
700771
AssumeRolePolicyDocument: {
701772
Statement: Match.arrayWith([
702773
statementWithCondition('HasTrustedAccounts'),
@@ -719,18 +790,5 @@ describe('Bootstrapping v2', () => {
719790
},
720791
});
721792
});
722-
723-
test('in the DeploymentActionRole', async () => {
724-
template.hasResource('AWS::IAM::Role', {
725-
Properties: {
726-
RoleName: iamRoleName('deploy-role'),
727-
AssumeRolePolicyDocument: {
728-
Statement: Match.arrayWith([
729-
statementWithCondition('HasTrustedAccounts'),
730-
]),
731-
},
732-
},
733-
});
734-
});
735793
});
736794
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Bootstrapper } from '../../../lib/api/bootstrap';
2+
import type { IIoHost } from '../../../lib/api/io';
3+
import { asIoHelper } from '../../../lib/api/io/private';
4+
5+
describe('ExternalId Protection Integration Test', () => {
6+
let ioHost: IIoHost;
7+
let ioHelper: any;
8+
9+
beforeEach(() => {
10+
ioHost = {
11+
notify: jest.fn(),
12+
requestResponse: jest.fn(),
13+
};
14+
ioHelper = asIoHelper(ioHost, 'bootstrap');
15+
});
16+
17+
test('bootstrap template denies AssumeRole with ExternalId by default', async () => {
18+
// GIVEN
19+
const bootstrapper = new Bootstrapper({ source: 'default' }, ioHelper);
20+
21+
// WHEN
22+
const template = await (bootstrapper as any).loadTemplate();
23+
24+
// THEN
25+
// Verify the parameter exists
26+
expect(template.Parameters.DenyExternalId).toMatchObject({
27+
Type: 'String',
28+
Default: 'true',
29+
AllowedValues: ['true', 'false'],
30+
});
31+
32+
// Verify the condition exists
33+
expect(template.Conditions.ShouldDenyExternalId).toEqual({
34+
'Fn::Equals': ['true', { Ref: 'DenyExternalId' }],
35+
});
36+
37+
// Verify each role has the ExternalId condition
38+
const rolesToCheck = [
39+
'FilePublishingRole',
40+
'ImagePublishingRole',
41+
'LookupRole',
42+
'DeploymentActionRole',
43+
];
44+
45+
for (const roleName of rolesToCheck) {
46+
const role = template.Resources[roleName];
47+
expect(role).toBeDefined();
48+
49+
// Find AssumeRole statements for AWS principals (not service principals)
50+
const assumeRoleStatements = role.Properties.AssumeRolePolicyDocument.Statement.filter(
51+
(stmt: any) => stmt.Action === 'sts:AssumeRole' && stmt.Principal?.AWS,
52+
);
53+
54+
// Each AssumeRole statement should have the ExternalId condition
55+
for (const stmt of assumeRoleStatements) {
56+
expect(stmt.Condition).toEqual({
57+
'Fn::If': [
58+
'ShouldDenyExternalId',
59+
{ Null: { 'sts:ExternalId': 'true' } },
60+
{ Ref: 'AWS::NoValue' },
61+
],
62+
});
63+
}
64+
}
65+
66+
// Verify CloudFormationExecutionRole does NOT have the condition (it's assumed by service)
67+
const cfnRole = template.Resources.CloudFormationExecutionRole;
68+
const cfnStatements = cfnRole.Properties.AssumeRolePolicyDocument.Statement;
69+
for (const stmt of cfnStatements) {
70+
expect(stmt.Condition).toBeUndefined();
71+
}
72+
});
73+
});

0 commit comments

Comments
 (0)