From c3ab00c4b1345293f904c128d96c83ab6de6b3b7 Mon Sep 17 00:00:00 2001 From: Maddison Das Date: Thu, 30 Jul 2026 11:22:26 +1000 Subject: [PATCH] Escape single quotes in PowerShell login script inputs Apply the same single-quote escape already used for the service principal secret to the remaining values interpolated into the generated PowerShell login script: tenant-id, subscription-id, client-id, federated token, and the AzureStack resourceManagerEndpointUrl. Factor the escape into a shared helper (escapePSSingleQuoted) so every interpolation site uses the same treatment, and add regression tests that assert each field is escaped across all supported auth paths. No behaviour change for valid inputs; legitimate values contain no single quotes. --- .../PowerShell/AzPSScriptBuilder.test.ts | 153 ++++++++++++++++++ src/PowerShell/AzPSScriptBuilder.ts | 28 +++- 2 files changed, 173 insertions(+), 8 deletions(-) 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;