I recently contributed to the AWS Cloud Development Kit (CDK) by implementing External ID support for AwsCustomResource, a feature that enhances security for cross-account AWS operations. The pull request #35252 was merged into the main branch after a comprehensive review process, addressing a critical security gap identified in issue #34018.
This article walks through the problem, the solution, and the engineering decisions that went into this contribution.
The Problem: Confused Deputy Attacks
What is a Confused Deputy Attack?
In multi-account AWS environments, services often need to assume roles across accounts. A "confused deputy" attack occurs when a malicious actor tricks a service (the "deputy") into performing unauthorized actions by exploiting the trust relationship between accounts.
Consider this scenario:
- Your Lambda function assumes a role in Account B using sts:AssumeRole
- An attacker discovers your function's configuration
- The attacker creates their own resource that tricks your function into assuming a role in their account instead
- Your function unknowingly performs operations in the attacker's account
The Security Gap in AwsCustomResource
Before this contribution, AwsCustomResource supported cross-account operations via assumedRoleArn but lacked support for External IDs—a critical AWS security best practice. This forced developers to choose between:
Security: Skip cross-account functionality
Functionality: Accept increased security risk
The Solution: External ID Support
What is an External ID?
An External ID is a secret value that must be provided when assuming a role. It acts as a second factor of authentication, ensuring that only entities with both the correct ARN and the secret can assume the role.
// Trust policy in the assumed role (Account B)
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::ACCOUNT-A:role/CustomResourceRole" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "my-secret-external-id-12345"
}
}
}
Implementation Overview
The implementation spans three key areas:
- CDK Construct Interface (aws-custom-resource.ts)
export interface AwsSdkCall {
// ... existing properties
/**
* The external ID to use when assuming the role for this call.
*
* An external ID is a secret identifier that you define and share with the
* account owner. It helps prevent the "confused deputy" problem in cross-account
* scenarios.
*
* @see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html
* @default - No external ID is used
*/
readonly externalId?: string;
/**
* The ARN of the role to assume for this call.
*
* When specified with externalId, both must be provided to the AssumeRole call.
*
* @default - No role is assumed (calls are made with the Lambda function's role)
*/
readonly assumedRoleArn?: string;
}
Key design decisions:
- Optional property: Maintains backward compatibility
- Comprehensive documentation: Explains security benefits and links to AWS best practices
- Paired with assumedRoleArn: Only applies when cross-account operations are configured
- Lambda Handler (custom-resource-handlers/lib/custom-resources/utils.ts)
async function getCredentials(assumedRoleArn?: string, externalId?: string): Promise<AWS.Credentials | undefined> {
if (!assumedRoleArn) {
return undefined;
}
const sts = new AWS.STS();
const timestamp = new Date().getTime();
const params: AWS.STS.AssumeRoleRequest = {
RoleArn: assumedRoleArn,
RoleSessionName: `AwsSdkCall-${timestamp}`,
};
// Add External ID if provided
if (externalId) {
params.ExternalId = externalId;
}
const { Credentials: assumedCredentials } = await sts.assumeRole(params).promise();
if (!assumedCredentials) {
throw new Error('Failed to assume role');
}
return new AWS.Credentials({
accessKeyId: assumedCredentials.AccessKeyId,
secretAccessKey: assumedCredentials.SecretAccessKey,
sessionToken: assumedCredentials.SessionToken,
});
}
The implementation:
- Passes External ID to STS AssumeRole calls when provided
- Maintains backward compatibility (works without External ID)
- Uses existing AWS SDK patterns for consistency
- Type Safety (construct-types.ts)
export interface AwsSdkCall {
service: string;
action: string;
parameters?: any;
physicalResourceId?: PhysicalResourceId;
assumedRoleArn?: string;
externalId?: string; // Added for type safety
region?: string;
apiVersion?: string;
outputPaths?: string[];
}
Ensures type consistency between the CDK construct and Lambda handler.
Usage Examples
Basic Cross-Account Operation with External ID
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
import { PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam';
const customResource = new AwsCustomResource(this, 'CrossAccountOperation', {
onCreate: {
service: 'S3',
action: 'putObject',
parameters: {
Bucket: 'cross-account-bucket',
Key: 'data.json',
Body: JSON.stringify({ message: 'Hello from Account A' }),
},
physicalResourceId: PhysicalResourceId.of('cross-account-s3-object'),
assumedRoleArn: 'arn:aws:iam::ACCOUNT-B:role/CrossAccountS3Role',
externalId: 'my-secret-external-id-12345', // Security enhancement!
},
policy: AwsCustomResourcePolicy.fromStatements([
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['sts:AssumeRole'],
resources: ['arn:aws:iam::ACCOUNT-B:role/CrossAccountS3Role'],
}),
]),
});
Different External IDs per Operation
const multiOpResource = new AwsCustomResource(this, 'MultiOpResource', {
onCreate: {
service: 'DynamoDB',
action: 'putItem',
parameters: { /* ... */ },
assumedRoleArn: 'arn:aws:iam::ACCOUNT-B:role/DynamoDBRole',
externalId: 'create-external-id-abc123',
},
onUpdate: {
service: 'DynamoDB',
action: 'updateItem',
parameters: { /* ... */ },
assumedRoleArn: 'arn:aws:iam::ACCOUNT-B:role/DynamoDBRole',
externalId: 'update-external-id-xyz789', // Different External ID
},
onDelete: {
service: 'DynamoDB',
action: 'deleteItem',
parameters: { /* ... */ },
assumedRoleArn: 'arn:aws:iam::ACCOUNT-B:role/DynamoDBRole',
externalId: 'delete-external-id-def456', // Different External ID
},
policy: AwsCustomResourcePolicy.fromStatements([
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['sts:AssumeRole'],
resources: ['arn:aws:iam::ACCOUNT-B:role/DynamoDBRole'],
}),
]),
});
Validation and Testing
Comprehensive Test Coverage
The contribution includes three levels of testing:
- Unit Tests (10 test cases) External ID parameter propagation to CloudFormation Different External IDs for different operations Backward compatibility without External ID Integration with existing assumedRoleArn CloudFormation template validation Edge cases and error scenarios
test('can specify external ID for cross-account operations', () => {
const stack = new Stack();
new AwsCustomResource(stack, 'MyResource', {
onCreate: {
service: 'S3',
action: 'listBuckets',
assumedRoleArn: 'arn:aws:iam::123456789012:role/MyRole',
externalId: 'my-external-id',
physicalResourceId: PhysicalResourceId.of('list-buckets'),
},
policy: AwsCustomResourcePolicy.fromSdkCalls({ resources: ['*'] }),
});
Template.fromStack(stack).hasResourceProperties('Custom::AWS', {
Create: {
assumedRoleArn: 'arn:aws:iam::123456789012:role/MyRole',
externalId: 'my-external-id',
},
});
});
- Integration Tests (4 scenarios)
- Real cross-account role assumption
- STS GetCallerIdentity validation
- CDK snapshot validation
- End-to-end workflow testing
const role = new iam.Role(stack, 'Role', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
externalIds: ['external-id-12345'],
});
const resource = new cr.AwsCustomResource(stack, 'GetCallerIdentity', {
onCreate: {
service: 'STS',
action: 'getCallerIdentity',
assumedRoleArn: role.roleArn,
externalId: 'external-id-12345',
physicalResourceId: cr.PhysicalResourceId.of('caller-identity'),
},
policy: cr.AwsCustomResourcePolicy.fromSdkCalls({ resources: ['*'] }),
});
- Lambda Handler Tests (7 test cases)
- getCredentials function correctly passes External ID
- STS AssumeRole includes ExternalId parameter
- Backward compatibility verification
- Type safety validation
Design Decisions and Alternatives
Why Optional External ID?
Making externalId optional maintains backward compatibility. Existing users aren't forced to change their code, while new users can adopt the security best practice.
Why Per-Operation External IDs?
Different operations may require different security contexts. For example:
Create: Stricter security with unique External ID
Update: Different permissions, different External ID
Delete: Potentially destructive, requires highest security
This granularity provides maximum flexibility.
Key Takeaways for Contributors
Security-First Design
Always consider security implications, especially for cross-account operations. Reference AWS documentation and best practices.Backward Compatibility is Critical
CDK is used by thousands of organizations. Any breaking change can affect production systems. Design features to be additive.Comprehensive Testing
Unit tests, integration tests, and manual validation ensure robustness. Don't skimp on test coverage.Documentation Matters
Inline documentation, README updates, and practical examples help users understand and adopt new features safely.Follow Existing Patterns
Consistency with existing CDK patterns makes features more intuitive and maintainable.
Conclusion
Adding External ID support to AwsCustomResource closes a critical security gap in AWS CDK's multi-account capabilities. The feature:
Prevents confused deputy attacks
Maintains full backward compatibility
Follows AWS security best practices
Provides flexible per-operation configuration
Includes comprehensive testing and documentation
This contribution demonstrates that security enhancements don't have to come at the cost of usability or backward compatibility. By carefully considering design decisions and thoroughly testing the implementation, we can deliver features that make AWS CDK both more powerful and more secure.
Top comments (0)