动态Pivot(2)

时间:2021-04-08 21:03:31

原文  http://book.51cto.com/art/200710/58875.htm

存储过程sp_pivot的实现包含糟糕的编程习惯和安全隐患。就像我在本章的前面提到的,微软强烈建议不要在用户定义存储过程的名称中使用sp_前缀。一方面,把存储过程创建为特殊存储过程的会带来灵活性;但另一方面,你所依赖的行为得不到任何支持。所以最好放弃这种通过创建以sp_为前缀的存储过程获取的灵活性,在用户数据库中使用其他前缀创建用户定义存储过程。
代码定义的所有输入参数都未限制大小(使用MAX说明符),而且未作任何输入验证。因为存储过程调用的动态执行基于用户输入的字符串,限制输入的大小并检查潜在的SQL注入危险是非常重要的。对于现有的实现,黑客可以很容易地注入代码并破坏你的系统。你可以在第4章和联机丛书(http://msdn2.microsoft.com/en-us/library/ms161953 (SQL.90).aspx)中找到关于SQL注入的讨论。作为一个利用用户输入注入恶意代码的示例,观察下面这个对存储过程的调用。

EXEC Northwind.dbo.sp_pivot
@query    = N'dbo.Orders',
@on_rows  = N'1 AS dummy_col) DummyTable;
PRINT ''So easy to inject code here!
This could have been a DROP TABLE or xp_cmdshell command!'';
SELECT * FROM (select EmployeeID AS empid',
@on_cols  = N'MONTH(OrderDate)',
@agg_func = N'COUNT',
@agg_col  = N'*';

存储过程生成的查询字符串应该是这样的:

SELECT *
FROM
( SELECT
1 AS dummy_col) DummyTable;
PRINT 'So easy to inject code here!
This could have been a DROP TABLE or xp_cmdshell command!';
SELECT * FROM (select EmployeeID AS empid,
MONTH(OrderDate) AS pivot_col,
1 AS agg_col
FROM
( SELECT * FROM dbo.Orders
) AS Query
) AS PivotInput
PIVOT
( COUNT(agg_col)
FOR pivot_col
IN([1],[2],[3],[4],[5],[6],[7],[8],[9],[10],[11],[12])
) AS PivotOutput;

当执行这些代码时,注入的PRINT语句可以顺利执行。为了证明可以轻易地注入代码,我使用了一个无害的PRINT语句,但是很明显,这些恶意代码可以是任何有效的T-SQL代码。例如DROP TABLE语句、调用xp_cmdshell等。总之,在这些地方采取措施防范SQL注入是极其重要的。
该存储过程不仅未防范SQL注入,而且根本就没有执行任何输入验证。例如,应该验证输入的对象和列名称的有效性。该存储过程也没有包含错误处理。我会在第10章讨论错误处理,所以没有在修改后的解决方案中演示这一点。下面我将演示输入验证。

在呈现修改后的解决方案之前,先删除已经存在的sp_pivot:

USE master;
GO
IF OBJECT_ID('dbo.sp_pivot') IS NOT NULL
DROP PROC dbo.sp_pivot;

代码清单7-9是该任务的修改后的解决方案
代码清单7-9  创建usp_pivot存储过程的脚本

USE Northwind;
GO

IF OBJECT_ID('dbo.usp_pivot') IS NOT NULL
DROP PROC dbo.usp_pivot;
GO

CREATE PROC dbo.usp_pivot
@schema_name AS sysname      = N'dbo',
-- 表/视图的架构
@object_name AS sysname      = NULL, 
-- 表/视图的名称
@on_rows     AS sysname      = NULL, -- 分组列
@on_cols     AS sysname      = NULL, -- 旋转列
@agg_func    AS NVARCHAR(12) = N'MAX',
-- 聚集函数
@agg_col     AS sysname      = NULL -- 统计列
AS

DECLARE
@object  AS NVARCHAR(600),
@sql     AS NVARCHAR(MAX),
@cols    AS NVARCHAR(MAX),
@newline AS NVARCHAR(2),
@msg     AS NVARCHAR(500);

SET @newline = NCHAR(13) + NCHAR(10);
SET @object  = QUOTENAME(@schema_name) + N'.' + QUOTENAME(@object_name);

-- 检查是否缺少输入
IF   @schema_name IS NULL
OR @object_name IS NULL
OR @on_rows     IS NULL
OR @on_cols     IS NULL
OR @agg_func    IS NULL
OR @agg_col     IS NULL
BEGIN
SET @msg = N'Missing input parameters: '
+ CASE WHEN @schema_name IS NULL 
THEN N'@schema_name;' ELSE N'' END
+ CASE WHEN @object_name IS NULL 
THEN N'@object_name;' ELSE N'' END
+ CASE WHEN @on_rows     IS NULL 
THEN N'@on_rows;'     ELSE N'' END
+ CASE WHEN @on_cols     IS NULL 
THEN N'@on_cols;'     ELSE N'' END
+ CASE WHEN @agg_func    IS NULL 
THEN N'@agg_func;'    ELSE N'' END
+ CASE WHEN @agg_col     IS NULL 
THEN N'@agg_col;'     ELSE N'' END
RAISERROR(@msg, 16, 1);
RETURN;
END 
-- 只允许已存在的表或视图作为输入对象
IF COALESCE(OBJECT_ID(@object, N'U'),
OBJECT_ID(@object, N'V')) IS NULL
BEGIN
SET @msg = N'%s is not an existing table or view in the database.';
RAISERROR(@msg, 16, 1, @object);
RETURN;
END

-- 检查 @on_rows, @on_cols, @agg_col 中的列名称是否存在

IF   COLUMNPROPERTY(OBJECT_ID(@object), @on_rows, 'ColumnId') IS NULL
OR COLUMNPROPERTY(OBJECT_ID(@object), @on_cols, 'ColumnId') IS NULL
OR COLUMNPROPERTY(OBJECT_ID(@object), @agg_col, 'ColumnId') IS NULL
BEGIN
SET @msg = N'%s, %s and %s must'
+ N' be existing column names in %s.';
RAISERROR(@msg, 16, 1, @on_rows, @on_cols, @agg_col, @object);
RETURN;
END

-- 检查@agg_func是否是已知的函数
-- 根据需要增加该清单并相应调整@agg_func的大小 
IF @agg_func NOT IN
(N'AVG', N'COUNT', N'COUNT_BIG', N'SUM', N'MIN', N'MAX',
N'STDEV', N'STDEVP', N'VAR', N'VARP')
BEGIN
SET @msg = N'%s is an unsupported aggregate function.';
RAISERROR(@msg, 16, 1, @agg_func);
RETURN;
END

-- 构造列列表
SET @sql =
N'SET @result = '                                    + @newline +
N'  STUFF('                                          + @newline +
N'    (SELECT N'','' + '
+ N'QUOTENAME(pivot_col) AS [text()]'       + @newline +
N'     FROM (SELECT DISTINCT('
+ QUOTENAME(@on_cols) + N') AS pivot_col'   + @newline +
N'           FROM ' + @object + N') AS DistinctCols' + @newline +
N'     ORDER BY pivot_col'                           + @newline +
N'     FOR XML PATH('''')),'                         + @newline +
N'    1, 1, N'''');'

EXEC sp_executesql
@stmt   = @sql,
@params = N'@result AS NVARCHAR(MAX) OUTPUT',
@result = @cols OUTPUT;

-- 检查 @cols 是否存在SQL 注入尝试
IF   UPPER(@cols) LIKE UPPER(N'%0x%')
OR UPPER(@cols) LIKE UPPER(N'%;%')
OR UPPER(@cols) LIKE UPPER(N'%''%')
OR UPPER(@cols) LIKE UPPER(N'%--%')
OR UPPER(@cols) LIKE UPPER(N'%/*%*/%') 
OR UPPER(@cols) LIKE UPPER(N'%EXEC%')
OR UPPER(@cols) LIKE UPPER(N'%xp[_]%')
OR UPPER(@cols) LIKE UPPER(N'%sp[_]%')
OR UPPER(@cols) LIKE UPPER(N'%SELECT%')
OR UPPER(@cols) LIKE UPPER(N'%INSERT%')
OR UPPER(@cols) LIKE UPPER(N'%UPDATE%')
OR UPPER(@cols) LIKE UPPER(N'%DELETE%')
OR UPPER(@cols) LIKE UPPER(N'%TRUNCATE%')
OR UPPER(@cols) LIKE UPPER(N'%CREATE%')
OR UPPER(@cols) LIKE UPPER(N'%ALTER%')
OR UPPER(@cols) LIKE UPPER(N'%DROP%')
-- 其他一些可能用于SQL注入的字符串
BEGIN
SET @msg = N'Possible SQL injection attempt.';
RAISERROR(@msg, 16, 1);
RETURN;
END

-- 创建PIVOT查询
SET @sql =
N'SELECT *'                                          + @newline +
N'FROM'                                              + @newline +
N'  ( SELECT '                                       + @newline +
N'      ' + QUOTENAME(@on_rows) + N','               + @newline +
N'      ' + QUOTENAME(@on_cols) + N' AS pivot_col,'  + @newline +
N'      ' + QUOTENAME(@agg_col) + N' AS agg_col'     + @newline +
N'    FROM ' + @object                               + @newline +
N'  ) AS PivotInput'                                 + @newline +
N'  PIVOT'                                           + @newline +
N'    ( ' + @agg_func + N'(agg_col)'                 + @newline +
N'      FOR pivot_col'                               + @newline +
N'        IN(' + @cols + N')'                        + @newline +
N'    ) AS PivotOutput;';

EXEC sp_executesql @sql;

该存储过程的实现遵循了良好的编程习惯并解决了前面提到的安全缺陷。但是要记住,当根据用户输入和存储数据/元数据构造代码时,要完全地防范SQL注入是非常困难的。

存储过程usp_pivot是在Northwind数据库中以usp_前缀创建的用户定义存储过程。这意味着它只能与Northwind中的表和视图进行交互,从这个意义来讲,它不如前面的实现那样灵活。但是你可以在Northwind中创建用于查询其他数据库对象的视图,并把该视图作为输入提供给这个存储过程。

usp_pivot存储过程的代码提供了几种防范SQL注入尝试的措施:

 限制输入参数的大小。
 存储过程只接收在数据库中存在的有效的表或视图名称,不接收其他形式的查询。同样,存储过程中的输入参数@on_rows、@on_cols和@agg_co只接收在输入表/视图中存在的有效的列名称,不能是任意的T-SQL表达式。你可以使用任意的查询创建视图,然后把它作为该存储过程的输入。
 代码在引用对象和列名称的地方使用了QUOTENAME,并用方括号作为分隔标识符。
 存储过程的代码检查@cols变量是否存在注入的代码字符串,它们可能通过存储被串联起来的旋转列值注入。
代码还对输入执行检查以确保提供了所有参数,这些表/视图和列名称存在,输入的聚集函数包含在支持的函数列表中。关于错误处理,我将在第10章再讨论。

usp_pivot存储过程看起来没有sp_pivot灵活,但你可以创建视图为usp_pivot提供数据。例如,考虑下面的代码,在前面曾用它返回按订单年份旋转的每个员工的订单金额合计(数量*单价):

EXEC Northwind.dbo.sp_pivot
@query    = N'
SELECT O.OrderID, EmployeeID, OrderDate, Quantity, UnitPrice
FROM dbo.Orders AS O
JOIN dbo.[Order Details] AS OD
ON OD.OrderID = O.OrderID',
@on_rows  = N'EmployeeID AS empid',
@on_cols  = N'YEAR(OrderDate)',
@agg_func = N'SUM',
@agg_col  = N'Quantity*UnitPrice';

通过创建一个包含所需数据的视图,你就可以利用usp_pivot实现同样的功能。

USE Northwind;
GO
IF OBJECT_ID('dbo.ViewForPivot') IS NOT NULL
DROP VIEW dbo.ViewForPivot;
GO

CREATE VIEW dbo.ViewForPivot
AS

SELECT
O.OrderID       AS orderid,
EmployeeID      AS empid,
YEAR(OrderDate) AS order_year,
Quantity * UnitPrice AS val
FROM dbo.Orders AS O
JOIN dbo.[Order Details] AS OD
ON OD.OrderID = O.OrderID;
GO

然后调用usp_pivot,就像这样:

EXEC dbo.usp_pivot
@object_name = N'ViewForPivot',
@on_rows  = N'empid',
@on_cols  = N'order_year',
@agg_func = N'SUM',
@agg_col  = N'val';

你将得到前面表7-13所示的输出。
相对于你的系统安全而言,这只是很小的代价。
完成后,运行下面的代码进行清理。

USE Northwind;
GO
IF OBJECT_ID('dbo.ViewForPivot') IS NOT NULL
DROP VIEW dbo.ViewForPivot;
GO
IF OBJECT_ID('dbo.usp_pivot') IS NOT NULL
DROP PROC dbo.usp_pivot;