LiteOrm 从架构设计层面内置了多层 SQL 注入防护机制。本文档全面介绍框架的安全策略、各个组件的原理、潜在风险点以及最佳实践。
LiteOrm 的 SQL 注入防护采用多层纵深防御策略:
| 层次 | 机制 | 说明 |
|---|---|---|
| 参数化 SQL | outputParams + 占位符 |
所有用户值以参数形式传递,零例外 |
| LIKE 转义 + 参数化 | 通配符转义 + ESCAPE 子句 |
双重保护防止 LIKE 注入 |
| ExprString 自动参数化 | 非 Expr 值自动转为命名参数 | 插值字符串中的用户值自动参数化 |
| 表达式类型白名单 | ExprTypeValidator |
控制允许的表达式类型 |
| 函数策略控制 | FunctionExprValidator |
控制可执行的 SQL 函数范围 |
| 自定义 SQL 预注册 | GenericSqlExpr |
禁止动态创建未注册的 SQL 片段 |
LiteOrm 所有 SQL 值传递都通过 outputParams 集合完成:
public static string ToSql(this Expr expr, SqlBuildContext context, ISqlBuilder sqlBuilder,
ICollection<KeyValuePair<string, object>> outputParams)
生成的 SQL 中使用参数占位符(如 @0、@1),值通过 outputParams 独立传递,从不将用户输入直接拼接到 SQL 字符串中。
示例:
var users = await userService.SearchAsync(u => u.UserName == "John");
// 生成 SQL: SELECT * FROM Users WHERE UserName = @0
// 参数: @0 = "John"
| 值类型 | 处理方式 | 安全分析 |
|---|---|---|
null |
输出 NULL 字面量 |
安全,不可注入 |
bool |
输出 1 / 0 |
安全 |
| 基本数值(int/long/float 等) | 直接内联 .ToString() |
安全,数值不可能包含 SQL 特殊字符 |
| 集合(IN 子句) | 每个元素单独参数化 | 安全 |
| 字符串、DateTime 等 | 参数化 | 安全 |
// 数值直接内联(仅适用于 IsConst + 基本数值类型)
var users = await userService.SearchAsync(u => u.Age >= 18);
// SQL: SELECT * FROM Users WHERE Age >= 18
// 字符串始终参数化
var users = await userService.SearchAsync(u => u.UserName == "O'Brien");
// SQL: SELECT * FROM Users WHERE UserName = @0
// 参数: @0 = "O'Brien" (单引号安全处理)
LIKE 查询同时使用参数化和通配符转义:
var users = await userService.SearchAsync(u => u.UserName.Contains("100%"));
// SQL: SELECT * FROM Users WHERE UserName LIKE @0 ESCAPE '/'
// 参数: @0 = "%100/%%" (% 被转义为 /%)
转义规则:_、%、[、] 等通配符使用 / 前缀转义(通过 ESCAPE '/' 声明):
用户输入: "100%_test"
转义后参数值: "100/%/_test" → LIKE @0 ESCAPE '/'
Column = NULL 自动转换为 IS NULL,避免标准 SQL 中 = NULL 的语义错误:
var results = await viewService.SearchAsync(u => u.UserName == null);
// SQL: SELECT * FROM Users WHERE UserName IS NULL
| 数据库 | 占位符格式 | 示例 |
|---|---|---|
| SQL Server / MySQL / PostgreSQL / SQLite | @n |
@0, @1 |
| Oracle | :n |
:0, :1 |
参数占位符的生成由 ISqlBuilder 接口抽象,数据库方言各自实现 ToSqlParam(name) 和 ToParamName(name)。
ExprString 是一个标记了 [InterpolatedStringHandler] 的 ref struct,借助 C# 编译器支持,在编译时解析插值字符串并自动区分 Expr 和普通值:
using static LiteOrm.Common.Expr;
dao.Search($"WHERE {Prop("Age")} > {minAge}");
// Prop("Age") → 走 ToSql(),完整参数化
// minAge (int) → 自动生成参数占位符 @0,值加入 outputParams
插值字符串 $"..."
│
├─ 格式化项是 Expr 对象 → expr.ToSql() → 完整表达式树处理
│
├─ 格式化项是普通值 → 自动生成 @N 占位符 + 加入参数列表
│
└─ 字面量字符串 → 直接追加(开发者硬编码的 SQL 关键词/结构)
代码示例:
using static LiteOrm.Common.Expr;
string keyword = "John";
int minAge = 18;
// ExprString 自动处理:
// - "WHERE " 是字面量,直接追加
// - {keyword} 是普通值,自动参数化为 @0
// - {Prop("Age")} 是 Expr,走 ToSql 参数化路径
// - " >= " 是字面量
// - {minAge} 是普通值,自动参数化为 @1
dao.Search($"WHERE {Prop("UserName")} LIKE {'%' + keyword + '%'} AND {Prop("Age")} >= {minAge}");
AppendLiteral)是开发者硬编码的 SQL 关键词和结构,不可由用户控制LiteOrm 采用访问者模式 + 策略模式实现表达式验证:
ExprValidator (抽象基类)
├── ExprTypeValidator ── 基于表达式类型白名单
├── FunctionExprValidator ── 基于函数策略
└── ExprValidatorGroup ── 多验证器组合
通过 ExprType 白名单控制允许的表达式类型:
// Minimum:允许基本查询条件(12 种类型)
// Value, Property, Unary, ValueSet, LogicBinary, And, Or, Not,
// Where, OrderBy, OrderByItem, Section 等
// 禁止:SelectItem, From, Table, Function, Update, Delete
// QueryOnly:允许完整 SELECT 查询(20 种类型)
// 包含 Minimum 的所有类型 + SelectItem, From, GroupBy, TableJoin 等
// 明确禁止:Update, Delete
var validator = ExprValidator.CreateQueryOnly();
if (validator.VisitAll(expr))
{
var results = await userService.SearchAsync(expr);
}
else
{
// validator.FailedExpr 包含被拒绝的节点
throw new UnauthorizedAccessException("Query contains disallowed expressions");
}
控制 FunctionExpr 的执行范围:
| 策略 | 值 | 说明 | 适用场景 |
|---|---|---|---|
AllowAll |
0 | 允许所有函数 | 本地开发 / 内部工具 |
AllowRegisted |
1 | 仅允许已注册的函数 | 生产环境推荐 |
Disallow |
2 | 禁止所有函数 | 完全受限环境 |
// 生产环境推荐:只允许已注册的函数
var validator = ExprValidatorGroup.Create(
ExprValidator.CreateQueryOnly(),
FunctionExprValidator.AllowRegisted
);
// 在 Search 前验证
if (!validator.VisitAll(expr))
{
throw new UnauthorizedAccessException(
$"Blacklisted expression found: {validator.FailedExpr}"
);
}
AllowRegisted 通过检查函数是否在 SqlBuilder 中注册过来判断是否允许:
using static LiteOrm.Common.Expr;
case FunctionPolicy.AllowRegisted:
return SqlBuilder.Instance.TryGetFunctionSqlHandler<SqlBuilder>(
funcExpr.FunctionName, out _);
var validator = ExprValidatorGroup.Create(
ExprValidator.CreateQueryOnly(), // 只允许查询类型
FunctionExprValidator.AllowRegisted // 只允许已注册的函数
);
if (!validator.VisitAll(expr))
{
// validator.FailedExpr — 失败节点
// validator.FailedVisitor — 失败的验证器
}
验证器组采用短路求值:任一验证器失败即停止,并记录失败的验证器。
GenericSqlExpr 提供了一种安全的机制来嵌入自定义 SQL 片段,通过预注册 + 回调委托的方式控制 SQL 生成:
public delegate string SqlGenerateHandler(
SqlBuildContext context, ISqlBuilder sqlBuilder,
ICollection<KeyValuePair<string, object>> outputParams, object arg);
public sealed class GenericSqlExpr : LogicExpr
{
public string Key { get; set; } // 注册表中查找的唯一键
public object Arg { get; set; } // 传递给回调的额外参数
}
using static LiteOrm.Common.Expr;
// 注册自定义 SQL 生成器
GenericSqlExpr.Register("CustomCheck", (context, sqlBuilder, outputParams, arg) =>
{
// 参数化:使用 outputParams 传递用户值
string paramName = outputParams.Count.ToString();
outputParams.Add(new(sqlBuilder.ToParamName(paramName), arg));
return $"dbo.CustomCheck({sqlBuilder.ToSqlParam(paramName)})";
});
// 在查询中使用
var expr = Prop("IsActive") == true
& new GenericSqlExpr("CustomCheck") { Arg = "someValue" };
var users = await userService.SearchAsync(expr);
ConcurrentDictionary 维护全局注册表,未注册的 key 会抛出异常outputParams,可以安全地传递用户值Arg 属性传递业务参数,不拼接到 SQL 中如果你是想把它用于“当前用户范围过滤”或“多租户过滤”等业务场景,请再结合权限过滤一并阅读,那里更强调什么时候该用运行时 Expr / GenericSqlExpr,什么时候该用 ConstFilter 或表路由。
ExprString 是一个标记了 [InterpolatedStringHandler] 的 ref struct,只有在调用接受 ExprString 类型参数的方法时(如 dao.Search(...)、SqlGen.ToSql(...) 等),编译器才会生成 ExprString 实例。普通的插值字符串生成的是普通 string,不会自动参数化:
using static LiteOrm.Common.Expr;
string userInput = request.Query["name"];
// ❌ 错误:普通插值字符串生成普通 string,不会变为 ExprString,无自动参数化
var badSql1 = $"SELECT * FROM Users WHERE Name = '{userInput}'"; // 危险:值直接拼入字面量
var badSql2 = $"SELECT * FROM Users WHERE Name = {userInput}"; // 错误:仍是普通 string,未参数化
// ✅ 正确:在 DAO 方法中使用 ExprString(方法接受 ExprString 类型参数)
var result = await dao.Search($"WHERE {Prop("Name")} == {userInput}").ToListAsync();
// 生成的 SQL: WHERE Name = @0
// 参数: @0 = userInput 的值
// ✅ 正确:使用 Expr 表达式构建查询
var expr = Prop("Name") == userInput;
var users = await userService.SearchAsync(expr);
在 ExprString 内部,字面量字符串(AppendLiteral)是开发者硬编码的 SQL 关键词和结构,不可由用户控制;格式化项中的普通值(非 Expr)会自动参数化。
SqlGenerateHandler 委托可以返回任意字符串。如果回调中不谨慎使用 outputParams,可能在自定义 SQL 中引入注入点:
using static LiteOrm.Common.Expr;
// ❌ 危险:直接在委托中拼接用户输入
GenericSqlExpr.Register("UnsafeLookup", (ctx, sb, params, arg) =>
{
return $"SELECT * FROM Users WHERE Code = '{arg}'";
});
// ✅ 安全:使用 outputParams 参数化
GenericSqlExpr.Register("SafeLookup", (ctx, sb, params, arg) =>
{
string paramName = params.Count.ToString();
params.Add(new(sb.ToParamName(paramName), arg));
return $"SELECT * FROM Users WHERE Code = {sb.ToSqlParam(paramName)}";
});
Expr.Prop 内部已对属性名和表别名做了合法名称校验(如拒绝 @、-、空格等特殊字符),传入非法名称会直接抛出 ArgumentException,一般情况下无需额外校验:
using static LiteOrm.Common.Expr;
// ✅ 安全:Prop 内部已做合法名称校验
var propName = request.Query["field"]; // 用户可控
// new PropertyExpr("Name@123") → 抛出 ArgumentException
// new PropertyExpr("Name-Column") → 抛出 ArgumentException
var expr = Prop(propName) == "value";
如果需要限制允许的字段范围(如只允许查询特定列),可使用白名单做业务层面的限制:
using static LiteOrm.Common.Expr;
// ✅ 推荐:当需要限制允许的字段范围时,使用白名单
var allowedFields = new HashSet<string> { "UserName", "Age", "Email" };
if (!allowedFields.Contains(propName))
throw new ArgumentException("Invalid field");
var expr = Prop(propName) == "value";
当允许前端通过 JSON 构造 Expr 时(前端原生 Expr 查询),务必配合验证器使用:
var expr = ExprJsonConvert.Deserialize(json);
var validator = ExprValidatorGroup.Create(
ExprValidator.CreateQueryOnly(),
FunctionExprValidator.AllowRegisted
);
if (!validator.VisitAll(expr))
{
throw new UnauthorizedAccessException("Query rejected by security validator");
}
// 建议额外限制:只允许特定表和列
var propValidator = new PropertyNameValidator(new[] { "UserName", "Age", "CreateTime" });
if (!propValidator.VisitAll(expr))
{
throw new UnauthorizedAccessException("Field access denied");
}
安全过滤应与权限过滤配合使用:
// 在进入 Search 之前,先拼上用户范围条件
LogicExpr permissionFilter = GetCurrentUserPermissionExpr();
LogicExpr finalExpr = expr & permissionFilter;
// 再通过安全验证器
if (!securityValidator.VisitAll(finalExpr))
throw new UnauthorizedAccessException();
var results = await userService.SearchAsync(finalExpr);
Expr 表达式体系虽然可以从架构层面杜绝 SQL 注入,但其功能非常强大灵活,使用时需注意:
ExprValidator 是可选的,如果不配置验证器,Expr 可以生成任意 SQL 结构(包括 UPDATE、DELETE 等)ExprValidator.CreateQueryOnly() + FunctionExprValidator.AllowRegisted 限制表达式能力范围ExprValidator + PropertyNameValidator 双重验证在生产环境中使用 LiteOrm 时,建议逐一确认以下事项:
| 检查项 | 说明 |
|---|---|
✅ 启用 AllowRegisted 函数策略 |
防止执行未注册的 SQL 函数 |
| ✅ 前端 Expr 查询前使用验证器 | 限制表达式类型和字段访问范围 |
✅ 自定义 SQL 使用 outputParams |
GenericSqlExpr 回调中使用参数化 |
| ✅ Expr.Prop 已内置名称校验 | 非法名称会直接抛异常;如需限制字段范围,额外使用白名单 |
| ✅ 通过 DAO 方法使用 ExprString | 普通插值字符串不生成 ExprString,需通过 dao.Search(...) 等方法触发 |
| ✅ 配合权限过滤使用 | 在验证器之外叠加用户范围过滤 |
| ✅ LIKE 查询不接受裸通配符 | 对于前端传入的 LIKE 值,考虑是否需要转义/禁止通配符 |
| ✅ 认识 Expr 的灵活性 | Expr 功能强大,生产环境务必配置验证器限制能力范围 |