diff --git a/src/Authentication/Authentication.Test/Utilities/Runtime/Cmdlets/StringEscapingTests.cs b/src/Authentication/Authentication.Test/Utilities/Runtime/Cmdlets/StringEscapingTests.cs new file mode 100644 index 00000000000..e75584d4c93 --- /dev/null +++ b/src/Authentication/Authentication.Test/Utilities/Runtime/Cmdlets/StringEscapingTests.cs @@ -0,0 +1,126 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; +using Xunit; + +namespace Microsoft.Graph.Authentication.Test.Utilities.Runtime.Cmdlets +{ + /// + /// Tests to verify that path escaping works correctly for PowerShell commands. + /// These tests validate the fix for CVE-like vulnerability where paths with single quotes + /// could break PowerShell command syntax or potentially allow command injection. + /// + public class StringEscapingTests + { + public static IEnumerable PathsWithSingleQuotes => + new List + { + new object[] { "C:\\User's Documents\\Module.psd1", "C:\\User''s Documents\\Module.psd1" }, + new object[] { "C:\\Test's\\Path\\File.ps1", "C:\\Test''s\\Path\\File.ps1" }, + new object[] { "C:\\Users\\John's Folder\\Scripts", "C:\\Users\\John''s Folder\\Scripts" }, + new object[] { "C:\\It's\\Working\\Test.psm1", "C:\\It''s\\Working\\Test.psm1" }, + new object[] { "C:\\Multiple'Single'Quotes\\File.ps1", "C:\\Multiple''Single''Quotes\\File.ps1" } + }; + + public static IEnumerable PathsWithoutSingleQuotes => + new List + { + new object[] { "C:\\Users\\Documents\\Module.psd1" }, + new object[] { "C:\\Windows\\System32\\Test.ps1" }, + new object[] { "C:\\Program Files\\Application\\Script.psm1" } + }; + + public static IEnumerable MaliciousPaths => + new List + { + // Path that attempts command injection + new object[] { "C:\\Test'; Write-Output 'INJECTED'; '\\Module.psd1", "C:\\Test''; Write-Output ''INJECTED''; ''\\Module.psd1" }, + // Path that attempts to close the string and run additional command + new object[] { "C:\\Malicious' -and $true -eq $true #\\Test.ps1", "C:\\Malicious'' -and $true -eq $true #\\Test.ps1" } + }; + + [Theory] + [MemberData(nameof(PathsWithSingleQuotes))] + public void EscapeSingleQuotedStringContent_WithSingleQuote_ShouldDoubleTheQuote(string input, string expected) + { + // Act + var result = CodeGeneration.EscapeSingleQuotedStringContent(input); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [MemberData(nameof(PathsWithoutSingleQuotes))] + public void EscapeSingleQuotedStringContent_WithoutSingleQuote_ShouldReturnUnchanged(string input) + { + // Act + var result = CodeGeneration.EscapeSingleQuotedStringContent(input); + + // Assert + Assert.Equal(input, result); + } + + [Theory] + [MemberData(nameof(MaliciousPaths))] + public void EscapeSingleQuotedStringContent_WithMaliciousInput_ShouldEscapeAllQuotes(string input, string expected) + { + // Act + var result = CodeGeneration.EscapeSingleQuotedStringContent(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeSingleQuotedStringContent_WithEmptyString_ShouldReturnEmpty() + { + // Arrange + var input = string.Empty; + + // Act + var result = CodeGeneration.EscapeSingleQuotedStringContent(input); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void EscapedPath_WhenUsedInPowerShellCommand_ShouldNotBreakSyntax() + { + // Arrange + var pathWithQuote = "C:\\User's Documents\\Module.psd1"; + var escapedPath = CodeGeneration.EscapeSingleQuotedStringContent(pathWithQuote); + + // Act - Simulate the command construction like in GetModuleCmdlet + var command = $"(Get-Command -Module (Import-Module '{escapedPath}' -PassThru))"; + + // Assert - Verify the command has properly escaped quotes + Assert.Contains("User''s Documents", command); + Assert.DoesNotContain("User's Documents", command); + + // Verify opening and closing quotes match + var singleQuoteCount = command.Count(c => c == '\''); + Assert.Equal(4, singleQuoteCount); // 2 pairs of quotes around the path + } + + [Fact] + public void EscapedScriptFolder_WhenUsedInPowerShellCommand_ShouldNotBreakSyntax() + { + // Arrange + var folderWithQuote = "C:\\User's Scripts"; + var escapedFolder = CodeGeneration.EscapeSingleQuotedStringContent(folderWithQuote); + + // Act - Simulate the command construction like in GetScriptCmdlet + var command = $"Get-ChildItem -Path '{escapedFolder}' -Recurse -Include '*.ps1' -File"; + + // Assert - Verify the command has properly escaped quotes + Assert.Contains("User''s Scripts", command); + Assert.DoesNotContain("User's Scripts", command); + } + } +} diff --git a/src/Authentication/Authentication/Utilities/Runtime/Cmdlets/GetModuleCmdlet.cs b/src/Authentication/Authentication/Utilities/Runtime/Cmdlets/GetModuleCmdlet.cs index 520fb2a6afe..1f78b8ee49d 100644 --- a/src/Authentication/Authentication/Utilities/Runtime/Cmdlets/GetModuleCmdlet.cs +++ b/src/Authentication/Authentication/Utilities/Runtime/Cmdlets/GetModuleCmdlet.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Management.Automation; +using System.Management.Automation.Language; namespace Microsoft.Graph.PowerShell.Authentication.Utilities.Runtime.Cmdlets { @@ -70,7 +71,8 @@ protected override void ProcessRecord() private IEnumerable GetModuleCmdlets(string modulePath) { - var getCmdletsCommand = $"(Get-Command -Module (Import-Module '{modulePath}' -PassThru))"; + var escapedModulePath = CodeGeneration.EscapeSingleQuotedStringContent(modulePath); + var getCmdletsCommand = $"(Get-Command -Module (Import-Module '{escapedModulePath}' -PassThru))"; return PSCmdletExtensions.RunScript(getCmdletsCommand); } } diff --git a/src/Authentication/Authentication/Utilities/Runtime/Cmdlets/GetScriptCmdlet.cs b/src/Authentication/Authentication/Utilities/Runtime/Cmdlets/GetScriptCmdlet.cs index 6c3053d3d5b..737188325e8 100644 --- a/src/Authentication/Authentication/Utilities/Runtime/Cmdlets/GetScriptCmdlet.cs +++ b/src/Authentication/Authentication/Utilities/Runtime/Cmdlets/GetScriptCmdlet.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Management.Automation; +using System.Management.Automation.Language; namespace Microsoft.Graph.PowerShell.Authentication.Utilities.Runtime.Cmdlets { @@ -67,9 +68,10 @@ protected override void ProcessRecord() private IEnumerable GetScriptCmdlets(string scriptFolder) { // https://stackoverflow.com/a/40969712/294804 + var escapedScriptFolder = CodeGeneration.EscapeSingleQuotedStringContent(scriptFolder); var getCmdletsCommand = $@" $currentFunctions = Get-ChildItem function: - Get-ChildItem -Path '{scriptFolder}' -Recurse -Include '*.ps1' -File | ForEach-Object {{ . $_.FullName }} + Get-ChildItem -Path '{escapedScriptFolder}' -Recurse -Include '*.ps1' -File | ForEach-Object {{ . $_.FullName }} Get-ChildItem function: | Where-Object {{ ($currentFunctions -notcontains $_) -and $_.CmdletBinding }} "; return this.RunScript(getCmdletsCommand);