Skip to content

Commit d576467

Browse files
authored
SqlReplication: Fix T-SQL string escaping (#2445)
1 parent 2ec3287 commit d576467

6 files changed

Lines changed: 437 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
55

66
## [Unreleased]
77

8+
### Added
9+
10+
- SqlServerDsc
11+
- Added private functions `ConvertTo-SqlString` and `ConvertTo-EscapedQueryString`
12+
to safely escape T-SQL string literals and query arguments
13+
([issue #2442](https://github.com/dsccommunity/SqlServerDsc/issues/2442)).
14+
- DSC_SqlReplication
15+
- Updated `Install-RemoteDistributor` to escape T-SQL arguments for SQL Server
16+
2025 to prevent SQL injection and ensure proper password redaction
17+
([issue #2442](https://github.com/dsccommunity/SqlServerDsc/issues/2442)).
18+
819
### Changed
920

1021
- SqlServerDsc

source/DSCResources/DSC_SqlReplication/DSC_SqlReplication.psm1

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -633,22 +633,46 @@ function Install-RemoteDistributor
633633

634634
$sqlMajorVersion = $serverObject.VersionMajor
635635

636+
# cSpell:ignore sp_adddistributor
636637
if ($sqlMajorVersion -eq 17)
637638
{
638639
$clearTextPassword = $AdminLinkCredentials.GetNetworkCredential().Password
639640

641+
<#
642+
Escape single quotes by doubling them for T-SQL string literals.
643+
This prevents SQL injection and ensures the escaped value matches
644+
what appears in the final query for proper redaction.
645+
646+
TODO: When this resource is converted to a class-based resource
647+
we need to convert to escaped string for the redaction as well,
648+
replace this inline logic with the private function:
649+
$escapedPassword = ConvertTo-SqlString -Text $clearTextPassword
650+
#>
651+
$escapedPassword = $clearTextPassword -replace "'", "''"
652+
640653
<#
641654
Need to execute stored procedure sp_adddistributor for SQL Server 2025.
642655
Workaround for issue: https://github.com/dsccommunity/SqlServerDsc/pull/2435#issuecomment-3796616952
643656
644657
TODO: Should support encrypted connection in the future, then we could
645-
probably go back to using InstallDistributor(), another option is
646-
to move to stored procedures for all of the Replication logic.
658+
probably go back to using InstallDistributor() instead of calling
659+
the stored procedure, another option is to move to stored procedures
660+
for all of the Replication logic, for all SQL Server versions.
661+
#>
662+
663+
<#
664+
Escape arguments for the T-SQL query to prevent SQL injection.
665+
666+
TODO: When this resource is converted to a class-based resource,
667+
replace this inline logic with the private function:
668+
$unescapedQuery = "EXECUTE sys.sp_adddistributor @distributor = N'${0}', @password = N'${1}', @encrypt_distributor_connection = 'optional', @trust_distributor_certificate = 'yes';"
669+
$query = ConvertTo-EscapedQueryString -Query $unescapedQuery -Argument $RemoteDistributor, $clearTextPassword
647670
#>
648-
$query = "EXECUTE sys.sp_adddistributor @distributor = N'$RemoteDistributor', @password = N'$clearTextPassword', @encrypt_distributor_connection = 'optional', @trust_distributor_certificate = 'yes';"
671+
$escapedRemoteDistributor = $RemoteDistributor -replace "'", "''"
672+
$query = "EXECUTE sys.sp_adddistributor @distributor = N'$escapedRemoteDistributor', @password = N'$escapedPassword', @encrypt_distributor_connection = 'optional', @trust_distributor_certificate = 'yes';"
649673

650674
# TODO: This need to pass a credential in the future, now connects using the one resource is run as.
651-
Invoke-SqlDscQuery -ServerObject $serverObject -DatabaseName 'master' -Query $query -RedactText $clearTextPassword -Force -ErrorAction 'Stop'
675+
Invoke-SqlDscQuery -ServerObject $serverObject -DatabaseName 'master' -Query $query -RedactText $escapedPassword -Force -ErrorAction 'Stop'
652676
}
653677
else
654678
{
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<#
2+
.SYNOPSIS
3+
Formats a query string with escaped values to prevent SQL injection.
4+
5+
.DESCRIPTION
6+
This function formats a query string with placeholders using provided
7+
arguments. Each argument is escaped by doubling single quotes to prevent
8+
SQL injection vulnerabilities. This is the standard escaping mechanism
9+
for SQL Server string literals.
10+
11+
The function takes a format string with standard PowerShell format
12+
placeholders (e.g., {0}, {1}) and an array of arguments to substitute
13+
into those placeholders. Each argument is escaped before substitution.
14+
15+
.PARAMETER Query
16+
Specifies the query string containing format placeholders (e.g., {0}, {1}).
17+
The placeholders will be replaced with the escaped values from the
18+
Argument parameter.
19+
20+
.PARAMETER Argument
21+
Specifies an array of strings that will be used to format the query string.
22+
Each string will have single quotes escaped by doubling them before being
23+
substituted into the query.
24+
25+
.EXAMPLE
26+
ConvertTo-EscapedQueryString -Query "SELECT * FROM Users WHERE Name = N'{0}'" -Argument "O'Brien"
27+
28+
Returns: SELECT * FROM Users WHERE Name = N'O''Brien'
29+
30+
.EXAMPLE
31+
ConvertTo-EscapedQueryString -Query "EXECUTE sys.sp_adddistributor @distributor = N'{0}', @password = N'{1}';" -Argument 'Server1', "Pass'word;123"
32+
33+
Returns: EXECUTE sys.sp_adddistributor @distributor = N'Server1', @password = N'Pass''word;123';
34+
35+
.INPUTS
36+
None.
37+
38+
.OUTPUTS
39+
`System.String`
40+
41+
Returns the formatted query string with escaped values.
42+
43+
.NOTES
44+
This function escapes single quotes by doubling them, which is the
45+
standard SQL Server escaping mechanism for string literals. This helps
46+
prevent SQL injection when embedding values in dynamic T-SQL queries.
47+
#>
48+
function ConvertTo-EscapedQueryString
49+
{
50+
[CmdletBinding()]
51+
[OutputType([System.String])]
52+
param
53+
(
54+
[Parameter(Mandatory = $true)]
55+
[System.String]
56+
$Query,
57+
58+
[Parameter(Mandatory = $true)]
59+
[AllowEmptyString()]
60+
[System.String[]]
61+
$Argument
62+
)
63+
64+
$escapedArguments = @()
65+
66+
foreach ($currentArgument in $Argument)
67+
{
68+
$escapedArguments += ConvertTo-SqlString -Text $currentArgument
69+
}
70+
71+
$result = $Query -f $escapedArguments
72+
73+
return $result
74+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<#
2+
.SYNOPSIS
3+
Escapes a string for use in a T-SQL string literal.
4+
5+
.DESCRIPTION
6+
This function escapes a string for safe use in T-SQL string literals by
7+
doubling single quotes. This is the standard SQL Server escaping mechanism
8+
for preventing SQL injection when embedding values in dynamic T-SQL queries.
9+
10+
Use this function when you need to escape a value that will also be used
11+
elsewhere (e.g., for redaction), ensuring the escaped value matches what
12+
appears in the final query.
13+
14+
.PARAMETER Text
15+
Specifies the text string to escape for T-SQL.
16+
17+
.EXAMPLE
18+
ConvertTo-SqlString -Text "O'Brien"
19+
20+
Returns: O''Brien
21+
22+
.EXAMPLE
23+
ConvertTo-SqlString -Text "Pass'word;123"
24+
25+
Returns: Pass''word;123
26+
27+
.EXAMPLE
28+
$escapedPassword = ConvertTo-SqlString -Text $password
29+
$query = "EXECUTE sys.sp_adddistributor @password = N'$escapedPassword';"
30+
Invoke-SqlDscQuery -Query $query -RedactText $escapedPassword
31+
32+
Escapes the password and uses the same escaped value for both the query
33+
and the RedactText parameter to ensure proper redaction.
34+
35+
.INPUTS
36+
None.
37+
38+
.OUTPUTS
39+
`System.String`
40+
41+
Returns the escaped string with single quotes doubled.
42+
43+
.NOTES
44+
This function only escapes single quotes by doubling them. This is
45+
sufficient for SQL Server string literals enclosed in single quotes.
46+
#>
47+
function ConvertTo-SqlString
48+
{
49+
[CmdletBinding()]
50+
[OutputType([System.String])]
51+
param
52+
(
53+
[Parameter(Mandatory = $true)]
54+
[AllowEmptyString()]
55+
[System.String]
56+
$Text
57+
)
58+
59+
$escapedText = $Text -replace "'", "''"
60+
61+
return $escapedText
62+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because Script Analyzer does not understand Pester syntax.')]
2+
param ()
3+
4+
BeforeDiscovery {
5+
try
6+
{
7+
if (-not (Get-Module -Name 'DscResource.Test'))
8+
{
9+
# Assumes dependencies have been resolved, so if this module is not available, run 'noop' task.
10+
if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable))
11+
{
12+
# Redirect all streams to $null, except the error stream (stream 2)
13+
& "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 3>&1 4>&1 5>&1 6>&1 > $null
14+
}
15+
16+
# If the dependencies have not been resolved, this will throw an error.
17+
Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop'
18+
}
19+
}
20+
catch [System.IO.FileNotFoundException]
21+
{
22+
throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks noop" first.'
23+
}
24+
}
25+
26+
BeforeAll {
27+
$script:moduleName = 'SqlServerDsc'
28+
29+
$env:SqlServerDscCI = $true
30+
31+
Import-Module -Name $script:moduleName -ErrorAction 'Stop'
32+
33+
$PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName
34+
$PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName
35+
$PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName
36+
}
37+
38+
AfterAll {
39+
$PSDefaultParameterValues.Remove('InModuleScope:ModuleName')
40+
$PSDefaultParameterValues.Remove('Mock:ModuleName')
41+
$PSDefaultParameterValues.Remove('Should:ModuleName')
42+
43+
Remove-Item -Path 'env:SqlServerDscCI'
44+
}
45+
46+
Describe 'ConvertTo-EscapedQueryString' -Tag 'Private' {
47+
Context 'When escaping single quotes in query arguments' {
48+
It 'Should escape a single quote in an argument' {
49+
InModuleScope -ScriptBlock {
50+
Set-StrictMode -Version 1.0
51+
52+
$result = ConvertTo-EscapedQueryString -Query "SELECT * FROM Users WHERE Name = N'{0}'" -Argument "O'Brien"
53+
54+
$result | Should -Be "SELECT * FROM Users WHERE Name = N'O''Brien'"
55+
}
56+
}
57+
58+
It 'Should escape multiple single quotes in an argument' {
59+
InModuleScope -ScriptBlock {
60+
Set-StrictMode -Version 1.0
61+
62+
$result = ConvertTo-EscapedQueryString -Query "SELECT * FROM Users WHERE Name = N'{0}'" -Argument "O'Brien's"
63+
64+
$result | Should -Be "SELECT * FROM Users WHERE Name = N'O''Brien''s'"
65+
}
66+
}
67+
68+
It 'Should handle arguments without single quotes' {
69+
InModuleScope -ScriptBlock {
70+
Set-StrictMode -Version 1.0
71+
72+
$result = ConvertTo-EscapedQueryString -Query "SELECT * FROM Users WHERE Name = N'{0}'" -Argument 'Smith'
73+
74+
$result | Should -Be "SELECT * FROM Users WHERE Name = N'Smith'"
75+
}
76+
}
77+
}
78+
79+
Context 'When formatting a query with multiple arguments' {
80+
It 'Should escape single quotes in all arguments' {
81+
InModuleScope -ScriptBlock {
82+
Set-StrictMode -Version 1.0
83+
84+
$result = ConvertTo-EscapedQueryString -Query "EXECUTE sys.sp_adddistributor @distributor = N'{0}', @password = N'{1}';" -Argument 'Server1', "Pass'word;123"
85+
86+
$result | Should -Be "EXECUTE sys.sp_adddistributor @distributor = N'Server1', @password = N'Pass''word;123';"
87+
}
88+
}
89+
90+
It 'Should handle multiple arguments with single quotes' {
91+
InModuleScope -ScriptBlock {
92+
Set-StrictMode -Version 1.0
93+
94+
$result = ConvertTo-EscapedQueryString -Query "INSERT INTO Users (FirstName, LastName) VALUES (N'{0}', N'{1}')" -Argument "Mary's", "O'Connor"
95+
96+
$result | Should -Be "INSERT INTO Users (FirstName, LastName) VALUES (N'Mary''s', N'O''Connor')"
97+
}
98+
}
99+
}
100+
101+
Context 'When handling special characters that could be used for SQL injection' {
102+
It 'Should escape single quotes in passwords with special characters' {
103+
InModuleScope -ScriptBlock {
104+
Set-StrictMode -Version 1.0
105+
106+
# Password with single quote, semicolon, and dashes
107+
$result = ConvertTo-EscapedQueryString -Query "EXECUTE sys.sp_adddistributor @password = N'{0}';" -Argument "Pass'word;--DROP TABLE Users"
108+
109+
$result | Should -Be "EXECUTE sys.sp_adddistributor @password = N'Pass''word;--DROP TABLE Users';"
110+
}
111+
}
112+
113+
It 'Should handle argument with only single quotes' {
114+
InModuleScope -ScriptBlock {
115+
Set-StrictMode -Version 1.0
116+
117+
$result = ConvertTo-EscapedQueryString -Query "SELECT N'{0}'" -Argument "'''"
118+
119+
$result | Should -Be "SELECT N''''''''"
120+
}
121+
}
122+
123+
It 'Should handle empty string argument' {
124+
InModuleScope -ScriptBlock {
125+
Set-StrictMode -Version 1.0
126+
127+
$result = ConvertTo-EscapedQueryString -Query "SELECT N'{0}'" -Argument ''
128+
129+
$result | Should -Be "SELECT N''"
130+
}
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)