sp_DatabaseRestore: harden against parameter SQL injection#3980
sp_DatabaseRestore: harden against parameter SQL injection#3980
Conversation
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.
There was a problem hiding this comment.
Pull request overview
This PR hardens dbo.sp_DatabaseRestore against parameter-based SQL injection by adding validation for path-like parameters and escaping/quoting user-controlled inputs in dynamic SQL and command construction.
Changes:
- Added a forbidden-character validation gate for path-shaped parameters to block high-risk shell metacharacters/control chars.
- Escaped single quotes in several dynamic
RESTORE ... FROM DISK = '...'andSTANDBY = '...'concatenation sites. - Improved identifier safety by switching
@DatabaseOwnertoQUOTENAME()and constraining/quoting@RunStoredProcAfterRestoreviaPARSENAME+QUOTENAME.
Comments suppressed due to low confidence (4)
sp_DatabaseRestore.sql:1518
- Same XML-escaping risk as the FULL/DIFF split-backup command:
FOR XML PATH('')will entity-encode characters like&in backup file names, which can corrupt theDISK=list in the generated RESTORE command. Consider switching toFOR XML PATH(''), TYPEwith.value(...)extraction to avoid XML encoding.
SET @sql = N'RESTORE LOG ' + @RestoreDatabaseName + N' FROM '
+ STUFF(
(SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + ''''
FROM #SplitLogBackups
WHERE DenseRank = @LogRestoreRanking
ORDER BY BackupFile
FOR XML PATH ('')),
sp_DatabaseRestore.sql:655
- The
REPLACE(@CurrentBackupPathFull, '''', '''''')here will change the actual Windows path passed toxp_cmdshell(Windows cmd treats single quotes as normal characters), so a valid folder likeC:\Backups\It's Friday\will be looked up asC:\Backups\It''s Friday\and fail. Forxp_cmdshell @cmdyou should keep the path unchanged and only escape quotes when embedding values into T-SQL string literals (dynamic SQL), not when building the command string variable itself.
SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathFull, N'''', N'''''') + N'"';
IF @Debug = 1
BEGIN
IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathFull';
PRINT @cmd;
END;
INSERT INTO @FileList (BackupFile) EXEC master.sys.xp_cmdshell @cmd;
sp_DatabaseRestore.sql:1081
- Same issue as the FULL path enumeration: doubling single quotes inside the
DIR /b "..."command changes the actual directory name passed toxp_cmdshell, breaking legitimate folders containing a single quote. The command string variable should keep the original path characters; escape is only needed when embedding into a T-SQL literal.
SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathDiff, N'''', N'''''') + N'"';
IF @Debug = 1
BEGIN
IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathDiff';
PRINT @cmd;
END;
INSERT INTO @FileList (BackupFile) EXEC master.sys.xp_cmdshell @cmd;
UPDATE @FileList SET BackupPath = @CurrentBackupPathDiff WHERE BackupPath IS NULL;
sp_DatabaseRestore.sql:1265
- Same issue as the FULL/DIFF enumerations:
REPLACE(@CurrentBackupPathLog, '''', '''''')will alter the actual path sent toxp_cmdshell, so directories with a single quote will no longer be found. Build theDIRcommand using the original path value, and reserve quote-doubling for dynamic SQL literals only.
SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathLog, N'''', N'''''') + N'"';
IF @Debug = 1
BEGIN
IF @cmd IS NULL PRINT '@cmd is NULL for @CurrentBackupPathLog';
PRINT @cmd;
END;
INSERT INTO @FileList (BackupFile) EXEC master.sys.xp_cmdshell @cmd;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| (SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + '''' | ||
| FROM #SplitDiffBackups | ||
| ORDER BY BackupFile | ||
| FOR XML PATH ('')), |
| 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; |
| SET @sql = N'EXEC ' + @RestoreDatabaseName + N'.' | ||
| + COALESCE(QUOTENAME(@RunStoredProcSchema) + N'.', N'') | ||
| + QUOTENAME(@RunStoredProcName); |
| (SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + '''' | ||
| FROM #SplitFullBackups | ||
| ORDER BY BackupFile | ||
| FOR XML PATH ('')), |
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Hardens sp_DatabaseRestore dynamic SQL and command construction to reduce parameter-based SQL injection / command-injection risk for non-sysadmin callers who can EXEC the proc.
Changes:
- Adds a validation gate for “path-shaped” parameters to reject high-risk shell metacharacters/control characters.
- Escapes single quotes at multiple dynamic
RESTORE ... FROM DISK = '...'concatenation sites (including nestedEXECtemplates) and adjusts split-backup list building to useFOR XML PATH(...), TYPE).value(...). - Uses
QUOTENAME(@DatabaseOwner)forALTER AUTHORIZATION, and constrains/quotes@RunStoredProcAfterRestoreviaPARSENAME+QUOTENAME.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ELSE | ||
| BEGIN | ||
| SET @cmd = N'DIR /b "' + @CurrentBackupPathDiff + N'"'; | ||
| SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathDiff, N'''', N'''''') + N'"'; |
| ELSE | ||
| BEGIN | ||
| SET @cmd = N'DIR /b "' + @CurrentBackupPathLog + N'"'; | ||
| SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathLog, N'''', N'''''') + N'"'; |
| @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. */ |
| 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. */ |
| ELSE | ||
| BEGIN | ||
| SET @cmd = N'DIR /b "' + @CurrentBackupPathFull + N'"'; | ||
| SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathFull, N'''', N'''''') + N'"'; |
- 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) <noreply@anthropic.com>
Summary
Hardens
sp_DatabaseRestoreagainst parameter-based SQL injection. Threat model: a caller withEXECUTEon the proc but not sysadmin should not be able to escalate to arbitrary T-SQL or shell command execution by passing crafted parameter values.",&,|,;,^,<,>, NUL, CR, LF — characters with no legitimate use in Windows paths but high command-injection risk. Single quotes are allowed (legal but rare in real paths) and escaped at concat sites instead. Applied to@BackupPathFull/Diff/Log,@MoveDataDrive/LogDrive/FilestreamDrive/FullTextCatalogDrive,@StandbyUndoPath,@FileNamePrefix. (@Databaseis not in this list — it's a database identifier, not a path;&/;are legal in DB names. It's protected via single-quote escaping at the few sites it gets concatenated into a quoted SQL literal.) The LIKE pattern usesCOLLATE Latin1_General_BIN2so the[…]class catches NCHAR(0) — under the default collation NUL is silently dropped from both pattern and value.QUOTENAME(@DatabaseOwner)in theALTER AUTHORIZATIONbuild (was'[' + @DatabaseOwner + ']', which a]in the value would escape).PARSENAME+QUOTENAMEfor@RunStoredProcAfterRestorebefore theEXECbuild. Rejects 3- or 4-part names; quotes both the schema and proc parts. Always emits a 3-part name — for 1-part input the schema slot is left empty ([db]..[proc]) so SQL Server's own schema resolution kicks in; without the second dot,[db].[proc]parses as schema.object in the current DB. Closes the highest-severity site — previously the value was concatenated raw and passed straight tosp_executesql @sql.@Databasein'...'. Includes single-fileRESTORE DATABASE/LOG FROM DISK = '...',STANDBY = '...', and the per-row split-backupDISK=entries built viaFOR XML PATH. Note: this is not applied to thexp_cmdshell DIR /b "..."command-string build — cmd.exe doesn't interpret''as an escape, so doubling there would corrupt legitimate paths with an apostrophe; the value is just passed as-is to xp_cmdshell and never crosses a SQL parser again., TYPE).value('.', 'nvarchar(max)')on the threeFOR XML PATHsplit-backup builders so legitimate filenames containing&/</>don't get XML-entity-encoded into the generatedRESTORE DISK=paths.REPLICATE(N'''', 4)) for{Path}substitution in theRESTORE HEADERONLY/RESTORE FILELISTONLYtemplates, where the path lives inside a nestedEXECliteral (two parser layers).Out of scope (deliberate): filenames sourced from the filesystem (xp_dirtree / xp_cmdshell output) and
RESTORE FILELISTONLYoutput flowing back into MOVE clauses. An attacker who can write into the backup folder is already trusted in this model. (The XML-PATH split-backup sites do escapeBackupFilealongsideBackupPathfor code-shape consistency, but that's a side effect, not the intended fix.)Test plan
Verified end-to-end against SQL Server 2025 RTM-CU4. All eleven injection probes plus regression cases pass:
C:\Backups\,\\server\share\, and a path containing a single quote (C:\Backups\It's Friday\). The xp_cmdshellDIR /bcommand preserves the apostrophe correctly (single quote, not doubled).@DatabaseOwner = 'sa'and@RunStoredProcAfterRestore = 'dbo.MyProc'(and'MyProc') — both accepted; the generatedALTER AUTHORIZATIONandEXECstrings (visible with@Debug = 1, @Execute = 'N') show[sa]and[Test].[dbo].[MyProc]/[Test]..[MyProc]respectively.@Database = 'My&DB'— passes validation (database identifier, not a path).@BackupPathFull = 'C:\Backups\"& whoami &"'— validation gate rejects with RAISERROR + RETURN.@BackupPathFull = 'C:\Backups\; xp_cmdshell ''calc''; --'— same, blocked by;rejection.@MoveDataDrivecontaining NCHAR(0) — caught by the BIN2-collated LIKE.@DatabaseOwner = 'sa]; DROP TABLE foo; --'—QUOTENAMEproduces a single bracketed identifier; theEXISTScheck inmaster.dbo.sysloginsfails to find that login and the proc falls through to the "not a valid Login" PRINT path with no injected statement executed.@RunStoredProcAfterRestore = 'dbo.MyProc; DROP TABLE foo; --'—PARSENAME(..., 1)returnsMyProc; DROP TABLE foo; --, gets wrapped in brackets; theEXECfails to find a procedure with that bracketed name.@RunStoredProcAfterRestore = 'srv.db.dbo.MyProc'— rejected with the new RAISERROR (4-part name not allowed).🤖 Generated with Claude Code