From beebc021452c6f3a0230d1411dd005acb9c4e155 Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Thu, 30 Apr 2026 04:53:38 -0700 Subject: [PATCH 1/5] sp_DatabaseRestore: harden against parameter SQL injection Threat model: a caller with EXECUTE on the proc but without sysadmin should not be able to escalate to arbitrary T-SQL or shell command execution by passing crafted parameter values. Changes: - Validation gate (after @BlockSize check). Reject path-shaped params that contain shell metacharacters or control chars: " & | ; ^ < > /dev/null CR LF. Single quotes are allowed (legal in Windows paths) and are escaped at concat sites. Applied to @BackupPathFull/Diff/Log, @MoveDataDrive/LogDrive/FilestreamDrive/FullTextCatalogDrive, @StandbyUndoPath, @FileNamePrefix, @Database. - @DatabaseOwner: replaced manual '[' + @DatabaseOwner + ']' with QUOTENAME() in the ALTER AUTHORIZATION statement. - @RunStoredProcAfterRestore: parsed via PARSENAME and rejected if the value is a 3- or 4-part name; each part wrapped in QUOTENAME before building the EXEC. Blocks ; -chained injection that previously ran through sp_executesql with raw concatenation. - Single-quote escaping at every dynamic-RESTORE/xp_cmdshell concat site that interpolates a caller-supplied path or @Database into a '...'-quoted literal: DIR /b commands for full/diff/log enumeration, RESTORE DATABASE/LOG FROM DISK = '...', STANDBY = '...', and the per-row split-backup DISK= entries built via FOR XML PATH. - HEADERONLY/FILELISTONLY {Path} substitution: doubled-twice escape (REPLICATE(N'''',4) per single quote) because the path lives inside a nested EXEC literal and crosses two parser layers. --- sp_DatabaseRestore.sql | 72 ++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/sp_DatabaseRestore.sql b/sp_DatabaseRestore.sql index d6156ba6..37fd7741 100755 --- a/sp_DatabaseRestore.sql +++ b/sp_DatabaseRestore.sql @@ -538,6 +538,27 @@ BEGIN END END +/* Reject path-shaped parameters that contain shell metacharacters or control chars. + Single quotes are deliberately allowed (legal in Windows paths) and are escaped at concat sites instead. */ +DECLARE @ForbiddenPathChars NVARCHAR(20) = N'"&|;^<>' + NCHAR(0) + NCHAR(10) + NCHAR(13); +DECLARE @ForbiddenPathPattern NVARCHAR(40) = N'%[' + @ForbiddenPathChars + N']%'; +DECLARE @InvalidPathParam sysname = NULL; +IF @BackupPathFull LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@BackupPathFull'; +IF @InvalidPathParam IS NULL AND @BackupPathDiff LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@BackupPathDiff'; +IF @InvalidPathParam IS NULL AND @BackupPathLog LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@BackupPathLog'; +IF @InvalidPathParam IS NULL AND @MoveDataDrive LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@MoveDataDrive'; +IF @InvalidPathParam IS NULL AND @MoveLogDrive LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@MoveLogDrive'; +IF @InvalidPathParam IS NULL AND @MoveFilestreamDrive LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@MoveFilestreamDrive'; +IF @InvalidPathParam IS NULL AND @MoveFullTextCatalogDrive LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@MoveFullTextCatalogDrive'; +IF @InvalidPathParam IS NULL AND @StandbyUndoPath LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@StandbyUndoPath'; +IF @InvalidPathParam IS NULL AND @FileNamePrefix LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@FileNamePrefix'; +IF @InvalidPathParam IS NULL AND @Database LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@Database'; +IF @InvalidPathParam IS NOT NULL +BEGIN + RAISERROR('Parameter %s contains a character that is not allowed in a file path. Forbidden: " & | ; ^ < > or control characters.', 16, 1, @InvalidPathParam) WITH NOWAIT; + RETURN; +END; + --File Extension cleanup IF @FileExtensionDiff LIKE '%.%' BEGIN @@ -625,7 +646,7 @@ BEGIN END ELSE BEGIN - SET @cmd = N'DIR /b "' + @CurrentBackupPathFull + N'"'; + SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathFull, N'''', N'''''') + N'"'; IF @Debug = 1 BEGIN IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathFull'; @@ -762,7 +783,7 @@ BEGIN SET @FileListParamSQL += N')' + NCHAR(13) + NCHAR(10); SET @FileListParamSQL += N'EXEC (''RESTORE FILELISTONLY FROM DISK=''''{Path}'''''')'; - SET @sql = REPLACE(@FileListParamSQL, N'{Path}', @CurrentBackupPathFull + @LastFullBackup); + SET @sql = REPLACE(@FileListParamSQL, N'{Path}', REPLACE(@CurrentBackupPathFull + @LastFullBackup, N'''', REPLICATE(N'''', 4))); IF @Debug = 1 BEGIN @@ -778,7 +799,7 @@ BEGIN END --get the backup completed data so we can apply tlogs from that point forwards - SET @sql = REPLACE(@HeadersSQL, N'{Path}', @CurrentBackupPathFull + @LastFullBackup); + SET @sql = REPLACE(@HeadersSQL, N'{Path}', REPLACE(@CurrentBackupPathFull + @LastFullBackup, N'''', REPLICATE(N'''', 4))); IF @Debug = 1 BEGIN @@ -939,7 +960,7 @@ BEGIN SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM ' + STUFF( - (SELECT CHAR( 10 ) + ',DISK=''' + BackupPath + BackupFile + '''' + (SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + '''' FROM #SplitFullBackups ORDER BY BackupFile FOR XML PATH ('')), @@ -949,7 +970,7 @@ BEGIN END; ELSE BEGIN - SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM DISK = ''' + @CurrentBackupPathFull + @LastFullBackup + N''' WITH NORECOVERY, REPLACE' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); + SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM DISK = ''' + REPLACE(@CurrentBackupPathFull, N'''', N'''''') + REPLACE(@LastFullBackup, N'''', N'''''') + N''' WITH NORECOVERY, REPLACE' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); END IF (@StandbyMode = 1) BEGIN @@ -959,11 +980,11 @@ BEGIN END ELSE IF (SELECT COUNT(*) FROM #SplitFullBackups) > 0 BEGIN - SET @sql = @sql + ', STANDBY = ''' + @StandbyUndoPath + @Database + 'Undo.ldf''' + NCHAR(13) + NCHAR(10); + SET @sql = @sql + ', STANDBY = ''' + REPLACE(@StandbyUndoPath, N'''', N'''''') + REPLACE(@Database, N'''', N'''''') + 'Undo.ldf''' + NCHAR(13) + NCHAR(10); END ELSE BEGIN - SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM DISK = ''' + @CurrentBackupPathFull + @LastFullBackup + N''' WITH REPLACE' + @BackupParameters + @MoveOption + N' , STANDBY = ''' + @StandbyUndoPath + @Database + 'Undo.ldf''' + NCHAR(13) + NCHAR(10); + SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM DISK = ''' + REPLACE(@CurrentBackupPathFull, N'''', N'''''') + REPLACE(@LastFullBackup, N'''', N'''''') + N''' WITH REPLACE' + @BackupParameters + @MoveOption + N' , STANDBY = ''' + REPLACE(@StandbyUndoPath, N'''', N'''''') + REPLACE(@Database, N'''', N'''''') + 'Undo.ldf''' + NCHAR(13) + NCHAR(10); END END; IF @Debug = 1 OR @Execute = 'N' @@ -1050,7 +1071,7 @@ BEGIN END ELSE BEGIN - SET @cmd = N'DIR /b "' + @CurrentBackupPathDiff + N'"'; + SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathDiff, N'''', N'''''') + N'"'; IF @Debug = 1 BEGIN IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathDiff'; @@ -1131,7 +1152,7 @@ BEGIN IF @Debug = 1 RAISERROR ('Split backups found', 0, 1) WITH NOWAIT; SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM ' + STUFF( - (SELECT CHAR( 10 ) + ',DISK=''' + BackupPath + BackupFile + '''' + (SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + '''' FROM #SplitDiffBackups ORDER BY BackupFile FOR XML PATH ('')), @@ -1140,7 +1161,7 @@ BEGIN '' ) + N' WITH NORECOVERY, REPLACE' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); END; ELSE - SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM DISK = ''' + @CurrentBackupPathDiff + @LastDiffBackup + N''' WITH NORECOVERY' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); + SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM DISK = ''' + REPLACE(@CurrentBackupPathDiff, N'''', N'''''') + REPLACE(@LastDiffBackup, N'''', N'''''') + N''' WITH NORECOVERY' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); IF (@StandbyMode = 1) BEGIN @@ -1149,9 +1170,9 @@ BEGIN IF @Execute = 'Y' OR @Debug = 1 RAISERROR('The file path of the undo file for standby mode was not specified. The database will not be restored in standby mode.', 0, 1) WITH NOWAIT; END ELSE IF (SELECT COUNT(*) FROM #SplitDiffBackups) > 0 - SET @sql = @sql + ', STANDBY = ''' + @StandbyUndoPath + @Database + 'Undo.ldf''' + NCHAR(13) + NCHAR(10); + SET @sql = @sql + ', STANDBY = ''' + REPLACE(@StandbyUndoPath, N'''', N'''''') + REPLACE(@Database, N'''', N'''''') + 'Undo.ldf''' + NCHAR(13) + NCHAR(10); ELSE - SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM DISK = ''' + @BackupPathDiff + @LastDiffBackup + N''' WITH STANDBY = ''' + @StandbyUndoPath + @Database + 'Undo.ldf''' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); + SET @sql = N'RESTORE DATABASE ' + @RestoreDatabaseName + N' FROM DISK = ''' + REPLACE(@BackupPathDiff, N'''', N'''''') + REPLACE(@LastDiffBackup, N'''', N'''''') + N''' WITH STANDBY = ''' + REPLACE(@StandbyUndoPath, N'''', N'''''') + REPLACE(@Database, N'''', N'''''') + 'Undo.ldf''' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); END; IF @Debug = 1 OR @Execute = 'N' BEGIN @@ -1162,7 +1183,7 @@ BEGIN EXECUTE @sql = [dbo].[CommandExecute] @DatabaseContext=N'master', @Command = @sql, @CommandType = 'RESTORE DATABASE', @Mode = 1, @DatabaseName = @UnquotedRestoreDatabaseName, @LogToTable = 'Y', @Execute = 'Y'; --get the backup completed data so we can apply tlogs from that point forwards - SET @sql = REPLACE(@HeadersSQL, N'{Path}', @CurrentBackupPathDiff + @LastDiffBackup); + SET @sql = REPLACE(@HeadersSQL, N'{Path}', REPLACE(@CurrentBackupPathDiff + @LastDiffBackup, N'''', REPLICATE(N'''', 4))); IF @Debug = 1 BEGIN @@ -1235,7 +1256,7 @@ BEGIN END ELSE BEGIN - SET @cmd = N'DIR /b "' + @CurrentBackupPathLog + N'"'; + SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathLog, N'''', N'''''') + N'"'; IF @Debug = 1 BEGIN IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathLog'; @@ -1365,7 +1386,7 @@ IF (@StandbyMode = 1) IF @Execute = 'Y' OR @Debug = 1 RAISERROR('The file path of the undo file for standby mode was not specified. Logs will not be restored in standby mode.', 0, 1) WITH NOWAIT; END; ELSE - SET @LogRecoveryOption = N'STANDBY = ''' + @StandbyUndoPath + @Database + 'Undo.ldf'''; + SET @LogRecoveryOption = N'STANDBY = ''' + REPLACE(@StandbyUndoPath, N'''', N'''''') + REPLACE(@Database, N'''', N'''''') + 'Undo.ldf'''; END; IF (@LogRecoveryOption = N'') @@ -1450,7 +1471,7 @@ WHERE BackupFile IS NOT NULL; IF @i = 1 BEGIN - SET @sql = REPLACE(@HeadersSQL, N'{Path}', @CurrentBackupPathLog + @BackupFile); + SET @sql = REPLACE(@HeadersSQL, N'{Path}', REPLACE(@CurrentBackupPathLog + @BackupFile, N'''', REPLICATE(N'''', 4))); IF @Debug = 1 BEGIN @@ -1490,7 +1511,7 @@ WHERE BackupFile IS NOT NULL; IF @Debug = 1 RAISERROR ('Split backups found', 0, 1) WITH NOWAIT; SET @sql = N'RESTORE LOG ' + @RestoreDatabaseName + N' FROM ' + STUFF( - (SELECT CHAR( 10 ) + ',DISK=''' + BackupPath + BackupFile + '''' + (SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + '''' FROM #SplitLogBackups WHERE DenseRank = @LogRestoreRanking ORDER BY BackupFile @@ -1500,7 +1521,7 @@ WHERE BackupFile IS NOT NULL; '' ) + N' WITH ' + @LogRecoveryOption + NCHAR(13) + NCHAR(10); END; ELSE - SET @sql = N'RESTORE LOG ' + @RestoreDatabaseName + N' FROM DISK = ''' + @CurrentBackupPathLog + @BackupFile + N''' WITH ' + @LogRecoveryOption + NCHAR(13) + NCHAR(10); + SET @sql = N'RESTORE LOG ' + @RestoreDatabaseName + N' FROM DISK = ''' + REPLACE(@CurrentBackupPathLog, N'''', N'''''') + REPLACE(@BackupFile, N'''', N'''''') + N''' WITH ' + @LogRecoveryOption + NCHAR(13) + NCHAR(10); IF @Debug = 1 OR @Execute = 'N' BEGIN @@ -1582,7 +1603,7 @@ IF @DatabaseOwner IS NOT NULL BEGIN IF EXISTS (SELECT * FROM master.dbo.syslogins WHERE syslogins.loginname = @DatabaseOwner) BEGIN - SET @sql = N'ALTER AUTHORIZATION ON DATABASE::' + @RestoreDatabaseName + ' TO [' + @DatabaseOwner + ']'; + SET @sql = N'ALTER AUTHORIZATION ON DATABASE::' + @RestoreDatabaseName + N' TO ' + QUOTENAME(@DatabaseOwner); IF @Debug = 1 OR @Execute = 'N' BEGIN @@ -1672,8 +1693,19 @@ END;' IF @RunStoredProcAfterRestore IS NOT NULL AND LEN(LTRIM(@RunStoredProcAfterRestore)) > 0 BEGIN + DECLARE @RunStoredProcSchema sysname = NULLIF(PARSENAME(@RunStoredProcAfterRestore, 2), N''); + DECLARE @RunStoredProcName sysname = PARSENAME(@RunStoredProcAfterRestore, 1); + IF @RunStoredProcName IS NULL + OR PARSENAME(@RunStoredProcAfterRestore, 3) IS NOT NULL + OR PARSENAME(@RunStoredProcAfterRestore, 4) IS NOT NULL + BEGIN + RAISERROR('@RunStoredProcAfterRestore must be a procedure name or schema.procedure (1- or 2-part name).', 16, 1) WITH NOWAIT; + RETURN; + END; PRINT 'Attempting to run ' + @RunStoredProcAfterRestore - SET @sql = N'EXEC ' + @RestoreDatabaseName + '.' + @RunStoredProcAfterRestore + SET @sql = N'EXEC ' + @RestoreDatabaseName + N'.' + + COALESCE(QUOTENAME(@RunStoredProcSchema) + N'.', N'') + + QUOTENAME(@RunStoredProcName); IF @Debug = 1 OR @Execute = 'N' BEGIN From 98d420baa9a218d928493898c254c63b77250762 Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Thu, 30 Apr 2026 05:06:36 -0700 Subject: [PATCH 2/5] sp_DatabaseRestore: use BIN2 collation in path-injection LIKE check LIKE '[...]' under the database default collation silently drops NCHAR(0), so a NUL byte in a path-shaped parameter would bypass the validation gate. Collating both sides as Latin1_General_BIN2 makes the character class compare byte-for-byte and catches NUL alongside the other forbidden characters. Verified end-to-end against a SQL Server 2025 instance: pipe in @StandbyUndoPath, NUL in @MoveDataDrive, caret in @Database, and < in @FileNamePrefix all now raise with the correct parameter name. Co-Authored-By: Claude Opus 4.7 (1M context) --- sp_DatabaseRestore.sql | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/sp_DatabaseRestore.sql b/sp_DatabaseRestore.sql index 37fd7741..a834a1f9 100755 --- a/sp_DatabaseRestore.sql +++ b/sp_DatabaseRestore.sql @@ -539,20 +539,21 @@ BEGIN END /* Reject path-shaped parameters that contain shell metacharacters or control chars. - Single quotes are deliberately allowed (legal in Windows paths) and are escaped at concat sites instead. */ -DECLARE @ForbiddenPathChars NVARCHAR(20) = N'"&|;^<>' + NCHAR(0) + NCHAR(10) + NCHAR(13); -DECLARE @ForbiddenPathPattern NVARCHAR(40) = N'%[' + @ForbiddenPathChars + N']%'; + Single quotes are deliberately allowed (legal in Windows paths) and are escaped at concat sites instead. + COLLATE Latin1_General_BIN2 is required so the LIKE '[...]' class catches NCHAR(0) — under default + collations the NUL byte is silently dropped from the pattern and compared values. */ +DECLARE @ForbiddenPathPattern NVARCHAR(40) = N'%["&|;^<>' + NCHAR(0) + NCHAR(10) + NCHAR(13) + N']%'; DECLARE @InvalidPathParam sysname = NULL; -IF @BackupPathFull LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@BackupPathFull'; -IF @InvalidPathParam IS NULL AND @BackupPathDiff LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@BackupPathDiff'; -IF @InvalidPathParam IS NULL AND @BackupPathLog LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@BackupPathLog'; -IF @InvalidPathParam IS NULL AND @MoveDataDrive LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@MoveDataDrive'; -IF @InvalidPathParam IS NULL AND @MoveLogDrive LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@MoveLogDrive'; -IF @InvalidPathParam IS NULL AND @MoveFilestreamDrive LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@MoveFilestreamDrive'; -IF @InvalidPathParam IS NULL AND @MoveFullTextCatalogDrive LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@MoveFullTextCatalogDrive'; -IF @InvalidPathParam IS NULL AND @StandbyUndoPath LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@StandbyUndoPath'; -IF @InvalidPathParam IS NULL AND @FileNamePrefix LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@FileNamePrefix'; -IF @InvalidPathParam IS NULL AND @Database LIKE @ForbiddenPathPattern SET @InvalidPathParam = N'@Database'; +IF @BackupPathFull LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@BackupPathFull'; +IF @InvalidPathParam IS NULL AND @BackupPathDiff LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@BackupPathDiff'; +IF @InvalidPathParam IS NULL AND @BackupPathLog LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@BackupPathLog'; +IF @InvalidPathParam IS NULL AND @MoveDataDrive LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@MoveDataDrive'; +IF @InvalidPathParam IS NULL AND @MoveLogDrive LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@MoveLogDrive'; +IF @InvalidPathParam IS NULL AND @MoveFilestreamDrive LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@MoveFilestreamDrive'; +IF @InvalidPathParam IS NULL AND @MoveFullTextCatalogDrive LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@MoveFullTextCatalogDrive'; +IF @InvalidPathParam IS NULL AND @StandbyUndoPath LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@StandbyUndoPath'; +IF @InvalidPathParam IS NULL AND @FileNamePrefix LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@FileNamePrefix'; +IF @InvalidPathParam IS NULL AND @Database LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@Database'; IF @InvalidPathParam IS NOT NULL BEGIN RAISERROR('Parameter %s contains a character that is not allowed in a file path. Forbidden: " & | ; ^ < > or control characters.', 16, 1, @InvalidPathParam) WITH NOWAIT; From 4c820824b4ded81381891875f4542932f00a83a1 Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Thu, 30 Apr 2026 05:12:48 -0700 Subject: [PATCH 3/5] sp_DatabaseRestore: address Copilot review on hardening PR - Drop @Database from the path-shape validation gate. Database names are identifiers and can legally contain '&', ';' etc. Downstream uses are either parameter-bound (LIKE filters against @FileList) or single-quote-escaped before concatenation, so the gate was a backward-compat regression for unusual but valid DB names. Tightened the error message wording now that the gate covers only path-shaped parameters. - Fix @RunStoredProcAfterRestore to emit a 3-part name. Previously a 1-part input ('MyProc') produced 'EXEC [db].[MyProc]', which SQL Server resolves as schema.object in the *current* database, not as db..proc. Now emits 'EXEC [db]..[MyProc]' for 1-part names and 'EXEC [db].[schema].[proc]' for 2-part names. - Add ", TYPE).value('.', 'nvarchar(max)')" to the three FOR XML PATH split-backup builders. Without TYPE+.value, characters like '&', '<', '>' in BackupFile names get XML-entity-encoded ('&', '<') in the generated RESTORE DISK= path, producing invalid file paths for legitimate filenames containing those characters. Verified end-to-end against SQL Server 2025: 1-part -> [db]..[proc], 2-part -> [db].[schema].[proc], @Database='My&DB' passes validation, @BackupPathFull='C:\B&evil\' still rejected, and FOR XML PATH output preserves '&' raw instead of entity-encoding it. Co-Authored-By: Claude Opus 4.7 (1M context) --- sp_DatabaseRestore.sql | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sp_DatabaseRestore.sql b/sp_DatabaseRestore.sql index a834a1f9..00d50777 100755 --- a/sp_DatabaseRestore.sql +++ b/sp_DatabaseRestore.sql @@ -541,7 +541,10 @@ END /* Reject path-shaped parameters that contain shell metacharacters or control chars. Single quotes are deliberately allowed (legal in Windows paths) and are escaped at concat sites instead. COLLATE Latin1_General_BIN2 is required so the LIKE '[...]' class catches NCHAR(0) — under default - collations the NUL byte is silently dropped from the pattern and compared values. */ + collations the NUL byte is silently dropped from the pattern and compared values. + @Database is intentionally NOT in this list: it is primarily an identifier (database names can + legally contain '&', ';', etc.) and downstream usage either parameter-binds it (LIKE filters + against @FileList) or single-quote-escapes it before concatenating into a quoted SQL literal. */ DECLARE @ForbiddenPathPattern NVARCHAR(40) = N'%["&|;^<>' + NCHAR(0) + NCHAR(10) + NCHAR(13) + N']%'; DECLARE @InvalidPathParam sysname = NULL; IF @BackupPathFull LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@BackupPathFull'; @@ -553,10 +556,9 @@ IF @InvalidPathParam IS NULL AND @MoveFilestreamDrive LIKE @ForbiddenPathPa IF @InvalidPathParam IS NULL AND @MoveFullTextCatalogDrive LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@MoveFullTextCatalogDrive'; IF @InvalidPathParam IS NULL AND @StandbyUndoPath LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@StandbyUndoPath'; IF @InvalidPathParam IS NULL AND @FileNamePrefix LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@FileNamePrefix'; -IF @InvalidPathParam IS NULL AND @Database LIKE @ForbiddenPathPattern COLLATE Latin1_General_BIN2 SET @InvalidPathParam = N'@Database'; IF @InvalidPathParam IS NOT NULL BEGIN - RAISERROR('Parameter %s contains a character that is not allowed in a file path. Forbidden: " & | ; ^ < > or control characters.', 16, 1, @InvalidPathParam) WITH NOWAIT; + RAISERROR('Parameter %s contains a forbidden character. Not allowed: " & | ; ^ < > or control characters.', 16, 1, @InvalidPathParam) WITH NOWAIT; RETURN; END; @@ -964,7 +966,7 @@ BEGIN (SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + '''' FROM #SplitFullBackups ORDER BY BackupFile - FOR XML PATH ('')), + FOR XML PATH (''), TYPE).value('.', 'nvarchar(max)'), 1, 2, '') + N' WITH NORECOVERY, REPLACE' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); @@ -1156,7 +1158,7 @@ BEGIN (SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + '''' FROM #SplitDiffBackups ORDER BY BackupFile - FOR XML PATH ('')), + FOR XML PATH (''), TYPE).value('.', 'nvarchar(max)'), 1, 2, '' ) + N' WITH NORECOVERY, REPLACE' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); @@ -1516,7 +1518,7 @@ WHERE BackupFile IS NOT NULL; FROM #SplitLogBackups WHERE DenseRank = @LogRestoreRanking ORDER BY BackupFile - FOR XML PATH ('')), + FOR XML PATH (''), TYPE).value('.', 'nvarchar(max)'), 1, 2, '' ) + N' WITH ' + @LogRecoveryOption + NCHAR(13) + NCHAR(10); @@ -1704,8 +1706,12 @@ BEGIN RETURN; END; PRINT 'Attempting to run ' + @RunStoredProcAfterRestore + /* Always emit a 3-part name (db.schema.proc). For 1-part input the schema slot is left empty + so the name resolves as db..proc — i.e., the default schema in the restored DB. Without the + second dot, [db].[proc] is parsed as schema.object in the *current* DB. */ SET @sql = N'EXEC ' + @RestoreDatabaseName + N'.' - + COALESCE(QUOTENAME(@RunStoredProcSchema) + N'.', N'') + + ISNULL(QUOTENAME(@RunStoredProcSchema), N'') + + N'.' + QUOTENAME(@RunStoredProcName); IF @Debug = 1 OR @Execute = 'N' From 5a77eb34a1f239416ee325e7d82b5c2c45a9dd1f Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Thu, 30 Apr 2026 05:40:35 -0700 Subject: [PATCH 4/5] sp_DatabaseRestore: address second Copilot review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the REPLACE single-quote-doubling around @CurrentBackupPath{Full, Diff,Log} when building the DIR /b command for xp_cmdshell. cmd.exe does not interpret '' as an escape, so the doubling broke legitimate Windows paths containing an apostrophe (e.g. C:\Backups\It's Friday\ was sent to cmd as DIR /b "C:\Backups\It''s Friday\"). The validation gate already rejects shell metacharacters, and the variable is just concatenated into a value passed to xp_cmdshell — it never crosses a SQL parser, so no SQL escaping is required. - Tighten the comment above the @RunStoredProcAfterRestore EXEC build. The previous wording claimed db..proc resolves to "the default schema in the restored DB"; verified empirically that SQL Server uses the caller's default schema in that DB (TestUser default_schema=myschema resolved TestRR..SchemaTest to myschema.SchemaTest), but the simpler and more accurate framing is to just say SQL Server applies its own schema-resolution rules — which sidesteps the dbo-vs-default nuance. Verified end-to-end against SQL Server 2025: legitimate path with apostrophe now produces DIR /b "C:\Backups\It's Friday\" (single quote, as cmd.exe expects); all earlier injection probes still fail closed. Co-Authored-By: Claude Opus 4.7 (1M context) --- sp_DatabaseRestore.sql | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sp_DatabaseRestore.sql b/sp_DatabaseRestore.sql index 00d50777..8a62dc88 100755 --- a/sp_DatabaseRestore.sql +++ b/sp_DatabaseRestore.sql @@ -649,7 +649,7 @@ BEGIN END ELSE BEGIN - SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathFull, N'''', N'''''') + N'"'; + SET @cmd = N'DIR /b "' + @CurrentBackupPathFull + N'"'; IF @Debug = 1 BEGIN IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathFull'; @@ -1074,7 +1074,7 @@ BEGIN END ELSE BEGIN - SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathDiff, N'''', N'''''') + N'"'; + SET @cmd = N'DIR /b "' + @CurrentBackupPathDiff + N'"'; IF @Debug = 1 BEGIN IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathDiff'; @@ -1259,7 +1259,7 @@ BEGIN END ELSE BEGIN - SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathLog, N'''', N'''''') + N'"'; + SET @cmd = N'DIR /b "' + @CurrentBackupPathLog + N'"'; IF @Debug = 1 BEGIN IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathLog'; @@ -1707,8 +1707,8 @@ BEGIN END; PRINT 'Attempting to run ' + @RunStoredProcAfterRestore /* Always emit a 3-part name (db.schema.proc). For 1-part input the schema slot is left empty - so the name resolves as db..proc — i.e., the default schema in the restored DB. Without the - second dot, [db].[proc] is parsed as schema.object in the *current* DB. */ + ([db]..[proc]) so SQL Server applies its own schema-resolution rules in the target DB. + Without the second dot, [db].[proc] is parsed as schema.object in the *current* DB. */ SET @sql = N'EXEC ' + @RestoreDatabaseName + N'.' + ISNULL(QUOTENAME(@RunStoredProcSchema), N'') + N'.' From 7d6749b17808326405599bd59dcb47c9feb1ca07 Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Fri, 1 May 2026 09:27:01 -0700 Subject: [PATCH 5/5] Add Documentation/Development/Test sp_DatabaseRestore.sql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regression-test script for the SQL injection hardening in PR #3980. Each case targets one of the inputs that, before the hardening, could have let a non-sysadmin caller escalate to arbitrary T-SQL or shell command execution. Layout: - Part 1: Path-shape validation gate. Each path-shaped parameter gets one test per forbidden character class (& ; | ^ < > " NUL CR LF). - Part 2: Identifier validation for @RunStoredProcAfterRestore (3- and 4-part names rejected). - Part 3: Regression — legitimate inputs (ordinary path, path with an apostrophe, @Database with '&', 1-part and 2-part proc names) must pass the gate and only fail later with the proc's normal "no rows" error. - Part 4: Logic-only verification that mimics the proc's PARSENAME + QUOTENAME and single-quote-doubling so the identifier-quoting and nested-EXEC escaping can be exercised without real backups. Paths use C:\ throughout so this works on any test rig without special storage setup. Tests use @Execute='N', @Debug=1 — nothing is actually restored — and each EXEC is wrapped in BEGIN TRY/CATCH so one failure doesn't stop the suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Development/Test sp_DatabaseRestore.sql | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 Documentation/Development/Test sp_DatabaseRestore.sql diff --git a/Documentation/Development/Test sp_DatabaseRestore.sql b/Documentation/Development/Test sp_DatabaseRestore.sql new file mode 100644 index 00000000..82e4a258 --- /dev/null +++ b/Documentation/Development/Test sp_DatabaseRestore.sql @@ -0,0 +1,307 @@ +/* +sp_DatabaseRestore — SQL injection regression tests +===================================================== + +These cases cover inputs that, prior to the hardening in PR #3980, +could have been abused by a caller with EXECUTE permission on the +proc to inject T-SQL or to inject shell commands via xp_cmdshell. + +Threat model: a caller who has EXECUTE on sp_DatabaseRestore but is +not sysadmin should not be able to escalate to arbitrary T-SQL or +shell command execution by passing crafted parameter values. + +How to run +---------- +sp_DatabaseRestore + dbo.CommandExecute (Ola Hallengren's Maintenance +Solution) must be installed in the current database. None of the C:\ +paths below need to actually exist — the validation gate fires (or +doesn't) before xp_dirtree / xp_cmdshell are invoked, and the +dynamic-RESTORE strings produced by @Debug = 1 are only PRINTed. + +Each test wraps the EXEC in BEGIN TRY / BEGIN CATCH so one failure +doesn't stop the suite. The expected outcome is annotated above +each test as either: + BLOCKED — the validation gate must RAISERROR + RETURN + PASSES — the validation gate must let the input through + NEUTRAL — the input must be quoted/escaped so it cannot break + out of its surrounding SQL literal or identifier + +The "logic-only" cases at the bottom mimic the proc's PARSENAME + +QUOTENAME / single-quote-doubling without needing a real backup +folder, so the schema-resolution and identifier-quoting behaviour +can still be exercised on a fresh test rig. + +Paths use C:\ so this works on any test rig without special storage. +*/ + + +PRINT '===================================================='; +PRINT 'PART 1 - Path-shape validation gate'; +PRINT 'Each path-shaped parameter that contains a character with no'; +PRINT 'legitimate use in a Windows path (and high command-injection'; +PRINT 'risk) must be rejected before the proc reaches xp_cmdshell'; +PRINT 'or dynamic-SQL construction.'; +PRINT '===================================================='; +GO + +PRINT '--- 1.1 BLOCKED: ampersand in @BackupPathFull (cmd shell metachar) ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\& whoami &\', + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 1.2 BLOCKED: semicolon + nested injection in @BackupPathFull ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\; xp_cmdshell ''calc''; --\', + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 1.3 BLOCKED: pipe in @StandbyUndoPath ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @StandbyUndoPath = 'C:\Standby\| dir |\', + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 1.4 BLOCKED: caret in @MoveDataDrive ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @MoveDataDrive = 'C:\Data^evil\', + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 1.5 BLOCKED: < in @FileNamePrefix ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @FileNamePrefix = 'pref in @MoveLogDrive ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @MoveLogDrive = 'C:\Logs>x\', + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 1.7 BLOCKED: double-quote in @BackupPathDiff ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @BackupPathDiff = 'C:\Diff\"& whoami &"\', + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 1.8 BLOCKED: NUL byte in @BackupPathLog ---'; +PRINT ' (the LIKE pattern uses COLLATE Latin1_General_BIN2 so NUL is detected;'; +PRINT ' under the default collation NUL is silently dropped from both sides)'; +DECLARE @PathWithNul nvarchar(260) = N'C:\Logs\' + NCHAR(0) + N'evil\'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @BackupPathLog = @PathWithNul, + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 1.9 BLOCKED: CR in @BackupPathFull ---'; +DECLARE @PathWithCR nvarchar(260) = N'C:\Backups\' + NCHAR(13) + N'evil\'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = @PathWithCR, + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 1.10 BLOCKED: LF in @MoveFilestreamDrive ---'; +DECLARE @PathWithLF nvarchar(260) = N'C:\FS\' + NCHAR(10) + N'evil\'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @MoveFilestreamDrive = @PathWithLF, + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + + +PRINT '===================================================='; +PRINT 'PART 2 - Identifier validation'; +PRINT '@RunStoredProcAfterRestore must be a 1- or 2-part name. The'; +PRINT 'proc rejects 3- and 4-part names so a caller cannot execute'; +PRINT 'cross-server / cross-database procs by smuggling extra dots.'; +PRINT '===================================================='; +GO + +PRINT '--- 2.1 BLOCKED: 3-part @RunStoredProcAfterRestore ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @RunStoredProcAfterRestore = 'targetdb.dbo.MyProc', + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 2.2 BLOCKED: 4-part (linked-server) @RunStoredProcAfterRestore ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @RunStoredProcAfterRestore = 'attackerSrv.targetdb.dbo.MyProc', + @Execute = 'N', @Debug = 1; + PRINT '*** REGRESSION: not blocked ***'; +END TRY BEGIN CATCH PRINT 'BLOCKED: ' + ERROR_MESSAGE(); END CATCH; +GO + + +PRINT '===================================================='; +PRINT 'PART 3 - Regression: legitimate inputs must still pass'; +PRINT 'These should pass the validation gate. They will fail later'; +PRINT 'with "(FULL) No rows were returned for that database in path"'; +PRINT 'because no real backups exist at C:\Backups\ — that is the'; +PRINT 'expected outcome and means the validation gate did not block.'; +PRINT '===================================================='; +GO + +PRINT '--- 3.1 PASSES: ordinary path ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @Execute = 'N', @Debug = 1; +END TRY BEGIN CATCH PRINT 'EXPECTED LATE FAILURE: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 3.2 PASSES: path with apostrophe (legal but rare on Windows) ---'; +PRINT ' (validation gate must allow it; downstream sites must escape it)'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\It''s Friday\', + @Execute = 'N', @Debug = 1; +END TRY BEGIN CATCH PRINT 'EXPECTED LATE FAILURE: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 3.3 PASSES: @Database with ampersand (database identifier, not a path) ---'; +PRINT ' (DB names can legally contain & ; etc.; @Database is intentionally NOT'; +PRINT ' in the path-shape gate — it gets single-quote-escaped at concat sites)'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'My&DB', + @BackupPathFull = 'C:\Backups\', + @Execute = 'N', @Debug = 1; +END TRY BEGIN CATCH PRINT 'EXPECTED LATE FAILURE: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 3.4 PASSES: 1-part @RunStoredProcAfterRestore ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @RunStoredProcAfterRestore = 'MyProc', + @Execute = 'N', @Debug = 1; +END TRY BEGIN CATCH PRINT 'EXPECTED LATE FAILURE: ' + ERROR_MESSAGE(); END CATCH; +GO + +PRINT '--- 3.5 PASSES: 2-part @RunStoredProcAfterRestore ---'; +BEGIN TRY + EXEC dbo.sp_DatabaseRestore @Database = 'AnyDB', + @BackupPathFull = 'C:\Backups\', + @RunStoredProcAfterRestore = 'dbo.MyProc', + @Execute = 'N', @Debug = 1; +END TRY BEGIN CATCH PRINT 'EXPECTED LATE FAILURE: ' + ERROR_MESSAGE(); END CATCH; +GO + + +PRINT '===================================================='; +PRINT 'PART 4 - Logic-only verification (mimics what the proc does)'; +PRINT 'These do not call the proc; they reproduce the proc''s'; +PRINT 'PARSENAME + QUOTENAME / single-quote-doubling so you can see'; +PRINT 'on a fresh rig (without real backups) that injected payloads'; +PRINT 'are quoted into harmless identifiers / string literals.'; +PRINT '===================================================='; +GO + +PRINT '--- 4.1 NEUTRAL: @RunStoredProcAfterRestore with embedded ;DROP TABLE ---'; +PRINT ' Input is wrapped in a single bracketed identifier; SQL Server treats'; +PRINT ' the entire payload as one (non-existent) procedure name and the EXEC'; +PRINT ' simply fails to find it — no statement break, no DROP runs.'; +DECLARE @Input nvarchar(260) = N'dbo.MyProc; DROP TABLE foo; --'; +DECLARE @Schema sysname = NULLIF(PARSENAME(@Input, 2), N''); +DECLARE @Proc sysname = PARSENAME(@Input, 1); +DECLARE @Db nvarchar(128) = QUOTENAME('TargetDB'); +PRINT 'Input: ' + @Input; +PRINT 'Output: EXEC ' + @Db + N'.' + ISNULL(QUOTENAME(@Schema), N'') + N'.' + QUOTENAME(@Proc); +GO + +PRINT '--- 4.2 NEUTRAL: @DatabaseOwner with bracket-injection ---'; +PRINT ' QUOTENAME doubles the inner ] so the entire string becomes one'; +PRINT ' bracketed identifier; the EXISTS check on syslogins fails to find'; +PRINT ' the login and the proc just PRINTs "not a valid Login".'; +DECLARE @Owner sysname = N'sa]; DROP TABLE foo; --'; +DECLARE @TargetDb nvarchar(128) = QUOTENAME('TargetDB'); +PRINT 'Input: ' + @Owner; +PRINT 'Output: ALTER AUTHORIZATION ON DATABASE::' + @TargetDb + N' TO ' + QUOTENAME(@Owner); +GO + +PRINT '--- 4.3 NEUTRAL: 1-part proc name uses 3-part db..proc form ---'; +PRINT ' Without the second dot, [TargetDB].[MyProc] would be parsed as'; +PRINT ' schema=TargetDB.proc=MyProc in the *current* DB, not a proc in'; +PRINT ' the restored DB.'; +DECLARE @In nvarchar(260) = N'MyProc'; +DECLARE @S sysname = NULLIF(PARSENAME(@In, 2), N''); +DECLARE @P sysname = PARSENAME(@In, 1); +DECLARE @D nvarchar(128) = QUOTENAME('TargetDB'); +PRINT 'Input: ' + @In; +PRINT 'Output: EXEC ' + @D + N'.' + ISNULL(QUOTENAME(@S), N'') + N'.' + QUOTENAME(@P); +GO + +PRINT '--- 4.4 NEUTRAL: @StandbyUndoPath with apostrophe gets quote-doubled in dynamic RESTORE ---'; +PRINT ' The path itself is preserved verbatim; what doubles is the apostrophe'; +PRINT ' inside the SQL string literal. After SQL parses the literal, the value'; +PRINT ' is a path containing one apostrophe, which is what the user supplied.'; +DECLARE @StandbyPath nvarchar(max) = N'C:\Standby\It''s\'; +DECLARE @TestDb nvarchar(128) = N'TestDB'; +PRINT 'Input @StandbyUndoPath: ' + @StandbyPath; +PRINT 'Generated STANDBY clause: STANDBY = ''' + REPLACE(@StandbyPath, N'''', N'''''') + REPLACE(@TestDb, N'''', N'''''') + 'Undo.ldf'''; +GO + +PRINT '--- 4.5 NEUTRAL: nested-EXEC RESTORE HEADERONLY needs four-quote escape ---'; +PRINT ' The HEADERONLY/FILELISTONLY templates are EXEC(''RESTORE ... DISK='''''''''')'; +PRINT ' so the path crosses two SQL-parser layers. A single apostrophe in the'; +PRINT ' path needs to become four single quotes (REPLICATE(N'''''''', 4)) to'; +PRINT ' survive both layers and reappear as one apostrophe in the inner literal.'; +DECLARE @Tpl nvarchar(4000) = N'EXEC (''RESTORE HEADERONLY FROM DISK=''''{Path}'''''')'; +DECLARE @Path nvarchar(max) = N'C:\Backups\It''s\full.bak'; +DECLARE @Sql nvarchar(max) = REPLACE(@Tpl, N'{Path}', REPLACE(@Path, N'''', REPLICATE(N'''', 4))); +PRINT 'Input @Path: ' + @Path; +PRINT 'Outer @sql: ' + @Sql; +PRINT '(after the outer EXEC parses @sql, the inner RESTORE sees a literal'; +PRINT '''C:\Backups\It''s\full.bak'' — one apostrophe, exactly as supplied)'; +GO + + +PRINT '===================================================='; +PRINT 'Done. Suite passed if every BLOCKED test produced "BLOCKED:"'; +PRINT 'and every PASSES test produced "EXPECTED LATE FAILURE: (FULL)'; +PRINT 'No rows were returned..." (or completed without raising).'; +PRINT '===================================================='; +GO