DEV Community

Cover image for Securing Cross-Account AWS Operations: Adding External ID Support to AwsCustomResource

Securing Cross-Account AWS Operations: Adding External ID Support to AwsCustomResource

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:

  1. Your Lambda function assumes a role in Account B using sts:AssumeRole
  2. An attacker discovers your function's configuration
  3. The attacker creates their own resource that tricks your function into assuming a role in their account instead
  4. 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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementation Overview
The implementation spans three key areas:

  1. 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;
}
Enter fullscreen mode Exit fullscreen mode

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
  1. 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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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
  1. 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[];
}
Enter fullscreen mode Exit fullscreen mode

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'],
    }),
  ]),
});
Enter fullscreen mode Exit fullscreen mode

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'],
    }),
  ]),
});
Enter fullscreen mode Exit fullscreen mode

Validation and Testing
Comprehensive Test Coverage
The contribution includes three levels of testing:

  1. 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',
    },
  });
});
Enter fullscreen mode Exit fullscreen mode
  1. Integration Tests (4 scenarios)
  2. Real cross-account role assumption
  3. STS GetCallerIdentity validation
  4. CDK snapshot validation
  5. 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: ['*'] }),
});
Enter fullscreen mode Exit fullscreen mode
  1. Lambda Handler Tests (7 test cases)
  2. getCredentials function correctly passes External ID
  3. STS AssumeRole includes ExternalId parameter
  4. Backward compatibility verification
  5. 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

  1. Security-First Design
    Always consider security implications, especially for cross-account operations. Reference AWS documentation and best practices.

  2. Backward Compatibility is Critical
    CDK is used by thousands of organizations. Any breaking change can affect production systems. Design features to be additive.

  3. Comprehensive Testing
    Unit tests, integration tests, and manual validation ensure robustness. Don't skimp on test coverage.

  4. Documentation Matters
    Inline documentation, README updates, and practical examples help users understand and adopt new features safely.

  5. 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)