diff --git a/packages/aws-cdk-lib/aws-rds/README.md b/packages/aws-cdk-lib/aws-rds/README.md index 43e4962f8becc..ae90ce098d63e 100644 --- a/packages/aws-cdk-lib/aws-rds/README.md +++ b/packages/aws-cdk-lib/aws-rds/README.md @@ -797,6 +797,23 @@ new rds.DatabaseInstance(this, 'InstanceWithCustomizedSecret', { }); ``` +For applications that embed database credentials in connection URLs (such as Go applications using `net/url` parser), you can generate URL-safe passwords that exclude characters known to cause URL parsing issues: + +```ts +declare const vpc: ec2.Vpc; +const engine = rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_16_3 }); + +new rds.DatabaseInstance(this, 'InstanceWithUrlSafePassword', { + engine, + vpc, + credentials: rds.Credentials.fromGeneratedSecret('postgres', { + urlSafePassword: true, // Excludes characters that can cause URL parsing issues (like ^) + }), +}); +``` + +The `urlSafePassword` option extends the default character exclusion set to include characters that are problematic in URLs, particularly the caret (`^`) character which causes failures in Go's `net/url` parser. If you specify both `urlSafePassword: true` and `excludeCharacters`, the explicit `excludeCharacters` takes precedence. + ### Snapshot credentials As noted above, Databases created with `DatabaseInstanceFromSnapshot` or `ServerlessClusterFromSnapshot` will not create user and auto-generated password by default because it's not possible to change the master username for a snapshot. Instead, they will use the existing username and password from the snapshot. You can still generate a new password - to generate a secret similarly to the other constructs, pass in credentials with `fromGeneratedSecret()` or `fromGeneratedPassword()`. @@ -816,6 +833,18 @@ new rds.DatabaseInstanceFromSnapshot(this, 'InstanceFromSnapshotWithCustomizedSe replicaRegions: [{ region: 'eu-west-1' }, { region: 'eu-west-2' }], }), }); + +// Alternative: Generate URL-safe password for snapshot credentials +new rds.DatabaseInstanceFromSnapshot(this, 'InstanceFromSnapshotWithUrlSafePassword', { + engine, + vpc, + snapshotIdentifier: 'mySnapshot', + credentials: rds.SnapshotCredentials.fromGeneratedSecret('username', { + encryptionKey: myKey, + urlSafePassword: true, // Excludes URL-problematic characters like ^ + replicaRegions: [{ region: 'eu-west-1' }, { region: 'eu-west-2' }], + }), +}); ``` ## Connecting diff --git a/packages/aws-cdk-lib/aws-rds/lib/database-secret.ts b/packages/aws-cdk-lib/aws-rds/lib/database-secret.ts index 484af567e34e0..1ae8effbf86c1 100644 --- a/packages/aws-cdk-lib/aws-rds/lib/database-secret.ts +++ b/packages/aws-cdk-lib/aws-rds/lib/database-secret.ts @@ -1,5 +1,5 @@ import { Construct } from 'constructs'; -import { DEFAULT_PASSWORD_EXCLUDE_CHARS } from './private/util'; +import { DEFAULT_PASSWORD_EXCLUDE_CHARS, URL_SAFE_PASSWORD_EXCLUDE_CHARS } from './private/util'; import * as kms from '../../aws-kms'; import * as secretsmanager from '../../aws-secretsmanager'; import { Aws, Names } from '../../core'; @@ -63,6 +63,20 @@ export interface DatabaseSecretProps { */ readonly replaceOnPasswordCriteriaChanges?: boolean; + /** + * Whether to generate a URL parser-compatible password by excluding characters that can cause issues in URL parsers. + * + * When enabled, the generated password will exclude the caret (^) character in addition to the default + * exclusion set. This specifically addresses compatibility issues with URL parsers like Go's net/url + * that fail when parsing URLs containing caret characters in the userinfo section. + * + * Note: The default exclusion set already excludes most URL-problematic characters (%, #, ?, &, @, /, etc.). + * This option adds the caret (^) character which is specifically problematic for certain URL parsers. + * + * @default false + */ + readonly urlSafePassword?: boolean; + /** * A list of regions where to replicate this secret. * @@ -84,7 +98,8 @@ export class DatabaseSecret extends secretsmanager.Secret { public static readonly PROPERTY_INJECTION_ID: string = 'aws-cdk-lib.aws-rds.DatabaseSecret'; constructor(scope: Construct, id: string, props: DatabaseSecretProps) { - const excludeCharacters = props.excludeCharacters ?? DEFAULT_PASSWORD_EXCLUDE_CHARS; + const excludeCharacters = props.excludeCharacters ?? + (props.urlSafePassword ? URL_SAFE_PASSWORD_EXCLUDE_CHARS : DEFAULT_PASSWORD_EXCLUDE_CHARS); super(scope, id, { encryptionKey: props.encryptionKey, @@ -111,6 +126,7 @@ export class DatabaseSecret extends secretsmanager.Secret { // If at some point we add other password customization options // they should be added here below (e.g. `passwordLength`). excludeCharacters, + urlSafePassword: props.urlSafePassword, })); const logicalId = `${Names.uniqueId(this)}${hash}`; diff --git a/packages/aws-cdk-lib/aws-rds/lib/private/util.ts b/packages/aws-cdk-lib/aws-rds/lib/private/util.ts index 886e6d5e1f9bf..a23e6d6df5c02 100644 --- a/packages/aws-cdk-lib/aws-rds/lib/private/util.ts +++ b/packages/aws-cdk-lib/aws-rds/lib/private/util.ts @@ -95,6 +95,7 @@ export function renderCredentials(scope: Construct, engine: IEngine, credentials secretName: renderedCredentials.secretName, encryptionKey: renderedCredentials.encryptionKey, excludeCharacters: renderedCredentials.excludeCharacters, + urlSafePassword: renderedCredentials.urlSafePassword, // if username must be referenced as a string we can safely replace the // secret when customization options are changed without risking a replacement replaceOnPasswordCriteriaChanges: credentials?.usernameAsString, @@ -125,6 +126,7 @@ export function renderSnapshotCredentials(scope: Construct, credentials?: Snapsh username: renderedCredentials.username, encryptionKey: renderedCredentials.encryptionKey, excludeCharacters: renderedCredentials.excludeCharacters, + urlSafePassword: renderedCredentials.urlSafePassword, replaceOnPasswordCriteriaChanges: renderedCredentials.replaceOnPasswordCriteriaChanges, replicaRegions: renderedCredentials.replicaRegions, }), @@ -169,3 +171,11 @@ export function applyDefaultRotationOptions(options: CommonRotationUserOptions, ...options, }; } +/** + * URL-safe password exclusion characters for database users. + * Extends the default exclusion set with characters that cause issues in URL parsers, + * particularly the caret (^) character which causes failures in Go's net/url parser. + * + * This constant is private to the RDS module. + */ +export const URL_SAFE_PASSWORD_EXCLUDE_CHARS = DEFAULT_PASSWORD_EXCLUDE_CHARS + '^'; diff --git a/packages/aws-cdk-lib/aws-rds/lib/props.ts b/packages/aws-cdk-lib/aws-rds/lib/props.ts index 54376a077a96f..b867133fb42b4 100644 --- a/packages/aws-cdk-lib/aws-rds/lib/props.ts +++ b/packages/aws-cdk-lib/aws-rds/lib/props.ts @@ -1,4 +1,5 @@ import { IParameterGroup } from './parameter-group'; +import { DEFAULT_PASSWORD_EXCLUDE_CHARS, URL_SAFE_PASSWORD_EXCLUDE_CHARS } from './private/util'; import * as ec2 from '../../aws-ec2'; import * as kms from '../../aws-kms'; import * as secretsmanager from '../../aws-secretsmanager'; @@ -175,6 +176,20 @@ export interface CredentialsBaseOptions { * @default - Secret is not replicated */ readonly replicaRegions?: secretsmanager.ReplicaRegion[]; + + /** + * Whether to generate a URL parser-compatible password by excluding characters that can cause issues in URL parsers. + * + * When enabled, the generated password will exclude the caret (^) character in addition to the default + * exclusion set. This specifically addresses compatibility issues with URL parsers like Go's net/url + * that fail when parsing URLs containing caret characters in the userinfo section. + * + * Note: The default exclusion set already excludes most URL-problematic characters (%, #, ?, &, @, /, etc.). + * This option adds the caret (^) character which is specifically problematic for certain URL parsers. + * + * @default false + */ + readonly urlSafePassword?: boolean; } /** @@ -315,6 +330,21 @@ export abstract class Credentials { * @default - Secret is not replicated */ public abstract readonly replicaRegions?: secretsmanager.ReplicaRegion[]; + + /** + * Whether to generate a URL parser-compatible password by excluding characters that can cause issues in URL parsers. + * Only used if `password` has not been set. + * + * When enabled, the generated password will exclude the caret (^) character in addition to the default + * exclusion set. This specifically addresses compatibility issues with URL parsers like Go's net/url + * that fail when parsing URLs containing caret characters in the userinfo section. + * + * Note: The default exclusion set already excludes most URL-problematic characters (%, #, ?, &, @, /, etc.). + * This option adds the caret (^) character which is specifically problematic for certain URL parsers. + * + * @default false + */ + public abstract readonly urlSafePassword?: boolean; } /** @@ -341,6 +371,20 @@ export interface SnapshotCredentialsFromGeneratedPasswordOptions { * @default - Secret is not replicated */ readonly replicaRegions?: secretsmanager.ReplicaRegion[]; + + /** + * Whether to generate a URL parser-compatible password by excluding characters that can cause issues in URL parsers. + * + * When enabled, the generated password will exclude the caret (^) character in addition to the default + * exclusion set. This specifically addresses compatibility issues with URL parsers like Go's net/url + * that fail when parsing URLs containing caret characters in the userinfo section. + * + * Note: The default exclusion set already excludes most URL-problematic characters (%, #, ?, &, @, /, etc.). + * This option adds the caret (^) character which is specifically problematic for certain URL parsers. + * + * @default false + */ + readonly urlSafePassword?: boolean; } /** @@ -354,8 +398,12 @@ export abstract class SnapshotCredentials { * Note - The username must match the existing master username of the snapshot. */ public static fromGeneratedSecret(username: string, options: SnapshotCredentialsFromGeneratedPasswordOptions = {}): SnapshotCredentials { + const excludeCharacters = options.excludeCharacters ?? + (options.urlSafePassword ? URL_SAFE_PASSWORD_EXCLUDE_CHARS : DEFAULT_PASSWORD_EXCLUDE_CHARS); + return { ...options, + excludeCharacters, generatePassword: true, replaceOnPasswordCriteriaChanges: true, username, @@ -464,6 +512,21 @@ export abstract class SnapshotCredentials { * @default - Secret is not replicated */ public abstract readonly replicaRegions?: secretsmanager.ReplicaRegion[]; + + /** + * Whether to generate a URL parser-compatible password by excluding characters that can cause issues in URL parsers. + * Only used if `generatePassword` is true. + * + * When enabled, the generated password will exclude the caret (^) character in addition to the default + * exclusion set. This specifically addresses compatibility issues with URL parsers like Go's net/url + * that fail when parsing URLs containing caret characters in the userinfo section. + * + * Note: The default exclusion set already excludes most URL-problematic characters (%, #, ?, &, @, /, etc.). + * This option adds the caret (^) character which is specifically problematic for certain URL parsers. + * + * @default false + */ + public abstract readonly urlSafePassword?: boolean; } /** diff --git a/packages/aws-cdk-lib/aws-rds/test/cluster.test.ts b/packages/aws-cdk-lib/aws-rds/test/cluster.test.ts index da82e0ceae05c..e5c796d23646a 100644 --- a/packages/aws-cdk-lib/aws-rds/test/cluster.test.ts +++ b/packages/aws-cdk-lib/aws-rds/test/cluster.test.ts @@ -4725,6 +4725,31 @@ describe('cluster', () => { }); }); + test('fromGeneratedSecret with urlSafePassword', () => { + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + new DatabaseClusterFromSnapshot(stack, 'Database', { + engine: DatabaseClusterEngine.auroraMysql({ version: AuroraMysqlEngineVersion.VER_3_07_1 }), + instanceProps: { + vpc, + }, + snapshotIdentifier: 'mySnapshot', + snapshotCredentials: SnapshotCredentials.fromGeneratedSecret('admin', { + urlSafePassword: true, + }), + }); + + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + GenerateSecretString: { + ExcludeCharacters: ' %+~`#$&*()|[]{}:;<>?!\'/@"\\^', + GenerateStringKey: 'password', + PasswordLength: 30, + SecretStringTemplate: '{"username":"admin"}', + }, + }); + }); + test('throws if generating a new password without a username', () => { const stack = testStack(); const vpc = new ec2.Vpc(stack, 'VPC'); diff --git a/packages/aws-cdk-lib/aws-rds/test/database-secret.test.ts b/packages/aws-cdk-lib/aws-rds/test/database-secret.test.ts index ee18ded34397a..9835e9cc528b3 100644 --- a/packages/aws-cdk-lib/aws-rds/test/database-secret.test.ts +++ b/packages/aws-cdk-lib/aws-rds/test/database-secret.test.ts @@ -1,7 +1,7 @@ import { Template } from '../../assertions'; import { CfnResource, Stack } from '../../core'; import { DatabaseSecret } from '../lib'; -import { DEFAULT_PASSWORD_EXCLUDE_CHARS } from '../lib/private/util'; +import { DEFAULT_PASSWORD_EXCLUDE_CHARS, URL_SAFE_PASSWORD_EXCLUDE_CHARS } from '../lib/private/util'; describe('database secret', () => { test('create a database secret', () => { @@ -139,6 +139,140 @@ describe('database secret', () => { }); expect(dbSecretlogicalId).not.toEqual(getSecretLogicalId(otherSecret2, stack)); }); + + test('create a database secret with URL-safe password', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const dbSecret = new DatabaseSecret(stack, 'Secret', { + username: 'admin-username', + urlSafePassword: true, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + Description: { + 'Fn::Join': [ + '', + [ + 'Generated by the CDK for stack: ', + { + Ref: 'AWS::StackName', + }, + ], + ], + }, + GenerateSecretString: { + ExcludeCharacters: URL_SAFE_PASSWORD_EXCLUDE_CHARS, + GenerateStringKey: 'password', + PasswordLength: 30, + SecretStringTemplate: '{"username":"admin-username"}', + }, + }); + + expect(getSecretLogicalId(dbSecret, stack)).toEqual('SecretA720EF05'); + }); + + test('URL-safe password excludes caret character', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new DatabaseSecret(stack, 'Secret', { + username: 'admin-username', + urlSafePassword: true, + }); + + // THEN + const template = Template.fromStack(stack); + const resources = template.findResources('AWS::SecretsManager::Secret'); + const secretResource = Object.values(resources)[0]; + const excludeCharacters = secretResource.Properties.GenerateSecretString.ExcludeCharacters; + + expect(excludeCharacters).toContain('^'); + expect(excludeCharacters).toEqual(URL_SAFE_PASSWORD_EXCLUDE_CHARS); + }); + + test('explicit excludeCharacters takes precedence over urlSafePassword', () => { + // GIVEN + const stack = new Stack(); + const customExcludeChars = 'abc123'; + + // WHEN + new DatabaseSecret(stack, 'Secret', { + username: 'admin-username', + urlSafePassword: true, + excludeCharacters: customExcludeChars, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + GenerateSecretString: { + ExcludeCharacters: customExcludeChars, + }, + }); + }); + + test('urlSafePassword affects logical ID when replaceOnPasswordCriteriaChanges is true', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const dbSecret1 = new DatabaseSecret(stack, 'Secret1', { + username: 'admin', + replaceOnPasswordCriteriaChanges: true, + urlSafePassword: false, + }); + + const dbSecret2 = new DatabaseSecret(stack, 'Secret2', { + username: 'admin', + replaceOnPasswordCriteriaChanges: true, + urlSafePassword: true, + }); + + // THEN + const logicalId1 = getSecretLogicalId(dbSecret1, stack); + const logicalId2 = getSecretLogicalId(dbSecret2, stack); + expect(logicalId1).not.toEqual(logicalId2); + }); + + test('urlSafePassword with master secret', () => { + // GIVEN + const stack = new Stack(); + const masterSecret = new DatabaseSecret(stack, 'MasterSecret', { + username: 'master-username', + urlSafePassword: true, + }); + + // WHEN + new DatabaseSecret(stack, 'UserSecret', { + username: 'user-username', + masterSecret, + urlSafePassword: true, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + GenerateSecretString: { + ExcludeCharacters: URL_SAFE_PASSWORD_EXCLUDE_CHARS, + GenerateStringKey: 'password', + PasswordLength: 30, + SecretStringTemplate: { + 'Fn::Join': [ + '', + [ + '{"username":"user-username","masterarn":"', + { + Ref: 'MasterSecretA11BF785', + }, + '"}', + ], + ], + }, + }, + }); + }); }); function getSecretLogicalId(dbSecret: DatabaseSecret, stack: Stack): string { diff --git a/packages/aws-cdk-lib/aws-rds/test/instance.test.ts b/packages/aws-cdk-lib/aws-rds/test/instance.test.ts index b9ce6e3da141e..04f19e7dbad0e 100644 --- a/packages/aws-cdk-lib/aws-rds/test/instance.test.ts +++ b/packages/aws-cdk-lib/aws-rds/test/instance.test.ts @@ -524,6 +524,26 @@ describe('instance', () => { }); }); + test('fromGeneratedSecret with urlSafePassword', () => { + new rds.DatabaseInstanceFromSnapshot(stack, 'Instance', { + snapshotIdentifier: 'my-snapshot', + engine: rds.DatabaseInstanceEngine.mysql({ version: rds.MysqlEngineVersion.VER_8_0_19 }), + vpc, + credentials: rds.SnapshotCredentials.fromGeneratedSecret('admin', { + urlSafePassword: true, + }), + }); + + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + GenerateSecretString: { + ExcludeCharacters: ' %+~`#$&*()|[]{}:;<>?!\'/@"\\^', + GenerateStringKey: 'password', + PasswordLength: 30, + SecretStringTemplate: '{"username":"admin"}', + }, + }); + }); + test('throws if generating a new password without a username', () => { expect(() => new rds.DatabaseInstanceFromSnapshot(stack, 'Instance', { snapshotIdentifier: 'my-snapshot',