web-dev-qa-db-fra.com

Empêcher l'injection SQL dans SQL dynamique

Imaginons une procédure stockée qui récupère des données et effectue une sorte de pagination. Cette procédure contient des entrées décrivant quel ensemble de données nous voulons et comment nous le trions.

Voici une requête très simple, mais prenons-la comme exemple.

create table Persons(id int, firstName varchar(50), lastName varchar(50))
go
create procedure GetPersons @pageNumber int = 1, @pageSize int = 20, @orderBy varchar(50) = 'id', @orderDir varchar(4) = 'desc'
as

declare @sql varchar(max)
set @sql = 'select id, firstName, lastName
from (
    select id, firstName, LastName, row_number() over(order by '+@orderBy+' '+@orderDir+') as rn
    from Persons
    ) t
where rn > ('+cast(@pageNumber as varchar)+'-1) * '+cast(@pageSize as varchar)+'
        and rn <= '+cast(@pageNumber as varchar)+' * '+cast(@pageSize as varchar)+' 
order by '+@orderBy+' '+@orderDir

exec(@sql)

Il est censé être utilisé comme ça:

exec GetPersons @pageNumber = 1, @pageSize = 20, @orderBy = 'id', @orderDir = 'desc'

Mais un gars intelligent pourrait lancer:

exec GetPersons @pageNumber = 1, @pageSize = 20, @orderBy = 'id)a from Persons)t;delete from Persons;print''', @orderDir = ''

... et déposer des données

Ce n'est évidemment pas une situation sûre. Et comment pourrions-nous l'empêcher?

Note: cette question ne concerne pas "est-ce un bon moyen de faire la pagination?" ni "est-ce une bonne chose de faire du sql dynamique?". La question est d'empêcher l'injection de code lors de la construction dynamique des requêtes SQL afin d'avoir des directives pour rendre le code un peu plus propre si nous devons refaire des procédures stockées similaires à l'avenir.

Quelques idées de base:

Valider les entrées

create procedure GetPersons @pageNumber int = 1, @pageSize int = 20, @orderBy varchar(50) = 'id', @orderDir varchar(4) = 'desc'
as

if @orderDir not in ('asc', 'desc') or @orderBy not in ('id', 'firstName', 'lastName')
begin
    raiserror('Cheater!', 16,1)
    return
end

declare @sql varchar(max)
set @sql = 'select id, firstName, lastName
from (
    select id, firstName, LastName, row_number() over(order by '+@orderBy+' '+@orderDir+') as rn
    from Persons
    ) t
where rn > ('+cast(@pageNumber as varchar)+'-1) * '+cast(@pageSize as varchar)+'
        and rn <= '+cast(@pageNumber as varchar)+' * '+cast(@pageSize as varchar)+' 
order by '+@orderBy+' '+@orderDir

exec(@sql)

Passez les identifiants au lieu des chaînes comme entrées

create procedure GetPersons @pageNumber int = 1, @pageSize int = 20, @orderBy tinyint = 1, @orderDir bit = 0
as

declare @orderByName varchar(50)
set @orderByName =  case @orderBy when 1 then 'id'
                        when 2 then 'firstName'
                        when 3 then 'lastName'
                    end 
                +' '+case @orderDir 
                        when 0 then 'desc' 
                        else 'asc' 
                    end

if @orderByName is null
begin
    raiserror('Cheater!', 16,1)
    return
end

declare @sql varchar(max)
set @sql = 'select id, firstName, lastName
from (
    select id, firstName, LastName, row_number() over(order by '+@orderByName+') as rn
    from Persons
    ) t
where rn > ('+cast(@pageNumber as varchar)+'-1) * '+cast(@pageSize as varchar)+'
        and rn <= '+cast(@pageNumber as varchar)+' * '+cast(@pageSize as varchar)+' 
order by '+@orderByName

exec(@sql)

D'autres suggestions?

6
irimias

Dans votre exemple de code, vous passez trois catégories de "choses" dans votre SQL dynamique.

  1. Vous passez @OrderDir, qui est un mot clé pour signifier ASC ou DESC.
  2. Vous passez @OrderBy, qui est un nom de colonne (ou potentiellement un ensemble de noms de colonnes, mais en fonction de la façon dont # 1 est implémenté, je suppose que vous vous attendez à un nom de colonne unique.
  3. Vous passez @PageNumber et @PageSize, qui deviennent des littéraux dans la chaîne générée.

Mots clés

C'est vraiment simple - vous voulez juste valider votre entrée. Vous savez parfaitement que c'est la bonne chose pour cette option. Dans ce cas, vous vous attendez à ASC ou DESC, vous pouvez donc soit vérifier que l'utilisateur transmet l'une de ces valeurs, soit basculer vers un autre paramètre sémantique, où vous avez un paramètre qui est un interrupteur à bascule. Déclarez votre paramètre comme @SortAscending bit = 0, puis dans votre procédure stockée, traduisez le bit en ASC ou DESC.

Noms des colonnes

Ici, vous devez utiliser la fonction QUOTENAME. Quotename s'assurera que les objets sont correctement [entre guillemets], garantissant que si quelqu'un essaie de passer dans une "colonne" de "; TRUNCATE TABLE USERS", il sera traité comme un nom de colonne, et non comme un morceau arbitraire de code injecté. Cela échouera, plutôt que de tronquer la table USERS:

SELECT [; TRUNCATE TABLE USERS]...
FROM...

Littéraux et paramètres

Pour @PageNumber et @PageSize, vous devez utiliser sp_executesql pour passer correctement les paramètres. Le paramétrage correct de votre SQL dynamique vous permet non seulement de transmettre des valeurs, mais également de récupérer des valeurs .

Dans cet exemple, @x et @y serait des variables étendues à vos procédures stockées. Ils ne sont pas disponibles dans votre SQL dynamique, vous les transmettez donc à @a et @b, qui sont limités au SQL dynamique. Cela vous permet d'avoir des valeurs correctement saisies à l'intérieur et à l'extérieur du SQL dynamique.

DECLARE @i int,
 @x int,
 @y int,
 @sql nvarchar(1000),
 @params nvarchar(1000);


SET @x = 10;
SET @y = 5;
SET @params = N'@i_out int OUT, @a int, @b int';
SET @sql    = N'SELECT @i_out = @a + @b';


EXEC sp_executesql @sql, @params, @i_out = @i OUT, @a = @x, @b = @y; 
SELECT @i;

Même avec des valeurs varchar, conserver la valeur en tant que variable empêche quelqu'un de passer arbitrairement du code qui est exécuté. Cet exemple garantit que l'entrée utilisateur est SELECTed et n'est pas exécutée arbitrairement:

DECLARE @UserInput varchar(100),
         @params nvarchar(1000) = N'@value varchar(100)',
         @sql nvarchar(1000)    = N'SELECT Value = @value';

SET @UserInput = '; TRUNCATE TABLE USERS;'
EXEC sp_executesql @sql, @params, @value = @UserInput;  

Mon code

Voici ma version de votre procédure stockée, avec la définition de la table et quelques exemples de lignes:

CREATE TABLE dbo.Persons
       (
        id INT,
        firstName VARCHAR(50),
        lastName VARCHAR(50)
       );
GO

INSERT INTO dbo.Persons(id, firstName,lastName)
VALUES (1,'George','Washington'),
       (2,'John','Adams'),
       (3,'Thomas','Jefferson'),
       (4,'James','Madison'),
       (5,'James','Monroe')


ALTER PROCEDURE dbo.GetPersons
       @pageNumber INT = 1,
       @pageSize INT = 20,
       @orderBy VARCHAR(50) = 'id',
       @orderDir VARCHAR(4) = 'desc'
AS
       SET NOCOUNT ON;

--validate inputs
IF NOT EXISTS ( SELECT   1 FROM     sys.columns
                WHERE    object_id = OBJECT_ID('dbo.Persons')
                AND name = @orderBy )
BEGIN
        RAISERROR('Order by column does not exist.', 16,1);
        RETURN;
END;

IF (@orderDir NOT IN ('ASC', 'DESC'))
BEGIN
        RAISERROR('Order direction is invalid. Must be ASC or DESC.', 16,1);
        RETURN;
END;

--Now do stuff
--sp_executesql takes in nvarchar as a datatype
DECLARE @sql NVARCHAR(MAX);

SET @sql = N'SELECT id, firstName, lastName
FROM (
    SELECT id, firstName, LastName, ROW_NUMBER() OVER(ORDER BY '
           + QUOTENAME(@orderBy) + N' ' + @orderDir + N') AS rn
    FROM dbo.Persons
    ) t
WHERE rn > ( @pageNumber-1) * @pageSize
        AND rn <= @pageNumber * @pageSize 
ORDER BY ' + QUOTENAME(@orderBy) + N' ' + @orderDir;

EXEC sys.sp_executesql @sql, N'@pageNumber int, @pageSize int',
                   @pageNumber = @pageNumber, @pageSize = @pageSize;

GO

Vous pouvez voir ici que le code est fonctionnel et vous donne l'ordre et la pagination appropriés:

EXEC dbo.GetPersons @OrderBy = 'id', @orderDir = 'DESC';
EXEC dbo.GetPersons @OrderBy = 'id', @orderDir = 'ASC';
EXEC dbo.GetPersons @OrderBy = 'firstName';
EXEC dbo.GetPersons @OrderBy = 'lastName';
EXEC dbo.GetPersons @PageNumber = 2, @PageSize = 1, @OrderBy = 'lastName', @orderDir = 'ASC';

Et voyez également comment la gestion des entrées protège contre quelqu'un qui essaie de faire des choses étranges:

EXEC dbo.GetPersons @OrderBy = 'lastName', @orderDir = 'UP';
EXEC dbo.GetPersons @OrderBy = ';TRUNCATE TABLE Persons;';

Lecture supplémentaire

exemple sp_executesql

Les mauvaises habitudes d'Aaron Bertrand: Kick: Utiliser EXEC () au lieu de sp_executesql

Procédure d'évier de cuisine d'Aaron Bertrand

9
AMtwo

Une méthode courante pour atténuer l'injection SQL consiste à utiliser QUOTENAME autour des variables transmises à la procédure stockée.

Donc, dans votre exemple, le code pourrait être modifié comme ceci:

declare @sql varchar(max)
set @sql = 'select id, firstName, lastName
from (
    select id, firstName, LastName, row_number() over(order by '+@orderBy+' '+@orderDir+') as rn
    from Persons
    ) t
where rn > ('+cast(@pageNumber as varchar)+'-1) * '+cast(@pageSize as varchar)+'
        and rn <= '+cast(@pageNumber as varchar)+' * '+cast(@pageSize as varchar)+' 
order by '+ Quotename(@orderBy)+' '+@orderDir

Si quelqu'un essayait de passer la commande supplémentaire "supprimer", l'exécution serait une erreur car le SQL dynamique résultant ressemble à ceci:

select id, firstName, lastName
from (
    select id, firstName, LastName, row_number() over(order by id)a from Persons)t;delete from Persons;print' ) as rn
    from Persons
    ) t
where rn > (1-1) * 20
        and rn <= 1 * 20 
order by [id)a from Persons)t;delete from Persons;print'] 

entraînant cette erreur:

Msg 102, niveau 15, état 1, ligne 27
Syntaxe incorrecte près de ']'.

En outre, Aaron Bertrand a un excellent blog sur Bad Habits to Kick: Utilisation d'EXEC () au lieu de sp_executesql

3
Scott Hodgin

Une solution évidente est de ne pas utiliser de SQL dynamique. Je pense que votre tâche peut être accomplie avec du code T-SQL régulier et non dynamique, ce qui vous donne également d'autres avantages en termes de sécurité (comme le chaînage de la propriété).

Donc au lieu de:

declare @sql varchar(max)
set @sql = 'select id, firstName, lastName
from (
    select id, firstName, LastName, row_number() over(order by '+@orderByName+') as rn
    from Persons
    ) t
where rn > ('+cast(@pageNumber as varchar)+'-1) * '+cast(@pageSize as varchar)+'
        and rn <= '+cast(@pageNumber as varchar)+' * '+cast(@pageSize as varchar)+' 
order by '+@orderByName

exec(@sql)

Vous pourriez, par exemple ..

SELECT id, firstName, lastName
FROM (
    SELECT id, firstName, lastName, ROW_NUMBER() OVER (
        ORDER BY (CASE @OrderByName
                  WHEN 1 THEN id END),
                 --- different datatypes, I'm assuming
                 (CASE
                  WHEN 2 THEN firstName
                  WHEN 3 THEN lastName END)) AS rn
    FROM persons
    ) AS t
WHERE rn > (@pageNumber-1) * @pageSize
  AND rn <= @pageNumber * @pageSize
ORDER BY rn;

Lire sur,

2
Daniel Hutmacher

La réponse de @Scott Hodgin touche à cela, mais fondamentalement, la meilleure approche lors de la génération de chaînes SQL dynamiques face au client/à l'application consiste à utiliser sp_executesql .

Bien qu'il ne soit pas entièrement infaillible pour éliminer les attaques par injection SQL, sp_executesql est probablement le meilleur que vous obtiendrez. Le article liens vers Scott par Aaron Bertrand est assez simple, mais pour résumer rapidement les avantages de sp_executesql par rapport à d'autres approches, c'est qu'il:

  1. Utilise des variables fortement typées dans la chaîne
  2. A de meilleures chances de réutilisation du plan de requête

Le premier point est ce qui me semble le plus important par rapport à votre question car vous pouvez limiter la longueur, le type, etc. de vos paramètres, ce qui rend incroyablement plus difficile l'injection de code désagréable.

Quant à fournir une réponse encore plus complète, j'ai mis à jour votre sp en conséquence. Assez intéressant, dans votre cas, car vous essayez de paramétrer des littéraux de colonne, vous devrez imbriquer les instructions sp_executesql, de sorte que la première instruction imbriquée définit les noms de colonne en tant que littéraux et la deuxième exécution passe dans les valeurs de pagination, comme suit:

create procedure GetPersons @pageNumber int = 1, @pageSize int = 20, @orderBy varchar(50) = 'id', @orderDir varchar(4) = 'desc'
as

if @orderDir not in ('asc', 'desc') or @orderBy not in ('id', 'firstName', 'lastName')
begin
    raiserror('Cheater!', 16,1)
    return
end

declare @sql nvarchar(max), @sql_out nvarchar(max)
set @sql = 'SELECT @sql_out = ''select id, firstName, lastName
from (
    select id, firstName, LastName, row_number() over(order by '' + @oB + '' '' + @oD + '') as rn
    from Persons
    ) t
where rn > (@pN -1) * @pS
        and rn <= @pN * @pS
order by '' + @oB + '' '' + @oD + '''''

--PRINT(@sql)
EXEC sp_executesql @sql, N'@oB varchar(50), @oD varchar(4), @sql_out nvarchar(max) OUTPUT', @oB=@orderBy, @oD=@orderDir, @sql_out=@sql_out OUTPUT
EXEC sp_executesql @sql_out, N'@pN int, @pS int', @pN=@pageNumber, @pS=@pageSize
0
John Eisbrener

Option simple - joignez-vous à sys.columns Pour vous assurer qu'il s'agit d'un nom de colonne valide et utilisez CASE par défaut sur ASC si autre chose que DESC est passé .

(oh, et utilisez nvarchar(max) pour @sql et sp_executesql)

declare @sql nvarchar(max)
select @sql = 'select id, firstName, lastName
  from (
    select id, firstName, LastName, row_number() over(order by '+QUOTENAME(c.name)+' '+ CASE WHEN @orderDir = 'DESC' THEN 'DESC' ELSE 'ASC' END +') as rn
    from Persons
    ) t
  where rn > ('+cast(@pageNumber as varchar)+'-1) * '+cast(@pageSize as varchar)+'
    and rn <= '+cast(@pageNumber as varchar)+' * '+cast(@pageSize as varchar)+' 
order by '+QUOTENAME(c.name)+' '+ CASE WHEN @orderDir = 'DESC' THEN 'DESC' ELSE 'ASC' END
FROM sys.columns c
WHERE c.name = @orderby
AND c.object_id = OBJECT_ID('dbo.Persons');

EXEC sp_executesql @sql;
0
Rob Farley