在C#项目中使用Dapper进行数据库操作时,SQL注入是最常见也最危险的安全漏洞之一。很多开发者以为用了Dapper就自动安全了,但实际上Dapper本身只是一个ORM映射工具,它不会替你判断查询语句是否安全。如果你在Dapper中直接拼接SQL字符串,把用户输入的值塞进查询语句里,那就等于把数据库大门敞开给攻击者。正确的做法是始终使用Dapper的参数化查询机制,让数据库驱动层去处理参数转义和类型绑定,从根本上杜绝SQL注入风险。下面我会把这个问题从头到尾讲透,包括哪些写法是危险的、哪些是安全的、以及实际项目中容易踩的坑。
一、SQL注入到底是怎么发生的
SQL注入的本质就是攻击者通过构造特殊的输入数据,改变了你原本想要执行的SQL语句的逻辑结构。举个最简单的例子,假设你有一个登录验证的查询语句,原本是根据用户名和密码去数据库里找匹配的记录。如果你把用户输入直接拼进SQL字符串,攻击者输入用户名为 "admin' --",那么最终执行的SQL就变成了只验证用户名、注释掉密码验证的语句,攻击者不需要密码就能登录。这不是理论上的威胁,这是每天都在发生的真实攻击。
二、Dapper中危险的内联拼接写法
很多开发者在刚接触Dapper的时候,习惯用字符串拼接的方式构造查询,觉得这样写起来快、直观。但这种写法在Dapper里同样存在SQL注入风险。下面是几种典型的危险写法:
// 危险写法一:直接字符串拼接
string sql = "SELECT * FROM Users WHERE UserName = '" + userName + "' AND Password = '" + password + "'";
var result = connection.Query<User>(sql).FirstOrDefault();
// 危险写法二:用字符串插值
string sql = $"SELECT * FROM Users WHERE UserName = '{userName}'";
var result = connection.Query<User>(sql).FirstOrDefault();
// 危险写法三:用String.Format拼接
string sql = String.Format("SELECT * FROM Users WHERE Id = {0}", userId);
var result = connection.Query<User>(sql).FirstOrDefault();
上面这三种写法,不管你用的是加号拼接、字符串插值还是String.Format,本质都是一样的——用户输入被直接嵌入到SQL语句的文本中。数据库引擎在解析这条SQL的时候,根本分不清哪部分是你写的代码逻辑、哪部分是用户输入的数据,攻击者就可以通过精心构造的输入来篡改SQL的语义。
三、Dapper参数化查询的正确用法
Dapper提供了非常简洁的参数化查询方式,核心就是用占位符(@ParameterName或者数字索引)代替直接拼接的值,然后把实际的参数值通过匿名对象、DynamicParameters或者字典的方式传进去。数据库驱动会自动处理参数的转义和类型转换,攻击者的恶意输入只会被当作普通字符串数据处理,不会被解析成SQL语法的一部分。
// 安全写法一:使用匿名对象传参
string sql = "SELECT * FROM Users WHERE UserName = @UserName AND Password = @Password";
var result = connection.Query<User>(sql, new { UserName = userName, Password = password }).FirstOrDefault();
// 安全写法二:使用单个参数
string sql = "SELECT * FROM Users WHERE Id = @Id";
var result = connection.Query<User>(sql, new { Id = userId }).FirstOrDefault();
// 安全写法三:使用DynamicParameters处理复杂场景
var parameters = new DynamicParameters();
parameters.Add("@UserName", userName);
parameters.Add("@Email", email);
parameters.Add("@Role", role, DbType.String, ParameterDirection.Input);
string sql = "INSERT INTO Users (UserName, Email, Role) VALUES (@UserName, @Email, @Role)";
connection.Execute(sql, parameters);
你看,安全写法和危险写法之间的区别其实很小,就是把拼接的部分换成了参数占位符,然后把值单独传进去。但就是这么一个小改动,安全性天差地别。Dapper在底层会调用ADO.NET的参数化命令对象,真正的SQL语句和参数数据是分开传输到数据库服务器的,数据库引擎在编译SQL的时候参数还没有被填进去,所以根本不存在注入的可能。
四、动态SQL拼接中的特殊风险
实际项目中,很多查询条件是动态的,比如用户可以选择按姓名搜索、按年龄搜索、按地区搜索,你需要根据用户的选择动态拼接WHERE条件。这种场景下特别容易出错,因为开发者往往觉得"这个字段名是我自己定的,不是用户输入的,应该没问题",但实际上列名、表名、ORDER BY字段这些都不能通过参数化来处理,Dapper的参数机制只能处理值(Value),不能处理标识符(Identifier)。
// 错误示范:动态拼接列名或表名
string orderBy = userInput; // 用户输入 "Id; DROP TABLE Users--"
string sql = "SELECT * FROM Users ORDER BY " + orderBy;
var result = connection.Query<User>(sql);
// 正确做法:白名单验证动态部分
string[] allowedColumns = { "Id", "UserName", "CreateTime" };
string orderBy = allowedColumns.Contains(userInput) ? userInput : "Id";
string sql = "SELECT * FROM Users ORDER BY " + orderBy;
var result = connection.Query<User>(sql);
这里的核心原则是:凡是来自用户输入的部分,如果它是用来作为SQL标识符(表名、列名、排序字段)的,你必须做白名单校验,只允许预定义的合法值通过。而凡是用来作为数据值的部分,一律用参数化查询。这两条规则缺一不可。
五、IN子句和动态参数列表的处理
另一个常见的场景是IN查询,比如用户要查询ID在某个列表中的记录。很多人会想着把ID列表拼成一个字符串塞进去,这又回到了拼接的老路。Dapper对这种场景有很好的支持,你可以直接传一个数组或者IEnumerable进去,Dapper会自动展开成多个参数。
// 安全写法:传数组参数
int[] ids = { 1, 2, 3, 4, 5 };
string sql = "SELECT * FROM Users WHERE Id IN @Ids";
var result = connection.Query<User>(sql, new { Ids = ids });
// 或者用字符串列表
List<string> names = new List<string> { "Alice", "Bob", "Charlie" };
string sql = "SELECT * FROM Users WHERE UserName IN @Names";
var result = connection.Query<User>(sql, new { Names = names });
需要注意的是,Dapper在处理IN子句时,会把你传入的集合自动展开成 @Ids0, @Ids1, @Ids2... 这样的形式。这意味着即使集合很大,每个元素仍然是作为独立参数传递的,不存在拼接风险。但如果你的集合特别大(比如几千个元素),可能需要考虑分批查询或者用其他方式优化,不过这是性能问题,不是安全问题。
六、存储过程调用中的参数安全
有些项目会用存储过程来封装数据库逻辑,这种情况下通过Dapper调用存储过程同样需要注意参数传递方式。正确的做法是用CommandType.StoredProcedure并通过参数对象传值:
// 安全调用存储过程
string sql = "sp_GetUserByName";
var parameters = new DynamicParameters();
parameters.Add("@UserName", userName, DbType.String);
parameters.Add("@Result", dbType: DbType.Int32, direction: ParameterDirection.Output);
connection.Execute(sql, parameters, commandType: CommandType.StoredProcedure);
int result = parameters.Get<int>("@Result");
如果你在调用存储过程时把参数拼进字符串里,那和普通SQL拼接一样危险。存储过程本身并不能自动防止注入,关键还是看你怎么传参。
七、项目中容易被忽视的几个细节
第一,日志记录。很多开发者在调试时会把完整的SQL语句(包括参数)打印到日志里,如果日志系统被攻击者访问到,他就能看到你的查询结构和参数信息,虽然参数化本身是安全的,但信息泄露仍然是风险。建议生产环境关闭详细SQL日志,或者只记录脱敏后的信息。
第二,ORM映射工具的误用。Dapper虽然轻量,但有些团队会在Dapper之上再封装一层,如果封装层内部用了拼接而不是参数化,那问题就被隐藏了。代码审查时要特别注意封装层的实现。
第三,数据库权限控制。即使你的查询全部参数化了,如果数据库连接用的是高权限账户(比如sa或者db_owner),一旦出现其他漏洞(比如二次注入、权限提升),后果会更严重。遵循最小权限原则,给应用程序的数据库账户只授予必要的SELECT、INSERT、UPDATE、DELETE权限。
八、总结与最佳实践清单
防止Dapper中的SQL注入,核心就一句话:永远不要把用户输入直接拼进SQL字符串,永远用参数化。具体到操作层面,记住以下几点:所有数据值用@参数占位符加匿名对象传递;动态列名、表名用白名单校验;IN子句直接传集合;存储过程用CommandType.StoredProcedure加参数对象;定期做代码审查,重点检查拼接操作;数据库账户用最小权限。做到这些,你的Dapper项目在SQL注入这个维度上就是安全的。安全不是靠运气,是靠每一行代码的规范。
