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 diff --git a/sp_DatabaseRestore.sql b/sp_DatabaseRestore.sql index d6156ba6..8a62dc88 100755 --- a/sp_DatabaseRestore.sql +++ b/sp_DatabaseRestore.sql @@ -538,6 +538,30 @@ 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. + 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. + @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'; +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 NOT NULL +BEGIN + RAISERROR('Parameter %s contains a forbidden character. Not allowed: " & | ; ^ < > or control characters.', 16, 1, @InvalidPathParam) WITH NOWAIT; + RETURN; +END; + --File Extension cleanup IF @FileExtensionDiff LIKE '%.%' BEGIN @@ -762,7 +786,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 +802,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,17 +963,17 @@ 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 ('')), + FOR XML PATH (''), TYPE).value('.', 'nvarchar(max)'), 1, 2, '') + N' WITH NORECOVERY, REPLACE' + @BackupParameters + @MoveOption + NCHAR(13) + NCHAR(10); 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 +983,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' @@ -1131,16 +1155,16 @@ 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 ('')), + FOR XML PATH (''), TYPE).value('.', 'nvarchar(max)'), 1, 2, '' ) + 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 +1173,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 +1186,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 @@ -1365,7 +1389,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 +1474,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,17 +1514,17 @@ 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 - FOR XML PATH ('')), + FOR XML PATH (''), TYPE).value('.', 'nvarchar(max)'), 1, 2, '' ) + 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 +1606,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 +1696,23 @@ 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 + /* Always emit a 3-part name (db.schema.proc). For 1-part input the schema slot is left empty + ([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'.' + + QUOTENAME(@RunStoredProcName); IF @Debug = 1 OR @Execute = 'N' BEGIN