在使用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注入的风险就可以降到几乎为零。在日常开发中养成这个习惯,比依赖任何安全工具都更可靠。