做 Django 项目做得久的人,多半都遇到过这样的场景:某天做代码评审,翻到一个导出报表的接口,里面赫然一行cursor.execute("SELECT * FROM orders WHERE status = '%s'" % status),写这行代码的同事还觉得“我在 Django 里,ORM 会自动帮我转义的”。这种误解在团队里传播得相当广——很多人以为只要用了 Django 框架,SQL 注入就自动被挡住了,于是把注意力全放在 XSS 和权限校验上,反而在最容易出事的那几行手写 SQL 上完全松懈。真实情况是,Django 的 ORM 确实把参数化查询做得很扎实,但它只覆盖“你用 ORM 标准写法”的那部分代码;一旦你伸手去拿cursor、写raw()、拼RawSQL、或者把用户传来的字段名塞进**kwargs,框架的保护伞就没有了。这篇内容我想系统地聊一遍 Django 里防 SQL 注入的完整链条:ORM 的保护边界到底在哪里、哪些写法会把这层保护撕开、怎么在存量代码里把这些入口挖出来验证、以及数据库层面还能怎么兜底。适合已经写过一段时间 Django、接手的项目有一定体量、准备或者正在做安全治理的开发者,也适合刚入门但不想将来给自己挖坑的同学。
1. ORM 的保护伞到底覆盖到哪一步
1.1 一条 filter() 在数据库里长什么样
先把最基础的东西说透,后面很多判断都建立在这个认知上。当你写下Order.objects.filter(status="paid", amount__gt=100)的时候,Django 并不是把这两个值拼进 SQL 字符串,而是经历了 Query → WhereNode → 编译成 SQL 模板 + 参数列表的过程。最终交给数据库驱动的,是两个东西:一段带占位符的 SQL,和一个参数元组,大致是SELECT ... FROM "order" WHERE ("order"."status" = %s AND "order"."amount" > %s),参数是['paid', 100]。
这两者是被分开传输的。数据库驱动在解析这条 SQL 的时候,已经把执行计划定死了——它知道这里只有一个字符串比较和一个数值比较,参数只是“填进去的值”,无论这个值里面包含'、--、; DROP TABLE还是UNION SELECT,它都只是一个字符串字面量,永远不会被重新解析成 SQL 语法。这就是参数化查询(prepared statement)的本质:结构先于数据确定,数据无法改变结构。
你可以拿sql_with_params()亲手验证一下,这比看任何文档都直观:
qs = Order.objects.filter(status="paid", amount__gt=100) sql, params = qs.query.sql_with_params() print(sql) print(params)输出的 SQL 里你只会看到%s,看不到paid。这就是“值是参数”的直接证据。反过来说,如果某个写法最终拼出来的 SQL 里直接出现了用户输入的字符,那它就已经脱离了参数化保护,不管外面包了几层 Django 的类。
1.2 参数化只保护“值”,不保护“结构”
这是整个话题最关键的一句话,值得单独拎出来。SQL 语句由两部分组成:结构(表名、列名、排序方向、运算符、函数名)和值(具体的字面量)。参数化机制只能保护值,因为参数的本质就是“延迟绑定的字面量”。表名和列名不行——数据库在执行计划阶段就需要知道查哪张表、用哪个索引,它不可能等到参数绑定时才决定FROM后面跟什么。
于是所有注入的变种,本质上都是在攻击“结构位”。当你看到下面这类代码的时候,就要立刻警觉:
field = request.GET.get("sort", "created_at") qs = Order.objects.order_by(field) # 结构位来自外部order_by接收的是字段名,它最终会变成ORDER BY "xxx"里的标识符。Django 会对字段名做一个合法性校验(在names_to_path里),非法字段会抛FieldError,这在某种程度上形成了一层“意外的防护”。但这种防护是靠异常实现的,不是设计层面的白名单,某些路径(比如别名、表达式、注解名)的校验强度并不一致,而且一旦有人用try/except把FieldError吞掉然后 fallback 到拼接,防线就直接没了。更稳妥的做法永远是显式白名单,这一点后面会展开。
同样的道理适用于:动态表名、动态列名、values()的字段列表、annotate()的别名、RawSQL里的片段、extra()里的where、JSON 字段的 key 路径、全文检索的字段名。凡是“决定 SQL 长什么样”的输入,都不能来自用户,只能来自你自己的常量表。
1.3 真正会破防的四类写法
把 Django 项目里可能引入注入的入口归个类,其实就那么几处,我在做代码审计的时候基本就是照着这个清单去扫:
| 类别 | 典型 API | 风险点 | 是否可参数化 |
|---|---|---|---|
| 原生 SQL 执行 | cursor.execute()、connection.cursor() | 手写 SQL 字符串 | 可,必须传 params |
| 原生查询入口 | Model.objects.raw()、RawSQL() | 片段拼接 | 可,必须传 params |
| 结构位动态化 | order_by()、values()、**kwargs、annotate别名 | 标识符注入 | 不可,只能白名单 |
| 已废弃入口 | extra(where=..., select=...)、extra(tables=...) | 完全是裸拼接 | 不可,应重构掉 |
这四类里,前两类是“你主动写 SQL”,只要养成传 params 的习惯就不会出事;后两类更隐蔽,因为代码读起来很像正常的 ORM 调用,评审时一眼扫过去根本发现不了。我见过最典型的例子是queryset.filter(**{f"{key}__icontains": value}),key来自前端的筛选项配置,value是参数化的,key不是——参数化保护住了值,但结构位被用户控制了。
判断标准很简单:如果一段用户输入最终变成了 SQL 里的“名字”,而不是 SQL 里的“值”,那它就绕过了参数化,必须按白名单处理。
2. raw() 与 cursor.execute() 的两种命运
2.1 传不传 params,差的不是一点半点
Model.objects.raw()和cursor.execute()这两个 API 长得几乎一样,raw(sql, params)、execute(sql, params),第二个参数是可选的。正是这个“可选”害了很多人——不传 params 的时候,它们就是纯粹的字符串执行器,一点防护都没有。下面这段代码是教科书级别的错误示范:
def search_orders(request): keyword = request.GET.get("q", "") sql = "SELECT * FROM order WHERE code LIKE '%%%s%%'" % keyword return list(Order.objects.raw(sql))注意这里它连%都手动拼接了,看上去还挺“讲究”,实际上keyword传进来一个' OR '1'='1就全线崩塌。正确写法是把%的部分留在 SQL 里,把变量交给 params:
def search_orders(request): keyword = request.GET.get("q", "") sql = "SELECT * FROM order WHERE code LIKE %s" return list(Order.objects.raw(sql, ["%" + keyword + "%"]))raw()的占位符统一写%s,不管底层是哪种数据库,Django 会负责转成后端需要的风格。这一点很多人不知道,以为raw()里得按数据库写?或者:name,结果为了“兼容”反而去手工拼接了,非常可惜。
cursor.execute()同理:
from django.db import connection with connection.cursor() as cursor: cursor.execute( "SELECT code, amount FROM order WHERE status = %s AND amount > %s", ["paid", 100], ) rows = cursor.fetchall()这里有个常被忽略的细节:connection.cursor()是个上下文管理器,它的__exit__只负责关闭游标,不会回滚事务。如果你在一个atomic()块里用裸游标写数据,出异常之后的清理逻辑得自己操心,别以为with帮你摆平了一切。
2.2 占位符与百分号转义那些坑
前面提到%s是统一写法,但它有个副作用:当你的 SQL 里同时出现了“占位符”和“字面量百分号”的时候,字面量百分号必须写成%%。因为驱动会先做一次格式化扫描,看到%就找后面的字符,如果后面跟着的不是s,行为就不可预期了。
# 错误:LIKE 里的 % 被当成了格式化符号 cursor.execute("SELECT * FROM order WHERE code LIKE '%abc%' AND status = %s", ["paid"]) # 正确:字面量百分号写成 %% cursor.execute("SELECT * FROM order WHERE code LIKE '%%abc%%' AND status = %s", ["paid"])这个坑我在两个项目里都踩过,表现是运行时抛IndexError: tuple index out of range或者ValueError: unsupported format character,看起来莫名其妙,因为 SQL 语法本身没错。还有一种更阴的情况:SQL 是动态拼出来的,今天恰好没有%,测试全绿;明天运营要求加个模糊匹配,代码一改就炸,而且炸在第 200 行开外的 SQL 上,排查半天。
另一个是单引号的问题。有些人为了“保险”,在占位符外面再加一层引号:
cursor.execute("SELECT * FROM order WHERE code = '%s'", [code]) # 大错特错这样写的结果是参数被当成字符串插进去之后,外面又被引号包了一层,code里如果本来就有单引号,数据库直接报语法错误;如果数据库的配置比较宽松(比如某些模式下),甚至可能被绕过。占位符外面永远不要加引号,加了就是自己给自己挖坑。
2.3 标识符没法参数化,那就只能靠白名单
表名和列名的参数化在所有主流数据库里都是不支持的,这不是 Django 的短板,是 SQL 协议本身的限制。所以处理这类需求,正确姿势只有一条路:映射表。
ORDER_FIELD_MAP = { "created": "created_at", "amount": "amount", "status": "status", } def apply_ordering(qs, raw_field): field = ORDER_FIELD_MAP.get(raw_field) if field is None: field = "created_at" return qs.order_by(field)关键点是:白名单里的值必须是代码里写死的常量,而不是“从数据库里查出来的字段列表”——后者本质上还是可控数据。有人喜欢用[f.name for f in Model._meta.get_fields()]去动态校验,看起来很聪明,但这样做同时也把_meta里所有的关联字段、反向关系、甚至id都放行了,攻击面比你想的大。宁可手写十来行常量,也不要图省事用反射。
还有些场景确实需要动态表名,比如分表查询。这时候的思路是先用一个字典把“逻辑名”映射到“物理模型”,然后走 ORM:
SHARD_MAP = {"2024": Order2024, "2025": Order2025} model = SHARD_MAP.get(year) if model is None: raise ValueError("unsupported shard") return model.objects.filter(status="paid")如果确实绕不开原生 SQL,还有最后一招:connection.ops.quote_name()。它会把标识符按当前数据库的规则加引号,能在一定程度上减少因名字里带特殊字符导致的语法问题。但要清楚,quote_name是转义不是校验,它挡不住逻辑上的越权(比如用quote_name包了个order表名,你依然能查到你本不该查的表)。真正的防线是白名单,quote_name只是辅助。
3. 藏在字段名里的注入面怎么挖出来
3.1 filter({...}) 与 Q({...}) 的键名来源
这类代码在带筛选器的列表接口里极其常见,写法大概是:
def build_query(params): q = Q() for key, value in params.items(): q &= Q(**{key: value}) return Order.objects.filter(q)value被参数化了,没问题;key直接变成了查询路径。攻击者可以构造status__startswith、created_at__year__gt这类键名来窥探数据结构,更极端的情况下,如果后端的 Django 版本较老、或者某个自定义 Lookup 注册时没有做好校验,还可能引出意料之外的 SQL 片段。即便只是“窥探”,在真实的攻防里也足够有价值了——攻击者可以靠报错差异一点点摸清字段名,然后发起下一步。
正确的处理方式是把前端的筛选参数翻译成内部的、固定的查询参数:
FILTER_SPECS = { "status": lambda v: Q(status=v), "min_amount": lambda v: Q(amount__gte=v), "keyword": lambda v: Q(title__icontains=v), } def build_query(params): q = Q() for key, value in params.items(): builder = FILTER_SPECS.get(key) if builder is None: continue q &= builder(value) return Order.objects.filter(q)这样做还有一个额外的好处:筛选逻辑变成了可枚举的,产品要新增筛选项,你改一个字典就完事,前端的乱传参数也不会影响后端行为。我一直觉得这是“安全设计顺带解决了可维护性”的典型例子。
同样的道理在 DRF 的filter_backends、ordering_fields、search_fields上成立。DRF 自带的OrderingFilter有ordering_fields白名单,配好了就没问题;怕的是有人自己写了个get_queryset,里面直接order_by(request.query_params.get("sort"))。
3.2 order_by、values、annotate 的别名面
values()和values_list()接受字段名列表,同样是结构位。有些接口设计成“让前端决定返回哪些列”,图省流量,这个需求本身合理,但实现上必须把前端传来的列名映射到允许的集合:
ALLOWED_COLUMNS = {"id": "id", "code": "code", "amount": "amount"} def select_columns(qs, requested): cols = [ALLOWED_COLUMNS[c] for c in requested if c in ALLOWED_COLUMNS] if not cols: cols = ["id"] return qs.values(*cols)annotate()的情况稍微复杂一点。注解的别名是你自己起的,一般不会有问题,但注解的内容如果用了F()加动态字段名,一样会出事:
# 危险:field 来自外部 qs.annotate(diff=F(field) - F("baseline"))F()的参数会走names_to_path,和order_by是同一套校验,同样只能算“意外防护”。只要字段名是外来的,一律走白名单,没有例外。
还有一种容易忽略的场景:聚合时用Sum("amount"),字段名写死当然没事,但如果有人写了个通用的统计接口,字段名从配置中心或者数据库里的“自定义报表”表里读出来,那就变成了二次注入——攻击者先污染配置,再触发查询。这种链路在我们做渗透测试的时候特别喜欢找,因为开发者往往只盯着 HTTP 入参,忘了数据本身也可能是攻击载荷。
3.3 RawSQL、extra、JSON key 这几个老面孔
RawSQL是 Django 官方提供的表达式入口,用来在 ORM 里塞一段原生 SQL 片段:
from django.db.models.expressions import RawSQL qs = Order.objects.annotate( rank=RawSQL("SELECT COUNT(*) FROM order o2 WHERE o2.amount > %s", [threshold]) )它支持的第二个参数是 params,用了就是安全的,不用就是灾难。我在审计时看到过RawSQL(f"amount > {threshold}")这种写法,那个f前缀就是危险信号本身——凡是 SQL 字符串前面带着f,基本可以直接判定为待修复。
extra()现在已经不推荐使用了(新版本里标记为废弃),它的where、select、tables参数都是纯裸拼接,没有任何参数化能力。历史项目里如果还有大面积的extra(),建议列个清单逐个重构成annotate+RawSQL或者干脆用原生 SQL 加参数。
JSON 字段这块要单独提一句。__has_key、__contains这类查询,键名和值都会被参数化,这一层是安全的。但如果你用的是KeyTextTransform这类表达式,它的 key 参数会直接变成 SQL 里的路径表达式:
from django.db.models.fields.json import KeyTextTransform qs.annotate(v=KeyTextTransform(user_key, "payload")) # user_key 外来就危险处理方式和前面完全一致:映射表。JSON 字段的结构本身就是业务约定的一部分,让用户随便指定 key 本来就不合理。
3.4 LIKE 通配符与 __in 列表的边界情况
这两个不算真正的注入,但经常被混淆,一并说清楚。
__contains、__icontains、__startswith这些 lookup,Django 内部会做通配符转义:用户输入里的%和_会被加上ESCAPE处理,所以q="100%"不会变成“匹配任意内容”。这点可以放心。但要注意它的性能影响:icontains生成的是LIKE '%xxx%',前缀通配符导致索引失效,几十万行的表上查一次就是全表扫描。这不是安全问题,是性能问题,但经常和安全问题一起被提出来,因为它们都跟“用户输入进入了 SQL”有关。
__in是彻底参数化的。Order.objects.filter(id__in=[1, 2, 3])生成的是IN (%s, %s, %s),列表长度决定占位符数量。这里有个真实的坑:如果列表长度不受控,比如id__in=request.GET.getlist("ids")而前端可以传一万个 id,生成的 SQL 会有上万个占位符,直接触发数据库的语句长度上限或者参数上限(PostgreSQL 是 65535,SQLite 更低)。轻则报错,重则拖垮连接池。所以对列表类参数一定要做长度截断,这个是纯工程经验,文档不会告诉你,但线上真的会出事。
__regex也要留意。它本身是参数化的,不会注入,但用户可以通过构造灾难性回溯的正则表达式把 CPU 打满,属于另一种形式的攻击。如果接口允许传正则,最好限制长度和复杂度,或者干脆不开放这个能力。
4. 存量项目里怎么把这些问题揪出来
4.1 先用特征字符串把可疑点全捞一遍
接手一个跑了几年、几十万行代码的项目,靠人肉读是不现实的。我的做法是先做一轮粗筛,把范围从“全项目”压缩到“两三百行”,然后逐个看。
grep -rnE "\.raw\(|RawSQL|\.extra\(|cursor\.execute|connection\.cursor|executemany" \ --include="*.py" . | grep -v "/migrations/"排除migrations目录很重要,不然你会被历史迁移文件里的 SQL 淹掉。扫出来的结果按文件分组,先看views.py、services.py、reports.py这类高概率位置,再看utils.py。
第二遍扫拼接痕迹,这些才是真正的危险信号:
grep -rnE "execute\(\s*f\"|execute\(\s*\"[^\"]*%[sd]\"\s*%|RawSQL\(\s*f\"" --include="*.py" .第三遍扫动态字段名:
grep -rnE "order_by\(|values\(|values_list\(|\*\*\{|\*\*kwargs" --include="*.py" .这一遍会命中大量正常代码,别慌,重点看参数是不是来自request、query_params、序列化后的 payload。判断方法很土但很有效:把命中行的变量往上追三层,看到底是不是从 HTTP 层流下来的。
除了手写 grep,静态扫描工具也值得跑一遍。开源的 Python 安全扫描工具里有针对“硬编码 SQL 表达式”的规则,能识别%拼接和f-string拼接这两种模式,配上 CI 能当门禁使。但它对动态字段名这类问题基本无感,所以只能作为辅助,不能作为唯一手段。
4.2 构造 POC 验证,别靠猜
扫出来的点,不要凭经验直接下结论,要构造一个能跑通的验证。方法很朴素:写个单元测试,用恶意载荷打进去,然后看两件事——有没有报错、connection.queries里记录的 SQL 长什么样。
from django.test import TestCase from django.db import connection class InjectionProbeTest(TestCase): def test_search_payload(self): payload = "' OR '1'='1" self.client.get("/api/orders/", {"q": payload}) executed = [q["sql"] for q in connection.queries] for sql in executed: self.assertNotIn(payload, sql)这里的关键是connection.queries,它只在DEBUG=True且测试环境下有内容。断言“载荷没有原样出现在 SQL 里”是个很粗糙但很有效的判据——如果它出现了,说明这段 SQL 是拼接的,基本可以确诊。
需要注意,DEBUG=True时connection.queries会累积所有查询,在测试里要把settings.DEBUG打开,或者用CaptureQueriesContext精确捕获某一段。另外这个机制会带来明显的内存开销,生产环境千万别开。
对于结构位的问题,POC 的构造要换个思路:传一个不存在但形似字段名的 key,比如filter(**{"not_a_field__icontains": "x"}),看会不会抛FieldError。如果抛了,说明 Django 的路径校验在起作用,风险等级下降;如果没抛而是执行成功了,那说明这个 key 被某段自定义逻辑吞掉了,得顺着往下查。
4.3 修完之后怎么确认真的修好了
修完一遍,我会跑一张回归清单,逐项确认:
| 检查项 | 验证方式 | 通过标准 |
|---|---|---|
| 原生 SQL 全部带参数 | 全量 grep + 人工复核 | 无裸拼接残留 |
| 结构位全部走白名单 | 传入非法字段名 | 返回默认排序/字段,不报 500 |
| 异常路径不泄漏 SQL | 构造触发 DB 错误的请求 | 返回通用错误页 |
| 参数长度受控 | 传超长列表、超长字符串 | 被截断或拒绝,不拖垮数据库 |
| 旧入口已清理 | 搜索extra( | 无残留 |
“传入非法字段名时返回 500”这个情况要特别注意。很多团队修的时候只加了个白名单判断,但没处理 fallback,结果非法输入直接抛异常,等于把错误路径暴露给了攻击者。正确的行为是静默降级到默认值,而不是抛错。
还有一点,修完不要只跑一遍功能测试就完事。要专门写一组“恶意输入”的测试用例固化下来,形成回归网。这些用例不会随时间变化,却能防止后来的人在重构时把白名单又改回动态拼接——这种情况我在真实项目里见过不止一次。
5. 代码之外的兜底:数据库和配置层
5.1 应用账号的权限一定要收紧
代码写得再好,也不该把数据库的全部权限交给应用。这是纵深防御里最便宜、收益最高的一招。原则就三条:应用账号只给 DML(SELECT/INSERT/UPDATE/DELETE),不给 DDL(CREATE/DROP/ALTER),不给任何管理类权限。
-- 示意,具体语法因数据库而异 GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_user'@'%';做不到这一点会怎样?假设真的被注入成功,攻击者能做的就远不止“读数据”。有了DROP权限可以删表;有了文件读写权限可以往服务器上落文件;有了跨库查询权限可以横向摸到别的业务库。权限收紧之后,即便某一行代码失守,损失也能被限制在“当前账号可见的数据范围内”。
再细一点,还可以按业务拆账号。报表类接口本来只需要读,就给它一个只读账号;定时任务需要写,单独配一个写入账号。多配几个连接的成本,远低于一次数据泄露的代价。Django 的多数据库配置支持这个玩法,DATABASES里定义多个 alias,业务代码里用using()指定。
5.2 DEBUG 模式和错误页是二次泄漏的重灾区
DEBUG = True上线,这是老生常谈但每年都能遇到的事故。在 Django 的调试页里,一次数据库异常会直接把完整的 SQL 语句、参数、甚至数据库连接的配置片段打在页面上。攻击者根本不需要注入成功,只要想办法让你报个错,就能免费拿到你的表结构。
上线检查清单里我一定会有这几条:
DEBUG = False ALLOWED_HOSTS = ["your-domain"]同时配上自定义的 404 和 500 模板,把 Django 默认的错误页替掉。默认页面在某些配置下依然会带一些提示信息,自定义模板能彻底断掉这条路。
日志也是同理。很多人习惯在except里把异常对象直接打日志,而数据库异常的str()往往包含完整的 SQL。日志文件如果权限没管好,或者被采集到了公共的日志平台,这个泄漏面比网页还大。我的做法是在业务层捕获异常后,只记录“某类操作失败”和请求 ID,具体的 SQL 留在调试级别、且只在必要时才开启。
5.3 从日志里找信号,而不是等出事
有了正常日志,就可以做一些简单的检测。数据库层或应用层日志里出现下面这些模式,基本可以判定有人在试探:
- 参数里出现
UNION、SELECT与注释符--的组合 - 参数里出现
information_schema、pg_catalog这类元数据表名 - 参数里出现
sleep(、pg_sleep(这类延时函数 - 大量请求在短时间内触发数据库语法错误
这些规则误报率不低(比如正常业务里也可能有带--的备注文本),所以不适合做阻断,只适合做告警和人工跟进。真正有价值的是“语法错误突增”这个信号——正常业务很少产生数据库语法错误,一旦某个接口的语法错误率突然拉高,十有八九是有人在手工构造载荷。
别把这类检测当成主力防线。它是“出事之后的第二只眼睛”,用来判断影响范围和攻击者的意图,不能替代代码层的修复。
6. 把这件事变成团队习惯
6.1 在合并请求上加一道自动门禁
个人的警觉靠不住,人会累、会忘、会离职。能长期生效的是流程。我的建议是在 CI 里挂两个检查:一个静态扫描,一个自定义的 grep 规则。
自定义规则可以写得很简单,比如禁止在业务代码里出现execute(后面紧跟f"的模式:
if grep -rnE 'execute\(\s*f"' --include="*.py" app/; then echo "检测到疑似拼接 SQL,请改用参数化写法" exit 1 fi规则粗糙没关系,它的作用是“让人停下来看一眼”,而不是“精确判断是否安全”。很多时候问题就出在“顺手写了没多想”,一道会失败的检查足以把人的注意力拉回来。
静态扫描工具可以作为第二步,覆盖更复杂的模式,比如变量拼接、字符串 format、跨行的 SQL 构造。这类工具的误报不少,所以建议只对新增代码生效(增量扫描),避免历史代码把门禁卡死,团队很快就把它关掉了。
6.2 评审时我会重点盯的几个信号
做代码评审的时候,我不可能逐行读完,所以会先扫信号词。看到下面这些,就停下来细看:
- SQL 字符串前面带
f或者后面跟% order_by()、values()、filter()的参数来自request- 出现
**{和}的组合 - 出现
extra( - 参数名里带
sort、order、field、column、table这类词,且来源是 HTTP 层
这五个信号覆盖了我见过的绝大多数问题。特别是最后一条,业务代码里凡是参数名叫field的,我都会追一遍来源,因为“动态字段”这个需求本身就是高危需求,实现方式稍有不慎就会出事。
反过来,如果一个改动里新增了白名单字典、新增了类型转换(int()、UUID()),那是好信号。类型转换其实是一种非常朴素但有效的防御:把用户输入强转成整数、UUID 这类结构化类型,转换失败就拒绝,注入空间直接归零。对于主键类的场景,我一直推荐在 URL 层就用类型转换器把参数卡死,比如路径参数用<int:pk>,比在视图里再判断要早一步。
6.3 三个最常见的认知误区
最后说几个我在带人和评审中反复纠正的误解,这些想通了,前面所有内容就串起来了。
第一个误区是“用了 ORM 就安全”。ORM 保护的是它自己生成的 SQL,你手写的那部分它管不着。而恰恰是手写的那部分,往往出现在报表、大屏、数据导出这些“ORM 不好写”的场景里,风险密度比普通接口高得多。
第二个误区是“转义一下就行”。很多人喜欢写个escape()函数把单引号变成两个单引号,觉得这样就安全了。问题在于不同数据库、不同字符集下的转义规则并不一致,而且转义只能处理值,处理不了结构。参数化是让数据库自己处理,转义是你自己猜数据库怎么处理,前者是根本方案,后者是权宜之计。遇到需要“手工转义”的代码,正确做法是重写成参数化,而不是把转义函数写得更完善。
第三个误区是“内部接口不用管”。后台管理、内部工具、给运营用的导出接口,这些地方最容易被忽视,因为“只有自己人用”。但内部工具的账号往往是共享的、弱口令的、甚至直接暴露在内网任意可达的位置,攻击者一旦进来,第一个翻的就是这些“不用管”的地方。我处理过的真实案例里,出问题的接口有相当比例是内部报表。
我自己干活的时候有个习惯:每当写出一个带字符串拼接的 SQL 片段,就先在心里问一句“这段代码如果被公开到代码仓库里,我会不会觉得难为情”。这个自检很简陋,但拦下来的问题比我读过的任何安全手册都多。参数化查询这件事没有灰色地带,要么传 params,要么就是在赌运气。