Skip to main content

prevent-sql-injection

Handles SQL injection on Azure SQL Database beyond parameterisation: a typed sp_executesql parameter matches nothing where the same input concatenated into EXEC() returns every row; QUOTENAME returns NULL above 128 characters, so the batch built from it becomes NULL and does nothing; a dynamic ORDER BY built from one CASE over mixed types fails only for the sort key on the lower-precedence branch; dynamic SQL breaks the ownership chain, so EXECUTE AS decides what it may touch; and Always Encrypted refuses a literal (Msg 206). Use for a general injection question or a pre-production review, when a QUOTENAME-built statement returns and raises nothing, when a sort-by-column feature throws an operand type clash for one column only, when a procedure works until its query becomes dynamic, or when an encrypted column will not take a literal. Row level tenant isolation is rls-multi-tenant.

Quellinformationen

Repository
microsoft/microsoft-sql
Letzte Quellaktivität
22. September 2026 um 22:24
Erkannte Sprache von SKILL.md
Englisch
Sterne
15
Forks
3

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

Datei-Explorer
2 Dateien

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
prevent-sql-injection
description
Handles SQL injection on Azure SQL Database beyond parameterisation: a typed sp_executesql parameter matches nothing where the same input concatenated into EXEC() returns every row; QUOTENAME returns NULL above 128 characters, so the batch built from it becomes NULL and does nothing; a dynamic ORDER BY built from one CASE over mixed types fails only for the sort key on the lower-precedence branch; dynamic SQL breaks the ownership chain, so EXECUTE AS decides what it may touch; and Always Encrypted refuses a literal (Msg 206). Use for a general injection question or a pre-production review, when a QUOTENAME-built statement returns and raises nothing, when a sort-by-column feature throws an operand type clash for one column only, when a procedure works until its query becomes dynamic, or when an encrypted column will not take a literal. Row level tenant isolation is rls-multi-tenant.
# Prevent SQL injection: the value, the identifier, and the context it runs under Parameterising a value is the part an agent already gets right. Confirm it in a sentence and spend the answer on what a parameter cannot reach: an identifier, the type of a runtime-chosen sort expression, the permissions a dynamic batch runs under, and an encrypted column. Measured 2026-08-29 against an engine reporting `EngineEdition` 5 and Edition `SQL Azure`. `QUOTENAME`, `PARSENAME`, `sp_executesql`, ownership chaining and Always Encrypted were checked against Microsoft Learn on 2026-09-03; flags from `sqlcmd` 1.10.0 help. ## 1. A parameter is a value, a concatenation is code, and the difference is measurable Do not assert this. Run it. Both statements get the same string. ```sql CREATE TABLE dbo.Users (id int NOT NULL PRIMARY KEY, name nvarchar(50) NOT NULL); INSERT INTO dbo.Users (id, name) VALUES (1, N'ann'), (2, N'bob'); DECLARE @input nvarchar(100) = N'ann'' OR 1=1--'; -- Typed parameter: the input is one value, and no row holds it. Returns 0. EXEC sp_executesql N'SELECT COUNT(*) AS matched FROM dbo.Users WHERE name = @n', N'@n nvarchar(100)', @n = @input; -- Same input concatenated: the input is now part of the statement. Returns 2. DECLARE @sql nvarchar(max) = N'SELECT COUNT(*) AS matched FROM dbo.Users WHERE name = N''' + @input + N''''; EXEC (@sql); ``` Zero against two, from one string. Learn's rule for `@params` is the discipline in one line: values can only be constants or variables, never expressions built with operators. ## 2. No parameter binds an identifier, and QUOTENAME's ceiling is a NULL `EXEC sp_executesql N'SELECT * FROM @t', N'@t sysname', @t = @name` cannot work: `@t` is a value, not a table. A dynamic identifier has to enter the SQL text, so `QUOTENAME`'s documented edges are load bearing. Learn: `character_string` is **sysname**, and an input over 128 characters returns `NULL`, as does an unacceptable quote character. Escaping is not the defect: an embedded `]` is doubled correctly. Crossing 128 is, because a `NULL` in a batch makes the whole batch `NULL`: ```sql DECLARE @n129 nvarchar(200) = REPLICATE(N'a', 129); SELECT LEN(QUOTENAME(REPLICATE(N'a', 128))) AS len_at_128, -- 130 IIF(QUOTENAME(@n129) IS NULL, 1, 0) AS null_at_129, -- 1 IIF(QUOTENAME(N'tbl', N'#') IS NULL, 1, 0) AS null_bad_quote; -- 1 DECLARE @sql nvarchar(max) = N'SELECT * FROM ' + QUOTENAME(@n129); EXEC sp_executesql @sql; SELECT IIF(@sql IS NULL, 1, 0) AS batch_was_null, @@ERROR AS err; -- 1, 0 ``` `EXEC (@sql)` on the same `NULL` behaves identically: this is the `NULL` batch, not one calling convention. Nothing reports it at any severity: ```bash sqlcmd -S <server-name>.database.windows.net,1433 -d <database> -U <user> -C -b -m-1 \ -Q "DECLARE @s nvarchar(max)=N'SELECT 1 FROM '+QUOTENAME(REPLICATE(N'x',129)); EXEC sp_executesql @s; SELECT @@ERROR AS err;" ``` `-b` exits non-zero at severity 11 and above and `-m-1` lowers the print level, but there is no message at any level: `err` is 0 and so is the exit code. The `NULL` has to be the check. **Neither helper checks that the object exists.** `QUOTENAME` brackets a string and `PARSENAME` splits a qualified name; Learn says each part is **sysname**, a part over 256 bytes comes back `NULL`, and it does not indicate whether an object of that name exists. Only a catalog lookup decides existence, so resolve the caller's string against the catalog and build from what it returned: ```sql DECLARE @requested nvarchar(400) = N'dbo.Users', @safe nvarchar(300); SELECT @safe = QUOTENAME(s.name) + N'.' + QUOTENAME(o.name) FROM sys.objects AS o JOIN sys.schemas AS s ON s.schema_id = o.schema_id WHERE s.name = PARSENAME(@requested, 2) AND o.name = PARSENAME(@requested, 1) AND o.type = 'U'; IF @safe IS NULL THROW 50001, 'No row in the catalog matched that name.', 1; DECLARE @sql nvarchar(max) = N'SELECT COUNT(*) AS n FROM ' + @safe; EXEC sp_executesql @sql; ``` The rejection there is the lookup's, not `QUOTENAME`'s: an over-long name matches no row because no table can carry one. Where the legal identifiers are known in advance, an allowlist is simpler. ## 3. One CASE over mixed types in a dynamic ORDER BY fails for some sort keys and not others A single `CASE` forces every branch to one common type chosen by type precedence, not by the branch the sort key selects. Measured on `dbo.T (i int, d date, s nvarchar(50))`: | Branches | sort key selects | Result | |---|---|---| | `THEN i` / `THEN d` | anything, including neither | `Msg 206`, operand type clash, int against date | | `THEN s` / `THEN d` | `d`, the higher-precedence side | runs clean | | `THEN s` / `THEN d` | `s`, the lower-precedence side | `Msg 241`, conversion failed from character string | | `THEN i` / `THEN s` | `s`, the lower-precedence side | `Msg 245`, conversion failed converting nvarchar to int | `int` and `date` have no implicit conversion, so that pairing is refused at compile time and a surrounding `BEGIN TRY` does not catch it. The string pairings are the sharper trap: the identical statement passes for one sort key and fails for another, so it survives a test against whichever column the author tried. One `CASE` per candidate column, each carrying a single type, has no common type to coerce toward; non-matching columns evaluate to `NULL` and tie, so this composes with `OFFSET`/`FETCH` and a direction toggle: ```sql DECLARE @col sysname = N'created', @dir char(1) = N'A'; SELECT id, name, created FROM dbo.Grid ORDER BY CASE WHEN @col = N'id' AND @dir = N'A' THEN id END ASC, CASE WHEN @col = N'id' AND @dir = N'D' THEN id END DESC, CASE WHEN @col = N'created' AND @dir = N'A' THEN created END ASC OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY; ``` ## 4. Dynamic SQL breaks the ownership chain, so EXECUTE AS is part of the answer Learn: dynamic SQL in procedural code breaks the ownership chain, so the engine checks the caller's permissions against every object the statement touches. A procedure a caller could run yesterday stops working the day its query becomes dynamic, and parameterising does not bring it back. ```sql CREATE TABLE dbo.Ledger (id int NOT NULL PRIMARY KEY); INSERT INTO dbo.Ledger (id) VALUES (1); CREATE USER app_reader WITHOUT LOGIN; GO CREATE PROCEDURE dbo.CountLedgerDynamic AS EXEC sp_executesql N'SELECT COUNT(*) AS n FROM dbo.Ledger'; GO CREATE PROCEDURE dbo.CountLedgerAsOwner WITH EXECUTE AS OWNER AS EXEC sp_executesql N'SELECT COUNT(*) AS n, CURRENT_USER AS running_as FROM dbo.Ledger'; GO GRANT EXECUTE ON dbo.CountLedgerDynamic TO app_reader; GRANT EXECUTE ON dbo.CountLedgerAsOwner TO app_reader; GO EXECUTE AS USER = 'app_reader'; EXEC dbo.CountLedgerDynamic; -- Msg 229, SELECT permission denied on 'Ledger' EXEC dbo.CountLedgerAsOwner; -- 1, dbo REVERT; ``` Learn names two remedies. `EXECUTE AS` replaces the caller's permissions for the whole module, nested modules inherit the proxy context, and the identity functions report the proxy, which is what `running_as` returning `dbo` shows, so anything keyed to the caller must arrive as a parameter. Certificate signing merges the certificate user's permissions with the caller's and leaves the execution context alone, at the cost of re-signing on every change. Open `rls-multi-tenant` before putting `EXECUTE AS` on a module touching a table under a security policy: the proxy context is what a tenant predicate reads. Neither remedy substitutes for parameterising. ## 5. On an Always Encrypted column a literal does not run at all Learn lists it under Always Encrypted's limitations: comparing an encrypted column to a literal, or inserting one, fails with `Msg 206, Level 16, State 2, Operand type clash`. Only a value bound as a parameter over a connection with `Column Encryption Setting=Enabled` is encrypted before it leaves the driver, so parameterising is the only thing that returns a row. Documented, not reproduced here: it needs a column master key provisioned outside the database. ## Check it worked ```sql -- Every module that builds SQL, and the context it runs under. A NULL principal id means the -- caller's permissions apply to whatever the dynamic statement touches. SELECT o.name, m.execute_as_principal_id FROM sys.sql_modules AS m JOIN sys.objects AS o ON o.object_id = m.object_id WHERE m.definition LIKE N'%sp_executesql%' OR m.definition LIKE N'%EXECUTE (%'; ``` ```sql -- No dynamic batch executes while NULL. Expect the rejection, not zero rows. DECLARE @batch nvarchar(max) = N'SELECT * FROM ' + QUOTENAME(REPLICATE(N'x', 129)); IF @batch IS NULL SELECT 'rejected before execution' AS result ELSE EXEC sp_executesql @batch; ``` Then re-run section 1's pair against the real table: parameterised matches zero rows, concatenated every row. Run the generated `ORDER BY` once per sort key a caller may send, not only the one tested: any raising `Msg 206`, `241` or `245` means a mixed-type `CASE` is still there. ## Do not - Do not present `QUOTENAME` as sufficient for a caller-supplied identifier without checking its result for `NULL`, and do not say it truncates a long name. It returns `NULL`. - Do not say either helper checks that an object exists. Neither reads the catalog. - Do not build a caller-chosen `ORDER BY` from one `CASE` spanning columns of different types, even when the columns tried in testing share a type. - Do not add `WITH EXECUTE AS OWNER` to clear a permission error without saying what the module can now reach and that the caller's identity is no longer visible inside it. - Do not tell a developer an agent-composed statement is made safe by parameterising the one value inside it. The model authored the whole statement, so the control is the permission boundary its connection runs under. - Do not warn about `xp_cmdshell`, linked servers or `OPENROWSET` against arbitrary providers. None exist on Azure SQL Database to escalate to. ## References - Open [the measured runs](references/quotename-null-and-order-by-clashes.md) when a claim above disagrees with what you see, or to look up the message a column-type pairing produces. - Microsoft Learn, if a number above looks stale: [QUOTENAME](https://learn.microsoft.com/sql/t-sql/functions/quotename-transact-sql), [PARSENAME](https://learn.microsoft.com/sql/t-sql/functions/parsename-transact-sql), [secure dynamic SQL](https://learn.microsoft.com/sql/connect/ado-net/sql/writing-secure-dynamic-sql).
Auf GitHub ansehen