问题与适用范围
一个多租户订单平台提供内部搜索接口。服务端可以从已认证会话中取得用户所属租户。请求可以 包含客户邮箱、订单状态列表、排序字段、排序方向和返回数量。代码审查发现了类似下面的实现:
const sql = `
SELECT id, customer_email, status, total_cents, created_at
FROM orders
WHERE tenant_id = '${tenantId}'
AND customer_email = '${input.email}'
AND status IN (${input.statuses.join(",")})
ORDER BY ${input.sort} ${input.direction}
LIMIT ${input.limit}
`请重新设计这个查询边界,使攻击者可控文本无法改变 SQL 语法结构。答案需要覆盖普通值、可选 筛选条件、列表参数、排序列等标识符、存储过程、ORM 的原生查询逃生口、数据库权限、日志, 以及已经存入数据库、随后又被动态 SQL 使用的数据。
核心规则是:SQL 代码只能由可信应用代码决定,外部数据必须通过服务端参数绑定接口传给 数据库。输入校验和最小权限可以增加防线,但不能把字符串拼接变成安全查询。
这是一道后端题,因为核心能力是查询构造、数据库驱动行为、服务端授权边界、数据库权限和 生产验证。使用哪种编程语言并不决定分类。
面试官在考察什么
第一个信号是候选人能否识别所有语法边界,而不只回答“使用预编译语句”。租户 ID、邮箱、 状态和返回数量属于数据,应该绑定为参数。表名、列名、SQL 关键字和排序方向通常不能放进普通 值占位符,所以需要重新设计查询,或者把很小的公开枚举映射到服务端固定 SQL 片段。
第二个信号是能否区分注入防护和授权。即使 tenant_id 已经参数化,如果它来自请求体,调用 方仍可能选择其他租户,租户隔离依然不安全。服务端应该从已认证主体推导租户,并把它加入每个 相关查询。参数化只能阻止值改变查询结构,不能证明调用方有权访问该值。
第三个信号是能否发现隐藏的执行边界。只有正确使用参数化 API 时,ORM 才能提供安全边界。 原生查询方法、字符串拼接筛选器、迁移工具、报表任务和执行动态 SQL 的存储过程都可能重新 引入同一缺陷。今天安全存储的数据,如果将来被另一个任务拼接进 SQL,也可能形成二次 SQL 注入。
最后一个信号是能否分层验证。高质量答案会结合代码审查、服务端参数绑定、标识符白名单、 最小权限数据库角色、安全错误处理、恶意字符串集成测试、租户隔离断言和数据库错误监控。 它不会把 Web 应用防火墙或手工转义当作主要修复方案。
回答前需要澄清的问题
- 线上使用哪种数据库和驱动? 占位符语法、数组绑定、标识符引用工具和预编译语句行为会
随实现变化。设计原则可以复用,具体 API 必须匹配当前驱动。
- 请求是否包含
tenantId? 即使包含,也不能用它决定授权范围。租户应来自已认证的服务端
上下文。管理员跨租户访问需要单独且明确授权的路径。
- 哪些筛选条件是可选的? 可选谓词可以从固定 SQL 片段中添加,但对应值仍要使用参数。
一个允许任意字段和任意操作符的通用 API 会显著扩大语法攻击面。
- 产品真正需要哪些排序字段? 如果只需要创建时间和总金额,就只公开这两个键。不要接收
任意列表达式、函数、排序规则或逗号分隔的排序子句。
- 接口是否会搜索多个状态或 ID? 使用驱动的数组能力、类型化数组参数,或生成一组占位符。
禁止把原始值连接进 SQL 文本。
- 链路中是否使用 ORM 原生查询 API? 同时检查明确标为不安全的方法和看似安全的模板方法,
确认值最终由驱动绑定,还是先被插值成字符串。
- 存储过程是否构造动态 SQL? 存储过程不会自动安全。它的参数在最终执行 SQL 时仍必须是
数据,动态 EXEC 一类路径需要同样审查。
- 应用数据库角色拥有哪些权限? 只读搜索接口通常不应拥有 schema、DDL 或无关表权限。
运行时凭据应与迁移、运维凭据分离。
- 发布前需要哪些证据? 在改代码前先定义恶意输入语料、租户隔离检查、原生查询清单、
数据库角色验证和生产错误信号。
30 秒回答框架
“我会让已认证的服务端上下文成为 tenantId 的唯一来源,再通过数据库驱动绑定所有数据值: 租户、邮箱、状态数组和返回数量。排序列和方向属于 SQL 语法,所以我会把两个公开枚举映射到 服务端固定 SQL 片段,拒绝其他输入。我还会盘点 ORM 原生查询和存储过程,因为它们可能重新 引入字符串 SQL;数据库中已有数据进入后续执行边界时也要再次参数化。最后收紧运行时数据库 角色,向客户端返回通用错误,内部记录脱敏的详细失败信息,并运行集成测试,证明恶意字符串 只能作为字面值,而且任何请求都不能返回其他租户的数据。”
分步深入解答
第一步:标记代码、数据和授权来源
先给原查询的每一部分分类:
| 查询部分 | 类型 | 安全来源 | |---|---|---| | SELECT、表名、谓词 | SQL 语法 | 静态应用代码 | | 租户 ID | 数据和授权范围 | 已认证服务端上下文 | | 邮箱、状态、返回数量 | 数据 | 驱动绑定参数 | | 排序列 | SQL 标识符 | 服务端白名单 | | 排序方向 | SQL 关键字 | 服务端白名单 |
不安全示例让请求派生字符串参与 SQL 解析。引号、注释、操作符或额外表达式可能在数据库理解 哪些字符本应是数据之前改变语句。检查少量“可疑字符串”无法恢复安全边界,因为 SQL 还有方言 差异、编码、注释、函数和解析器行为。
审查范围应该从所有 SQL 执行点开始,而不只看这个接口。搜索原生查询 API、查询方法附近的 模板字符串和拼接、动态存储过程执行、报表筛选器,以及允许调用方传字段名或操作符的查询 构造器。沿包装层继续追踪,直到能说明哪些内容作为 SQL 文本进入驱动,哪些内容进入参数集合。
第二步:在服务端绑定每一个值
PostgreSQL 风格的 TypeScript 实现可以把值与 SQL 文本分离:
const SORT_COLUMNS = {
createdAt: "o.created_at",
total: "o.total_cents",
} as const
const SORT_DIRECTIONS = {
asc: "ASC",
desc: "DESC",
} as const
interface OrderSearchInput {
email: string | null
statuses: string[] | null
sort: string
direction: string
limit: number
}
async function findOrders(authenticatedTenantId: string, input: OrderSearchInput) {
if (
!Object.hasOwn(SORT_COLUMNS, input.sort) ||
!Object.hasOwn(SORT_DIRECTIONS, input.direction)
) {
throw new Error("Unsupported sort option")
}
const sortColumn =
SORT_COLUMNS[input.sort as keyof typeof SORT_COLUMNS]
const sortDirection =
SORT_DIRECTIONS[input.direction as keyof typeof SORT_DIRECTIONS]
const sql = `
SELECT o.id, o.customer_email, o.status, o.total_cents, o.created_at
FROM orders AS o
WHERE o.tenant_id = $1
AND ($2::text IS NULL OR o.customer_email = $2)
AND ($3::text[] IS NULL OR o.status = ANY($3))
ORDER BY ${sortColumn} ${sortDirection}
LIMIT $4
`
return db.query(sql, [
authenticatedTenantId,
input.email,
input.statuses,
input.limit,
])
}保留的字符串插值只使用经过运行时成员检查后,从常量映射中选出的值。任何请求字符串都不会 成为标识符或关键字。租户、邮箱、状态数组和返回数量始终留在驱动参数集合中。
执行前还要把 limit 校验为产品允许范围内的整数,用来保护资源和 API 语义。它仍然应作为 参数绑定,因为业务校验和代码数据分离解决的是两个不同问题。
如果数据库没有方便的数组参数,可以根据数组长度生成占位符,再逐个绑定元素:
status IN ($3, $4, $5)
values = [tenantId, email, status1, status2, status3]可信代码可以生成占位符文本,状态值不能连接进 SQL。空数组语义也要明确:它可以表示“不加 状态筛选”,也可以表示“没有任何匹配”,不能让查询构造器偶然决定。
第三步:让动态语法保持最小并归服务端所有
普通绑定参数表示值,不能代表任意标识符或关键字。把 "created_at" 作为 $1 传入,通常 只会得到一个字符串字面量,而不会按该列排序。用值绑定接口处理不可信标识符,无法完成产品 需求。
应该只公开很小的词汇表,并映射到固定内部片段:
createdAt -> o.created_at
total -> o.total_cents
asc -> ASC
desc -> DESC未知键一律拒绝。不要从请求透传带引号的标识符、SQL 函数、JSON 路径、排序规则、空值顺序 或表达式。以后如果产品要增加计算排序,应在服务端实现表达式,再把一个新的公开键映射到它。
可信管理工具确实需要动态标识符时,数据库提供的标识符引用工具可能适用。但普通 API 对任意 用户输入做标识符引用,往往仍保留了过宽能力。狭窄映射更容易审查和测试。
第四步:独立保证租户隔离
从已验证会话、令牌或服务身份中推导 authenticatedTenantId。不要相信 JSON 请求体、查询 参数或浏览器状态里重复出现的租户值。每个读写租户数据的查询都应包含授权范围,或者通过一个 强制要求租户参数的仓储层执行。
测试矩阵必须包含属于其他租户的有效订单 ID、邮箱或状态。即使所有 SQL 值在语法上都很普通, 查询也不能返回那条记录。这样才能发现单纯注入测试覆盖不到的授权缺陷。
如果数据库行级安全基于可信会话身份配置,并且经过连接池场景测试,它可以再增加一道边界。 它不能替代服务端授权、正确的会话变量管理或安全查询构造。应把它当作有独立配置和测试的额外 执行层。
第五步:审查 ORM、存储过程和二次执行路径
ORM 的常规筛选 API 往往会绑定参数,但原生执行方法可能同时提供安全参数化形式和明确不安全 的字符串形式。需要核对具体方法、框架版本和驱动路径。仅凭 queryRaw 之类方法名无法判断, 必须证明最终 SQL 和参数是否分开发给驱动。
只有输入在最终 SQL 执行中仍是参数,存储过程才安全。如果存储过程把参数拼进动态 SQL 后再 执行,只是把漏洞边界移到了数据库内部。搜索过程体中的动态执行语句,并审查应用角色获得的 执行权限。
二次 SQL 注入指恶意文本第一次被当作普通数据安全存储,后来又被另一个进程作为 SQL 语法 拼接。例如导入任务可以安全写入报表名称,但夜间报表任务随后把名称拼进查询。第一次写入可以 完全参数化,后续执行仍然可能有漏洞。无论值来自直接请求、其他服务、文件还是已有数据库行, 每个执行边界都要参数化;如果值确实需要选择语法,则必须映射到固定片段。
第六步:增加纵深防御,但不能掩盖缺陷
运行时数据库角色只保留所需表和操作权限。只读搜索服务不应拥有 schema,不应有删除表、创建 扩展或更新无关数据的权限。迁移和运维使用独立凭据。最小权限可以限制遗漏注入或其他应用入侵 的影响范围,但不能让不安全查询变得可接受。
输入校验应执行业务类型、长度、枚举成员和范围约束,从而尽早拒绝格式错误并减少资源滥用。 它仍是第二道防线,因为通过校验的值放进错误 SQL 上下文仍可能危险,而且姓名等自由文本本来 就可能包含标点。
手工转义依赖数据库方言,也很脆弱。不要新建通用 escapeSql 工具再拼接输出。如果遗留路径 暂时无法替换,应隔离它,使用数据库厂商明确提供的机制,收紧权限,补充测试并跟踪删除。Web 应用防火墙可以在事故期间提供临时检测或虚拟补丁,但不能证明所有数据库执行路径安全。
客户端只收到通用失败信息。内部日志记录路由、操作、部署版本和数据库错误类别,但不向调用方 回显 SQL、堆栈、连接信息或敏感参数。日志也要避免秘密和完整个人数据,同时保留足够的调查 标识。
第七步:在查询边界验证修复
使用真实驱动和数据库方言运行集成测试。语料应包含引号、注释标记、分号、Unicode、通配符和 看似 SQL 的文本。断言不能只写“请求没有崩溃”。值必须按字面量处理,查询结构必须保持固定, 接口只能返回已授权数据。
至少覆盖:
- 含引号或类似注释字符的邮箱仍按完整字面量比较。
- 每个允许排序键都生成预期的固定
ORDER BY片段。 - 未知排序键、方向、状态和越界返回数量在查询前被拒绝。
- 空数组、单元素数组和多元素数组具有明确语义并使用绑定值。
- 调用方无法通过修改任何请求字段取得其他租户数据。
- ORM 原生查询和存储过程路径使用同一套恶意语料。
- 报表或维护任务再次使用已存储字符串时,该值在第二个执行边界仍是数据。
- 运行时数据库角色不能修改 schema,也不能访问无关表。
静态分析和代码审查可以防止新增原始插值路径。可以为不安全原生查询方法和 SQL 附近的字符串 构造设置规则或审查检查点,但仍要保留集成测试,因为简单文本搜索未必能看到包装层、生成代码、 存储过程和真实驱动行为。
第八步:发布、观察和响应
发布时观察数据库错误率、接口延迟、拒绝输入数量和查询形状。语法错误、权限错误或非法排序键 突然增加,可能意味着遗漏客户端,也可能意味着主动探测。不要为了计数而记录完整恶意载荷。
如果怀疑发生真实注入,应禁用或收窄有漏洞接口,保留相关部署和审计证据,轮换可能泄露的数据库 凭据,并根据运行时角色权限判断攻击者可能读写的范围。检查数据库日志和业务记录中的未授权访问, 修复所有同类执行路径,把已发现的攻击类别加入回归语料后再恢复正常访问。
完成标准是:不安全语法路径已经消失,租户授权仍被执行,运行时角色权限受限,所有查询执行器 已经盘点,测试证明恶意输入在即时执行和后续执行中都只能作为数据。
高质量示范回答
“我会先盘点这个接口使用的所有 SQL 执行路径,包括 ORM 原生方法和存储过程。原查询混合了 五类问题:租户、邮箱、状态和返回数量是数据;排序列和方向是 SQL 语法;租户范围同时还是授权 决策。
我会从已认证的服务端上下文取得租户,并和邮箱、类型化状态数组、有限范围整数一起交给驱动 绑定。排序只公开 createdAt、total、asc 和 desc,经过运行时成员检查后映射到常量 SQL 片段,其他值全部拒绝。如果驱动没有数组绑定,就只生成占位符列表,每个元素仍单独绑定。
我不会因为使用 ORM 就假设所有路径安全,而会核对具体原生查询 API。存储过程也要检查动态 执行。已有数据后来进入报表或批处理 SQL 时仍是不可信输入,必须在新的执行边界再次参数化。
纵深防御方面,搜索服务使用只能读取所需列或视图、不能修改 schema 的数据库角色。业务校验 约束枚举、长度和数量,参数化继续承担防注入职责。客户端只收到通用错误,内部日志记录操作和 错误类别,不记录 SQL 文本或敏感参数。
最后我会用真实驱动做集成测试。引号、注释、看似 SQL 的文本、Unicode 和数组元素都必须保持 字面值。测试覆盖全部允许和拒绝的排序选项、空列表和大列表、ORM 与存储过程、已存储数据再次 使用,以及跨租户标识符。只有查询结构保持不变,而且任何请求都无法返回其他租户数据,修复才 算完成。”
常见错误
- 只参数化邮箱 → 租户、列表元素、返回数量或其他筛选器仍可改变 SQL → **绑定所有值,并
盘点完整执行路径。**
- 把请求列名放进普通值占位符 → 值占位符不能表示标识符 → **把小型公开枚举映射到服务端
固定 SQL 片段。**
- 给任意请求标识符加引号 → 接口仍让调用方控制过宽语法面 → **只公开产品真正需要的排序
键,其他全部拒绝。**
- 把校验后的状态列表连接进
IN (...)→ 校验可能漂移,每个元素也重新进入 SQL 文本 →
使用类型化数组参数,或生成占位符并逐个绑定。
- 因为
tenantId已参数化就从请求中读取 → 代码数据分离不能授权目标租户 → **从已认证
服务端上下文推导租户范围。**
- 认为 ORM 能阻止所有注入 → 原生或不安全方法可以绕过常规绑定 → **核对具体 API 和最终
驱动调用。**
- 把字符串构造移进存储过程 → 过程内动态 SQL 仍然可以被注入 → **让过程参数一直保持到
最终执行。**
- 只在第一次写入时清洗 → 已存储恶意文本可能在报表任务中变成 SQL 语法 → **保护每个
执行边界,防止二次注入。**
- 使用自定义工具转义引号 → 方言、编码和上下文差异使手工转义脆弱 → **使用驱动的服务端
参数绑定接口。**
- 因为查询已修复就给应用 owner 权限 → 其他缺陷或凭据泄露仍有过大影响范围 → **使用
最小权限运行时角色,并分离迁移凭据。**
- 为了调试把数据库错误和 SQL 返回给客户端 → 调用方会得到 schema 和查询细节,日志也
可能泄露个人数据 → 返回通用错误,内部记录结构化且脱敏的证据。
- 只测试一个经典载荷 → 授权、数组、存储过程、其他原生路径和二次执行仍未覆盖 →
用多样语料验证查询结构和租户结果。
追问
追问一:预编译语句能保护动态表名或列名吗?
普通绑定参数表示值,一般不能替换表名、列名、操作符或 SQL 关键字。应重新设计 API,让调用方 只选择小型枚举,再把每个允许键映射到应用持有的固定 SQL 片段。如果可信管理工具确实需要动态 标识符,应使用数据库厂商提供的标识符机制和更严格的授权边界,不能把这种能力暴露给普通用户 搜索接口。
追问二:使用 ORM 就足以防止 SQL 注入吗?
只有选定 API 一直把参数绑定保留到最终驱动调用时才足够。普通等值和筛选方法通常可以做到, 原始字符串方法、不安全变体、动态字段名、自定义操作符和扩展未必可以。检查线上框架版本,在 安全测试环境观察生成的 SQL 与参数,并运行恶意输入集成测试。ORM 能减少犯错机会,不能免除 对逃生口的理解。
追问三:什么是二次 SQL 注入?
攻击者可控值第一次作为数据存储,没有改变原语句。后续进程读取该值并拼接动态 SQL,使它改变 新语句结构。修复点位于后续执行边界:把已存储值绑定为数据;如果它本来用于选择语法,就映射 到固定片段。当数据库行、文件、队列或内部服务影响可执行 SQL 时,都应把它们视为不可信来源。
追问四:是否应该拒绝所有引号、分号或 SQL 关键字?
不应该。姓名、搜索文本和其他合法数据可能包含标点或看似 SQL 的词。业务校验应约束真实领域 类型、长度、格式、枚举和范围。参数化通过把完整值固定为字面数据来提供安全性。拒绝列表既不 完整,也会破坏合法输入。
追问五:如何处理很大的动态 IN 列表?
优先使用数据库类型化数组或表值参数;否则为每个元素生成一个占位符并分别绑定。为资源控制 设置最大列表长度。非常大的列表可能需要临时表、批量载入机制或重新设计 API,但写入值仍应通过 绑定或批量协议传输,不能拼进 SQL 字符串。
追问六:确认生产环境发生 SQL 注入时怎么办?
先限制有漏洞路径,保留部署、数据库和应用审计证据,并轮换可能暴露的凭据。根据运行时角色权限 确定调查范围,检查未授权读写,修复所有同类查询构造器和存储过程。把真实路径加入回归测试,在 条件允许时继续收紧权限,只有修复后的查询和租户边界都验证通过后才恢复流量。