Skip to content

sp_DatabaseRestore: harden against parameter SQL injection#3980

Open
BrentOzar wants to merge 4 commits intodevfrom
claude/elegant-leavitt-274be8
Open

sp_DatabaseRestore: harden against parameter SQL injection#3980
BrentOzar wants to merge 4 commits intodevfrom
claude/elegant-leavitt-274be8

Conversation

@BrentOzar
Copy link
Copy Markdown
Member

@BrentOzar BrentOzar commented Apr 30, 2026

Summary

Hardens sp_DatabaseRestore against parameter-based SQL injection. Threat model: a caller with EXECUTE on the proc but not sysadmin should not be able to escalate to arbitrary T-SQL or shell command execution by passing crafted parameter values.

  • Validation gate for path-shaped parameters. Rejects ", &, |, ;, ^, <, >, 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. (@Database is 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 uses COLLATE Latin1_General_BIN2 so the […] class catches NCHAR(0) — under the default collation NUL is silently dropped from both pattern and value.
  • QUOTENAME(@DatabaseOwner) in the ALTER AUTHORIZATION build (was '[' + @DatabaseOwner + ']', which a ] in the value would escape).
  • PARSENAME + QUOTENAME for @RunStoredProcAfterRestore before the EXEC build. 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 to sp_executesql @sql.
  • Single-quote escape at every dynamic-RESTORE concat site that wraps a caller-supplied path or @Database in '...'. Includes single-file RESTORE DATABASE/LOG FROM DISK = '...', STANDBY = '...', and the per-row split-backup DISK= entries built via FOR XML PATH. Note: this is not applied to the xp_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 three FOR XML PATH split-backup builders so legitimate filenames containing &/</> don't get XML-entity-encoded into the generated RESTORE DISK= paths.
  • Doubled-twice escape (REPLICATE(N'''', 4)) for {Path} substitution in the RESTORE HEADERONLY / RESTORE FILELISTONLY templates, where the path lives inside a nested EXEC literal (two parser layers).

Out of scope (deliberate): filenames sourced from the filesystem (xp_dirtree / xp_cmdshell output) and RESTORE FILELISTONLY output 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 escape BackupFile alongside BackupPath for 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:

  • Deploy + verify the proc compiles cleanly.
  • Regression — legitimate paths still work: C:\Backups\, \\server\share\, and a path containing a single quote (C:\Backups\It's Friday\). The xp_cmdshell DIR /b command preserves the apostrophe correctly (single quote, not doubled).
  • @DatabaseOwner = 'sa' and @RunStoredProcAfterRestore = 'dbo.MyProc' (and 'MyProc') — both accepted; the generated ALTER AUTHORIZATION and EXEC strings (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).
  • Injection attempts blocked or neutralized:
    • @BackupPathFull = 'C:\Backups\"& whoami &"' — validation gate rejects with RAISERROR + RETURN.
    • @BackupPathFull = 'C:\Backups\; xp_cmdshell ''calc''; --' — same, blocked by ; rejection.
    • @MoveDataDrive containing NCHAR(0) — caught by the BIN2-collated LIKE.
    • @DatabaseOwner = 'sa]; DROP TABLE foo; --'QUOTENAME produces a single bracketed identifier; the EXISTS check in master.dbo.syslogins fails 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) returns MyProc; DROP TABLE foo; --, gets wrapped in brackets; the EXEC fails 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

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.
Copilot AI review requested due to automatic review settings April 30, 2026 11:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = '...' and STANDBY = '...' concatenation sites.
  • Improved identifier safety by switching @DatabaseOwner to QUOTENAME() and constraining/quoting @RunStoredProcAfterRestore via PARSENAME + 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 the DISK= list in the generated RESTORE command. Consider switching to FOR XML PATH(''), TYPE with .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 to xp_cmdshell (Windows cmd treats single quotes as normal characters), so a valid folder like C:\Backups\It's Friday\ will be looked up as C:\Backups\It''s Friday\ and fail. For xp_cmdshell @cmd you 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 to xp_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 to xp_cmdshell, so directories with a single quote will no longer be found. Build the DIR command 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.

Comment thread sp_DatabaseRestore.sql Outdated
(SELECT CHAR( 10 ) + ',DISK=''' + REPLACE(BackupPath, '''', '''''') + REPLACE(BackupFile, '''', '''''') + ''''
FROM #SplitDiffBackups
ORDER BY BackupFile
FOR XML PATH ('')),
Comment thread sp_DatabaseRestore.sql Outdated
Comment on lines +555 to +559
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;
Comment thread sp_DatabaseRestore.sql
Comment on lines +1706 to +1708
SET @sql = N'EXEC ' + @RestoreDatabaseName + N'.'
+ COALESCE(QUOTENAME(@RunStoredProcSchema) + N'.', N'')
+ QUOTENAME(@RunStoredProcName);
Comment thread sp_DatabaseRestore.sql Outdated
(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>
@BrentOzar BrentOzar added this to the 2026-07 Release milestone Apr 30, 2026
@BrentOzar BrentOzar self-assigned this Apr 30, 2026
- 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 ('&amp;', '&lt;') 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nested EXEC templates) and adjusts split-backup list building to use FOR XML PATH(...), TYPE).value(...).
  • Uses QUOTENAME(@DatabaseOwner) for ALTER AUTHORIZATION, and constrains/quotes @RunStoredProcAfterRestore via PARSENAME + QUOTENAME.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread sp_DatabaseRestore.sql Outdated
ELSE
BEGIN
SET @cmd = N'DIR /b "' + @CurrentBackupPathDiff + N'"';
SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathDiff, N'''', N'''''') + N'"';
Comment thread sp_DatabaseRestore.sql Outdated
ELSE
BEGIN
SET @cmd = N'DIR /b "' + @CurrentBackupPathLog + N'"';
SET @cmd = N'DIR /b "' + REPLACE(@CurrentBackupPathLog, N'''', N'''''') + N'"';
Comment thread sp_DatabaseRestore.sql
Comment on lines +545 to +547
@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. */
Comment thread sp_DatabaseRestore.sql Outdated
Comment on lines +1710 to +1711
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. */
Comment thread sp_DatabaseRestore.sql Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants