From 6994487a9bbf479a796a42caa68bf40ca184a22a Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 1 Feb 2026 10:09:08 +0100 Subject: [PATCH 1/4] SqlReplication: Add T-SQL string escaping functions --- CHANGELOG.md | 14 ++ .../DSC_SqlReplication.psm1 | 32 ++++- .../Private/ConvertTo-EscapedQueryString.ps1 | 74 ++++++++++ source/Private/ConvertTo-SqlString.ps1 | 62 ++++++++ .../ConvertTo-EscapedQueryString.Tests.ps1 | 133 ++++++++++++++++++ .../Private/ConvertTo-SqlString.Tests.ps1 | 129 +++++++++++++++++ 6 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 source/Private/ConvertTo-EscapedQueryString.ps1 create mode 100644 source/Private/ConvertTo-SqlString.ps1 create mode 100644 tests/Unit/Private/ConvertTo-EscapedQueryString.Tests.ps1 create mode 100644 tests/Unit/Private/ConvertTo-SqlString.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c8d59a1..62f7cc1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- SqlServerDsc + - Added private function `ConvertTo-SqlString` that escapes a string for use + in T-SQL string literals by doubling single quotes + ([issue #2442](https://github.com/dsccommunity/SqlServerDsc/issues/2442)). + - Added private function `ConvertTo-EscapedQueryString` that safely escapes + single quotes in T-SQL query arguments to prevent SQL injection vulnerabilities + ([issue #2442](https://github.com/dsccommunity/SqlServerDsc/issues/2442)). +- SqlReplication + - Updated `Install-RemoteDistributor` to escape T-SQL arguments when building + the T-SQL query for SQL Server 2025 containing special characters + ([issue #2442](https://github.com/dsccommunity/SqlServerDsc/issues/2442)). + ### Changed - SqlServerDsc diff --git a/source/DSCResources/DSC_SqlReplication/DSC_SqlReplication.psm1 b/source/DSCResources/DSC_SqlReplication/DSC_SqlReplication.psm1 index b359f7ad7..26fb18a06 100644 --- a/source/DSCResources/DSC_SqlReplication/DSC_SqlReplication.psm1 +++ b/source/DSCResources/DSC_SqlReplication/DSC_SqlReplication.psm1 @@ -633,22 +633,46 @@ function Install-RemoteDistributor $sqlMajorVersion = $serverObject.VersionMajor + # cSpell:ignore sp_adddistributor if ($sqlMajorVersion -eq 17) { $clearTextPassword = $AdminLinkCredentials.GetNetworkCredential().Password + <# + Escape single quotes by doubling them for T-SQL string literals. + This prevents SQL injection and ensures the escaped value matches + what appears in the final query for proper redaction. + + TODO: When this resource is converted to a class-based resource + we need to convert to escaped string for the redaction as well, + replace this inline logic with the private function: + $escapedPassword = ConvertTo-SqlString -Text $clearTextPassword + #> + $escapedPassword = $clearTextPassword -replace "'", "''" + <# Need to execute stored procedure sp_adddistributor for SQL Server 2025. Workaround for issue: https://github.com/dsccommunity/SqlServerDsc/pull/2435#issuecomment-3796616952 TODO: Should support encrypted connection in the future, then we could - probably go back to using InstallDistributor(), another option is - to move to stored procedures for all of the Replication logic. + probably go back to using InstallDistributor() instead of calling + the stored procedure, another option is to move to stored procedures + for all of the Replication logic, for all SQL Server versions. + #> + + <# + Escape arguments for the T-SQL query to prevent SQL injection. + + TODO: When this resource is converted to a class-based resource, + replace this inline logic with the private function: + $unescapedQuery = "EXECUTE sys.sp_adddistributor @distributor = N'${0}', @password = N'${1}', @encrypt_distributor_connection = 'optional', @trust_distributor_certificate = 'yes';" + $query = ConvertTo-EscapedQueryString -Query $unescapedQuery -Argument $RemoteDistributor, $clearTextPassword #> - $query = "EXECUTE sys.sp_adddistributor @distributor = N'$RemoteDistributor', @password = N'$clearTextPassword', @encrypt_distributor_connection = 'optional', @trust_distributor_certificate = 'yes';" + $escapedRemoteDistributor = $RemoteDistributor -replace "'", "''" + $query = "EXECUTE sys.sp_adddistributor @distributor = N'$escapedRemoteDistributor', @password = N'$escapedPassword', @encrypt_distributor_connection = 'optional', @trust_distributor_certificate = 'yes';" # TODO: This need to pass a credential in the future, now connects using the one resource is run as. - Invoke-SqlDscQuery -ServerObject $serverObject -DatabaseName 'master' -Query $query -RedactText $clearTextPassword -Force -ErrorAction 'Stop' + Invoke-SqlDscQuery -ServerObject $serverObject -DatabaseName 'master' -Query $query -RedactText $escapedPassword -Force -ErrorAction 'Stop' } else { diff --git a/source/Private/ConvertTo-EscapedQueryString.ps1 b/source/Private/ConvertTo-EscapedQueryString.ps1 new file mode 100644 index 000000000..46b4fd653 --- /dev/null +++ b/source/Private/ConvertTo-EscapedQueryString.ps1 @@ -0,0 +1,74 @@ +<# + .SYNOPSIS + Formats a query string with escaped values to prevent SQL injection. + + .DESCRIPTION + This function formats a query string with placeholders using provided + arguments. Each argument is escaped by doubling single quotes to prevent + SQL injection vulnerabilities. This is the standard escaping mechanism + for SQL Server string literals. + + The function takes a format string with standard PowerShell format + placeholders (e.g., {0}, {1}) and an array of arguments to substitute + into those placeholders. Each argument is escaped before substitution. + + .PARAMETER Query + Specifies the query string containing format placeholders (e.g., {0}, {1}). + The placeholders will be replaced with the escaped values from the + Argument parameter. + + .PARAMETER Argument + Specifies an array of strings that will be used to format the query string. + Each string will have single quotes escaped by doubling them before being + substituted into the query. + + .EXAMPLE + ConvertTo-EscapedQueryString -Query "SELECT * FROM Users WHERE Name = N'{0}'" -Argument "O'Brien" + + Returns: SELECT * FROM Users WHERE Name = N'O''Brien' + + .EXAMPLE + ConvertTo-EscapedQueryString -Query "EXECUTE sys.sp_adddistributor @distributor = N'{0}', @password = N'{1}';" -Argument 'Server1', "Pass'word;123" + + Returns: EXECUTE sys.sp_adddistributor @distributor = N'Server1', @password = N'Pass''word;123'; + + .INPUTS + None. + + .OUTPUTS + System.String + + Returns the formatted query string with escaped values. + + .NOTES + This function escapes single quotes by doubling them, which is the + standard SQL Server escaping mechanism for string literals. This helps + prevent SQL injection when embedding values in dynamic T-SQL queries. +#> +function ConvertTo-EscapedQueryString +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Query, + + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [System.String[]] + $Argument + ) + + $escapedArguments = @() + + foreach ($arg in $Argument) + { + $escapedArguments += ConvertTo-SqlString -Text $arg + } + + $result = $Query -f $escapedArguments + + return $result +} diff --git a/source/Private/ConvertTo-SqlString.ps1 b/source/Private/ConvertTo-SqlString.ps1 new file mode 100644 index 000000000..f5d1d1131 --- /dev/null +++ b/source/Private/ConvertTo-SqlString.ps1 @@ -0,0 +1,62 @@ +<# + .SYNOPSIS + Escapes a string for use in a T-SQL string literal. + + .DESCRIPTION + This function escapes a string for safe use in T-SQL string literals by + doubling single quotes. This is the standard SQL Server escaping mechanism + for preventing SQL injection when embedding values in dynamic T-SQL queries. + + Use this function when you need to escape a value that will also be used + elsewhere (e.g., for redaction), ensuring the escaped value matches what + appears in the final query. + + .PARAMETER Text + Specifies the text string to escape for T-SQL. + + .EXAMPLE + ConvertTo-SqlString -Text "O'Brien" + + Returns: O''Brien + + .EXAMPLE + ConvertTo-SqlString -Text "Pass'word;123" + + Returns: Pass''word;123 + + .EXAMPLE + $escapedPassword = ConvertTo-SqlString -Text $password + $query = "EXECUTE sys.sp_adddistributor @password = N'$escapedPassword';" + Invoke-SqlDscQuery -Query $query -RedactText $escapedPassword + + Escapes the password and uses the same escaped value for both the query + and the RedactText parameter to ensure proper redaction. + + .INPUTS + None. + + .OUTPUTS + System.String + + Returns the escaped string with single quotes doubled. + + .NOTES + This function only escapes single quotes by doubling them. This is + sufficient for SQL Server string literals enclosed in single quotes. +#> +function ConvertTo-SqlString +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [System.String] + $Text + ) + + $escapedText = $Text -replace "'", "''" + + return $escapedText +} diff --git a/tests/Unit/Private/ConvertTo-EscapedQueryString.Tests.ps1 b/tests/Unit/Private/ConvertTo-EscapedQueryString.Tests.ps1 new file mode 100644 index 000000000..bcdbd3243 --- /dev/null +++ b/tests/Unit/Private/ConvertTo-EscapedQueryString.Tests.ps1 @@ -0,0 +1,133 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = 'SqlServerDsc' + + $env:SqlServerDscCI = $true + + Import-Module -Name $script:moduleName -ErrorAction 'Stop' + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Remove-Item -Path 'env:SqlServerDscCI' +} + +Describe 'ConvertTo-EscapedQueryString' -Tag 'Private' { + Context 'When escaping single quotes in query arguments' { + It 'Should escape a single quote in an argument' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-EscapedQueryString -Query "SELECT * FROM Users WHERE Name = N'{0}'" -Argument "O'Brien" + + $result | Should -Be "SELECT * FROM Users WHERE Name = N'O''Brien'" + } + } + + It 'Should escape multiple single quotes in an argument' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-EscapedQueryString -Query "SELECT * FROM Users WHERE Name = N'{0}'" -Argument "O'Brien's" + + $result | Should -Be "SELECT * FROM Users WHERE Name = N'O''Brien''s'" + } + } + + It 'Should handle arguments without single quotes' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-EscapedQueryString -Query "SELECT * FROM Users WHERE Name = N'{0}'" -Argument 'Smith' + + $result | Should -Be "SELECT * FROM Users WHERE Name = N'Smith'" + } + } + } + + Context 'When formatting a query with multiple arguments' { + It 'Should escape single quotes in all arguments' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-EscapedQueryString -Query "EXECUTE sys.sp_adddistributor @distributor = N'{0}', @password = N'{1}';" -Argument 'Server1', "Pass'word;123" + + $result | Should -Be "EXECUTE sys.sp_adddistributor @distributor = N'Server1', @password = N'Pass''word;123';" + } + } + + It 'Should handle multiple arguments with single quotes' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-EscapedQueryString -Query "INSERT INTO Users (FirstName, LastName) VALUES (N'{0}', N'{1}')" -Argument "Mary's", "O'Connor" + + $result | Should -Be "INSERT INTO Users (FirstName, LastName) VALUES (N'Mary''s', N'O''Connor')" + } + } + } + + Context 'When handling special characters that could be used for SQL injection' { + It 'Should escape single quotes in passwords with special characters' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + # Password with single quote, semicolon, and dashes + $result = ConvertTo-EscapedQueryString -Query "EXECUTE sys.sp_adddistributor @password = N'{0}';" -Argument "Pass'word;--DROP TABLE Users" + + $result | Should -Be "EXECUTE sys.sp_adddistributor @password = N'Pass''word;--DROP TABLE Users';" + } + } + + It 'Should handle argument with only single quotes' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-EscapedQueryString -Query "SELECT N'{0}'" -Argument "'''" + + $result | Should -Be "SELECT N''''''''" + } + } + + It 'Should handle empty string argument' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-EscapedQueryString -Query "SELECT N'{0}'" -Argument '' + + $result | Should -Be "SELECT N''" + } + } + } +} diff --git a/tests/Unit/Private/ConvertTo-SqlString.Tests.ps1 b/tests/Unit/Private/ConvertTo-SqlString.Tests.ps1 new file mode 100644 index 000000000..88bce3bac --- /dev/null +++ b/tests/Unit/Private/ConvertTo-SqlString.Tests.ps1 @@ -0,0 +1,129 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies have been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies have not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.' + } +} + +BeforeAll { + $script:moduleName = 'SqlServerDsc' + + $env:SqlServerDscCI = $true + + Import-Module -Name $script:moduleName -ErrorAction 'Stop' + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Remove-Item -Path 'env:SqlServerDscCI' +} + +Describe 'ConvertTo-SqlString' -Tag 'Private' { + Context 'When escaping single quotes' { + It 'Should escape a single quote in a string' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-SqlString -Text "O'Brien" + + $result | Should -Be "O''Brien" + } + } + + It 'Should escape multiple single quotes in a string' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-SqlString -Text "O'Brien's" + + $result | Should -Be "O''Brien''s" + } + } + + It 'Should return the same string when no single quotes present' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-SqlString -Text 'Smith' + + $result | Should -Be 'Smith' + } + } + } + + Context 'When handling special characters' { + It 'Should escape single quotes in passwords with special characters' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-SqlString -Text "Pass'word;--123" + + $result | Should -Be "Pass''word;--123" + } + } + + It 'Should handle string with only single quotes' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-SqlString -Text "'''" + + $result | Should -Be "''''''" + } + } + + It 'Should handle empty string' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $result = ConvertTo-SqlString -Text '' + + $result | Should -Be '' + } + } + } + + Context 'When used with ConvertTo-EscapedQueryString' { + It 'Should produce matching escaped values for redaction' { + InModuleScope -ScriptBlock { + Set-StrictMode -Version 1.0 + + $password = "Pass'word;123" + $escapedPassword = ConvertTo-SqlString -Text $password + + $query = ConvertTo-EscapedQueryString -Query "EXECUTE sp_test @password = N'{0}';" -Argument $password + + # The escaped password should appear in the query + $query | Should -BeLike "*$escapedPassword*" + + # The escaped password should be "Pass''word;123" + $escapedPassword | Should -Be "Pass''word;123" + } + } + } +} From e6b7ead396b6476d8c0c6841ff76fd227069d648 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 1 Feb 2026 10:17:58 +0100 Subject: [PATCH 2/4] Fix formatting in ConvertTo-EscapedQueryString function and improve variable naming --- source/Private/ConvertTo-EscapedQueryString.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/Private/ConvertTo-EscapedQueryString.ps1 b/source/Private/ConvertTo-EscapedQueryString.ps1 index 46b4fd653..85ecdd87b 100644 --- a/source/Private/ConvertTo-EscapedQueryString.ps1 +++ b/source/Private/ConvertTo-EscapedQueryString.ps1 @@ -36,7 +36,7 @@ None. .OUTPUTS - System.String + `System.String` Returns the formatted query string with escaped values. @@ -63,9 +63,9 @@ function ConvertTo-EscapedQueryString $escapedArguments = @() - foreach ($arg in $Argument) + foreach ($currentArgument in $Argument) { - $escapedArguments += ConvertTo-SqlString -Text $arg + $escapedArguments += ConvertTo-SqlString -Text $currentArgument } $result = $Query -f $escapedArguments From aa2197b28e96453e64727b0c1a4b949b87eb7ae2 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 1 Feb 2026 10:18:49 +0100 Subject: [PATCH 3/4] Update CHANGELOG to reflect added T-SQL string escaping functions and enhancements to Install-RemoteDistributor --- CHANGELOG.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f7cc1bb..eb377af20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - SqlServerDsc - - Added private function `ConvertTo-SqlString` that escapes a string for use - in T-SQL string literals by doubling single quotes + - Added private functions `ConvertTo-SqlString` and `ConvertTo-EscapedQueryString` + to safely escape T-SQL string literals and query arguments ([issue #2442](https://github.com/dsccommunity/SqlServerDsc/issues/2442)). - - Added private function `ConvertTo-EscapedQueryString` that safely escapes - single quotes in T-SQL query arguments to prevent SQL injection vulnerabilities - ([issue #2442](https://github.com/dsccommunity/SqlServerDsc/issues/2442)). -- SqlReplication - - Updated `Install-RemoteDistributor` to escape T-SQL arguments when building - the T-SQL query for SQL Server 2025 containing special characters +- DSC_SqlReplication + - Updated `Install-RemoteDistributor` to escape T-SQL arguments for SQL Server + 2025 to prevent SQL injection and ensure proper password redaction ([issue #2442](https://github.com/dsccommunity/SqlServerDsc/issues/2442)). ### Changed From d597e91761241f77e47a2a075cb8f7477e702ea7 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 1 Feb 2026 10:19:50 +0100 Subject: [PATCH 4/4] Fix output type formatting in ConvertTo-SqlString function documentation --- source/Private/ConvertTo-SqlString.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Private/ConvertTo-SqlString.ps1 b/source/Private/ConvertTo-SqlString.ps1 index f5d1d1131..de10b689e 100644 --- a/source/Private/ConvertTo-SqlString.ps1 +++ b/source/Private/ConvertTo-SqlString.ps1 @@ -36,7 +36,7 @@ None. .OUTPUTS - System.String + `System.String` Returns the escaped string with single quotes doubled.