在使用Dapper进行数据库操作时,防止SQL注入最直接有效的方式就是利用Dapper的动态参数(DynamicParameters)配合匿名对象来传递查询条件。核心原理很简单:Dapper会自动将匿名对象的属性映射为参数化查询的参数,而不是将值直接拼接到SQL字符串中。这样一来,无论用户输入什么内容,数据库驱动都会把它当作纯数据处理,而不是可执行的SQL代码,从根本上杜绝了SQL注入的风险。下面我会从原理、用法、实战案例到进阶技巧,把这套方法讲透。
为什么匿名对象配合Dapper能防SQL注入
传统的字符串拼接方式写SQL是这样的:
var sql = "SELECT * FROM Users WHERE Name = '" + userName + "'";
如果userName的值是 ' OR '1'='1,那最终执行的SQL就变成了一条永远为真的查询,数据全部泄露。而Dapper的参数化查询机制是这样工作的:
var sql = "SELECT * FROM Users WHERE Name = @Name";
var result = connection.Query<User>(sql, new { Name = userName });Dapper会把@Name作为参数占位符,然后通过ADO.NET的SqlParameter对象把实际值安全地传递给数据库引擎。数据库引擎在编译执行计划时,会把参数值和SQL结构完全分离,攻击者的恶意输入永远只会被当作字符串字面量,不可能改变SQL的语法结构。这就是参数化查询防注入的底层逻辑。
Dapper动态参数DynamicParameters的核心用法
当查询条件不固定、需要动态构建WHERE子句时,就需要用到DynamicParameters。它本质上是一个字典结构的参数集合,可以在运行时灵活添加参数。最常见的场景是:根据前端传来的筛选条件,动态拼装查询语句。
var parameters = new DynamicParameters();
parameters.Add("@Name", userName);
parameters.Add("@Age", age);
parameters.Add("@City", city);
var sql = @"SELECT * FROM Users
WHERE (@Name IS NULL OR Name = @Name)
AND (@Age IS NULL OR Age = @Age)
AND (@City IS NULL OR City = @City)";
var result = connection.Query<User>(sql, parameters);上面这段代码的关键点在于:所有用户输入都通过parameters.Add()方法以参数形式注入,而不是字符串拼接。即使某个字段为null,SQL逻辑也能正确处理,不会出现语法错误。
匿名对象与DynamicParameters混合使用的最佳实践
在实际项目中,我们经常需要把一部分参数用匿名对象快速传递,另一部分用DynamicParameters动态添加。Dapper完全支持这种混合模式。你可以先用匿名对象初始化一部分固定参数,再用DynamicParameters追加动态参数。
var baseParams = new { Status = 1, IsDeleted = false };
var dynamicParams = new DynamicParameters(baseParams);
if (!string.IsNullOrEmpty(keyword))
{
dynamicParams.Add("@Keyword", keyword);
sqlBuilder.Append(" AND (Name LIKE @Keyword OR Email LIKE @Keyword)");
}
if (startDate.HasValue)
{
dynamicParams.Add("@StartDate", startDate.Value);
sqlBuilder.Append(" AND CreatedAt >= @StartDate");
}
var result = connection.Query<User>(sqlBuilder.ToString(), dynamicParams);这里有一个细节值得注意:DynamicParameters的构造函数可以直接接收一个匿名对象,它会自动把匿名对象的所有属性提取出来作为初始参数。这样写代码既简洁又安全,一行初始化,后续按需追加。
用匿名对象处理IN查询的防注入写法
IN查询是SQL注入的高发区,很多开发者习惯把ID列表拼成字符串塞进SQL里,这是非常危险的。用Dapper的匿名对象配合动态参数,可以安全地处理IN查询。
var ids = new List<int> { 1, 2, 3, 4, 5 };
var parameters = new DynamicParameters();
parameters.Add("@Ids", ids);
var sql = "SELECT * FROM Orders WHERE OrderId IN @Ids";
var result = connection.Query<Order>(sql, parameters);Dapper会自动识别集合类型的参数,将其展开为IN (@Ids1, @Ids2, @Ids3...)的形式。整个过程完全参数化,不存在任何拼接,安全性有保障。需要注意的是,不同数据库对IN查询的参数化支持略有差异,SQL Server和PostgreSQL支持良好,MySQL需要确认驱动版本。
动态构建WHERE条件时的防注入模板
下面给出一个完整的、可直接用于生产环境的动态查询模板,涵盖了常见的筛选场景:
public List<User> SearchUsers(string name, int? age, string city, DateTime? startDate, DateTime? endDate)
{
var sqlBuilder = new StringBuilder();
sqlBuilder.Append("SELECT * FROM Users WHERE 1=1");
var parameters = new DynamicParameters();
if (!string.IsNullOrWhiteSpace(name))
{
sqlBuilder.Append(" AND Name LIKE @Name");
parameters.Add("@Name", $"%{name}%");
}
if (age.HasValue)
{
sqlBuilder.Append(" AND Age = @Age");
parameters.Add("@Age", age.Value);
}
if (!string.IsNullOrWhiteSpace(city))
{
sqlBuilder.Append(" AND City = @City");
parameters.Add("@City", city);
}
if (startDate.HasValue)
{
sqlBuilder.Append(" AND CreatedAt >= @StartDate");
parameters.Add("@StartDate", startDate.Value);
}
if (endDate.HasValue)
{
sqlBuilder.Append(" AND CreatedAt <= @EndDate");
parameters.Add("@EndDate", endDate.Value);
}
sqlBuilder.Append(" ORDER BY CreatedAt DESC");
using var connection = new SqlConnection(_connectionString);
return connection.Query<User>(sqlBuilder.ToString(), parameters).ToList();
}这段代码的防注入要点有三个:第一,所有用户输入都通过parameters.Add()传入,绝不拼接;第二,使用StringBuilder构建SQL结构,结构部分是开发者自己写的固定字符串,不含用户输入;第三,LIKE查询中的通配符也是在代码层面拼接好再作为参数值传入,而不是在SQL里拼接。
匿名对象在存储过程调用中的防注入应用
Dapper调用存储过程时同样支持匿名对象传参,而且防注入效果一样可靠:
var result = connection.Query<User>(
"sp_GetUsersByCondition",
new {
Name = userName,
Status = status,
PageIndex = pageIndex,
PageSize = pageSize
},
commandType: CommandType.StoredProcedure
);存储过程内部的参数定义是预编译的,Dapper传入的匿名对象属性会自动映射到存储过程的参数上。即使存储过程内部有动态SQL,只要存储过程本身使用了参数化写法,整个调用链就是安全的。但如果存储过程内部用了字符串拼接来构造动态SQL,那防注入的责任就转移到了存储过程的编写者身上,Dapper层面无法保证。
容易踩的坑和注意事项
虽然Dapper的参数化机制本身很安全,但在实际使用中仍然有几个容易忽略的问题:
第一,不要在动态构建SQL时把表名、列名、排序方向等结构性内容用参数代替。参数化查询只能保护数据值,不能保护SQL结构。如果你需要动态指定列名或表名,必须用白名单验证:
var allowedColumns = new HashSet<string> { "Name", "Age", "City", "CreatedAt" };
if (!allowedColumns.Contains(sortColumn))
throw new ArgumentException("Invalid sort column");
sqlBuilder.Append($" ORDER BY {sortColumn} DESC");第二,注意参数类型匹配。如果传入的值类型和数据库字段类型不一致,可能导致隐式转换带来的性能问题甚至类型错误。建议在Add参数时显式指定DbType:
parameters.Add("@Age", age.Value, DbType.Int32);
parameters.Add("@CreatedAt", startDate.Value, DbType.DateTime2);第三,批量操作时不要在循环中反复创建连接。Dapper支持批量参数化操作,利用事务可以大幅提升性能同时保持安全性:
using var connection = new SqlConnection(_connectionString);
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
var sql = "INSERT INTO Users (Name, Age, City) VALUES (@Name, @Age, @City)";
var users = GetUserList();
connection.Execute(sql, users, transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}第四,对于超大数据量的IN查询,要注意参数数量限制。SQL Server默认最多支持2100个参数,如果ID列表过长,需要分批处理或者改用临时表方案。
与其他ORM防注入机制的对比
相比Entity Framework Core的LINQ查询,Dapper的防注入方式更加底层和透明。EF Core的LINQ本质上也是参数化查询,但它在内部做了更多抽象,开发者不容易看到实际生成的SQL。Dapper则要求开发者自己写SQL,参数化的过程完全可见可控。对于需要精细调优SQL的场景,Dapper配合DynamicParameters和匿名对象的方案,既保持了安全性,又不牺牲灵活性和性能。
对比原生ADO.NET的SqlParameter写法,Dapper的匿名对象方式代码量减少了60%以上,可读性大幅提升,同时安全性完全等价。可以说Dapper在安全性和开发效率之间找到了一个很好的平衡点。
总结:防SQL注入的核心原则
不管用什么技术栈,防SQL注入的核心原则只有一条:永远不要把用户输入直接拼接到SQL字符串中。Dapper通过DynamicParameters和匿名对象的组合,把这条原则落实到了最简洁的代码形态。开发者只需要记住三点:用参数化查询代替字符串拼接、用白名单验证结构性内容、用显式类型避免隐式转换。做到这三点,SQL注入的风险就可以降到几乎为零。在日常开发中养成这个习惯,比依赖任何安全工具都更可靠。
