diff --git a/__tests__/PowerShell/AzPSScriptBuilder.test.ts b/__tests__/PowerShell/AzPSScriptBuilder.test.ts index 862d7a0f5..76b32c244 100644 --- a/__tests__/PowerShell/AzPSScriptBuilder.test.ts +++ b/__tests__/PowerShell/AzPSScriptBuilder.test.ts @@ -150,4 +150,157 @@ describe("Getting AzLogin PS script", () => { }); }); + const INJECT_RAW = "abc' ; Start-Process calc ; $x='"; + const INJECT_ESCAPED = "abc'' ; Start-Process calc ; $x=''"; + + test('SECURITY: tenant-id single quote is escaped (SP+secret path)', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'true'); + setEnv('auth-type', 'SERVICE_PRINCIPAL'); + let creds = { + 'clientId': 'client-id', + 'clientSecret': 'client-secret', + 'tenantId': INJECT_RAW, + 'subscriptionId': 'subscription-id' + } + setEnv('creds', JSON.stringify(creds)); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([_, loginScript]) => { + expect(loginScript).toContain(`-Tenant '${INJECT_ESCAPED}'`); + expect(loginScript).not.toContain(`-Tenant '${INJECT_RAW}'`); + }); + }); + + test('SECURITY: subscription-id single quote is escaped (SP+secret path)', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'true'); + setEnv('auth-type', 'SERVICE_PRINCIPAL'); + let creds = { + 'clientId': 'client-id', + 'clientSecret': 'client-secret', + 'tenantId': 'tenant-id', + 'subscriptionId': INJECT_RAW + } + setEnv('creds', JSON.stringify(creds)); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([_, loginScript]) => { + expect(loginScript).toContain(`-Subscription '${INJECT_ESCAPED}'`); + expect(loginScript).not.toContain(`-Subscription '${INJECT_RAW}'`); + }); + }); + + test('SECURITY: client-id single quote is escaped (SP+secret path, PSCredential)', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'true'); + setEnv('auth-type', 'SERVICE_PRINCIPAL'); + let creds = { + 'clientId': INJECT_RAW, + 'clientSecret': 'client-secret', + 'tenantId': 'tenant-id', + 'subscriptionId': 'subscription-id' + } + setEnv('creds', JSON.stringify(creds)); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([_, loginScript]) => { + expect(loginScript).toContain(`New-Object System.Management.Automation.PSCredential('${INJECT_ESCAPED}',`); + expect(loginScript).not.toContain(`New-Object System.Management.Automation.PSCredential('${INJECT_RAW}',`); + }); + }); + + test('SECURITY: client-id single quote is escaped (OIDC path)', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'false'); + setEnv('tenant-id', 'tenant-id'); + setEnv('subscription-id', 'subscription-id'); + setEnv('client-id', INJECT_RAW); + setEnv('auth-type', 'SERVICE_PRINCIPAL'); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + jest.spyOn(loginConfig, 'getFederatedToken').mockImplementation(async () => { loginConfig.federatedToken = "fake-token"; }); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([_, loginScript]) => { + expect(loginScript).toContain(`-ApplicationId '${INJECT_ESCAPED}'`); + expect(loginScript).not.toContain(`-ApplicationId '${INJECT_RAW}'`); + }); + }); + + test('SECURITY: client-id single quote is escaped (user-assigned MI path)', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'true'); + setEnv('auth-type', 'IDENTITY'); + setEnv('client-id', INJECT_RAW); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([_, loginScript]) => { + expect(loginScript).toContain(`-AccountId '${INJECT_ESCAPED}'`); + expect(loginScript).not.toContain(`-AccountId '${INJECT_RAW}'`); + }); + }); + + test('SECURITY: tenant-id and subscription-id single quotes are escaped (system-assigned MI path)', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'false'); + setEnv('tenant-id', INJECT_RAW); + setEnv('subscription-id', INJECT_RAW); + setEnv('auth-type', 'IDENTITY'); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([_, loginScript]) => { + expect(loginScript).toContain(`-Tenant '${INJECT_ESCAPED}'`); + expect(loginScript).toContain(`-Subscription '${INJECT_ESCAPED}'`); + expect(loginScript).not.toContain(`-Tenant '${INJECT_RAW}'`); + expect(loginScript).not.toContain(`-Subscription '${INJECT_RAW}'`); + }); + }); + + test('SECURITY: resourceManagerEndpointUrl single quote is escaped (AzureStack path)', () => { + setEnv('environment', 'azurestack'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'true'); + setEnv('auth-type', 'SERVICE_PRINCIPAL'); + let creds = { + 'clientId': 'client-id', + 'clientSecret': 'client-secret', + 'tenantId': 'tenant-id', + 'subscriptionId': 'subscription-id', + 'resourceManagerEndpointUrl': INJECT_RAW + } + setEnv('creds', JSON.stringify(creds)); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([_, loginScript]) => { + expect(loginScript).toContain(`-ARMEndpoint '${INJECT_ESCAPED}'`); + expect(loginScript).not.toContain(`-ARMEndpoint '${INJECT_RAW}'`); + }); + }); + + test('SECURITY: escapePSSingleQuoted handles null/undefined without throwing', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'false'); + setEnv('auth-type', 'IDENTITY'); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => { + expect(loginScript).toContain("Connect-AzAccount -Identity -Environment 'azurecloud'"); + expect(loginMethod).toBe('system-assigned managed identity'); + }); + }); + }); \ No newline at end of file diff --git a/src/PowerShell/AzPSScriptBuilder.ts b/src/PowerShell/AzPSScriptBuilder.ts index 5cd34580f..8b6b05824 100644 --- a/src/PowerShell/AzPSScriptBuilder.ts +++ b/src/PowerShell/AzPSScriptBuilder.ts @@ -21,12 +21,20 @@ export default class AzPSScriptBuilder { return script; } + // Doubles single quotes for safe interpolation into a PowerShell '...' literal. + private static escapePSSingleQuoted(value: string): string { + if (value === null || value === undefined) { + return ""; + } + return String(value).split("'").join("''"); + } + static async getAzPSLoginScript(loginConfig: LoginConfig) { let loginMethodName = ""; let commands = ""; if (loginConfig.environment.toLowerCase() == "azurestack") { - commands += `Add-AzEnvironment -Name '${loginConfig.environment}' -ARMEndpoint '${loginConfig.resourceManagerEndpointUrl}' | out-null;`; + commands += `Add-AzEnvironment -Name '${loginConfig.environment}' -ARMEndpoint '${AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.resourceManagerEndpointUrl)}' | out-null;`; } if (loginConfig.authType === LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL) { if (loginConfig.servicePrincipalSecret) { @@ -64,10 +72,11 @@ export default class AzPSScriptBuilder { } private static loginWithSecret(loginConfig: LoginConfig): string { - let servicePrincipalSecret: string = loginConfig.servicePrincipalSecret.split("'").join("''"); + let servicePrincipalSecret: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.servicePrincipalSecret); + let servicePrincipalId: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.servicePrincipalId); let loginCmdlet = `$psLoginSecrets = ConvertTo-SecureString '${servicePrincipalSecret}' -AsPlainText -Force; `; - loginCmdlet += `$psLoginCredential = New-Object System.Management.Automation.PSCredential('${loginConfig.servicePrincipalId}', $psLoginSecrets); `; - + loginCmdlet += `$psLoginCredential = New-Object System.Management.Automation.PSCredential('${servicePrincipalId}', $psLoginSecrets); `; + let cmdletSuffix = "-Credential $psLoginCredential"; loginCmdlet += AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix); @@ -76,7 +85,9 @@ export default class AzPSScriptBuilder { private static async loginWithOIDC(loginConfig: LoginConfig) { await loginConfig.getFederatedToken(); - let cmdletSuffix = `-ApplicationId '${loginConfig.servicePrincipalId}' -FederatedToken '${loginConfig.federatedToken}'`; + let servicePrincipalId: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.servicePrincipalId); + let federatedToken: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.federatedToken); + let cmdletSuffix = `-ApplicationId '${servicePrincipalId}' -FederatedToken '${federatedToken}'`; return AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix); } @@ -86,7 +97,8 @@ export default class AzPSScriptBuilder { } static loginWithUserAssignedIdentity(loginConfig: LoginConfig): string { - let cmdletSuffix = `-AccountId '${loginConfig.servicePrincipalId}'`; + let servicePrincipalId: string = AzPSScriptBuilder.escapePSSingleQuoted(loginConfig.servicePrincipalId); + let cmdletSuffix = `-AccountId '${servicePrincipalId}'`; return AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix); } @@ -99,10 +111,10 @@ export default class AzPSScriptBuilder { } loginCmdlet += `-Environment '${environment}' `; if(tenantId){ - loginCmdlet += `-Tenant '${tenantId}' `; + loginCmdlet += `-Tenant '${AzPSScriptBuilder.escapePSSingleQuoted(tenantId)}' `; } if(subscriptionId){ - loginCmdlet += `-Subscription '${subscriptionId}' `; + loginCmdlet += `-Subscription '${AzPSScriptBuilder.escapePSSingleQuoted(subscriptionId)}' `; } loginCmdlet += `${cmdletSuffix} -InformationAction Ignore | out-null;`; return loginCmdlet;