Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- SqlServerDsc
- 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)).
- 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)).
Comment thread
johlju marked this conversation as resolved.

### Changed

- SqlServerDsc
Expand Down
32 changes: 28 additions & 4 deletions source/DSCResources/DSC_SqlReplication/DSC_SqlReplication.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
74 changes: 74 additions & 0 deletions source/Private/ConvertTo-EscapedQueryString.ps1
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
johlju marked this conversation as resolved.

.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 ($currentArgument in $Argument)
{
$escapedArguments += ConvertTo-SqlString -Text $currentArgument
}

$result = $Query -f $escapedArguments

return $result
}
62 changes: 62 additions & 0 deletions source/Private/ConvertTo-SqlString.ps1
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
johlju marked this conversation as resolved.

.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
}
133 changes: 133 additions & 0 deletions tests/Unit/Private/ConvertTo-EscapedQueryString.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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''"
}
}
}
}
Loading
Loading