Django SQL注入防御:ORM边界与原生SQL参数化实践

发布时间:2026/10/27 3:46:46
Django SQL注入防御:ORM边界与原生SQL参数化实践 Django 项目里关于 SQL 注入的讨论往往会在两个极端之间摇摆一种认为“用了 ORM 就天然免疫”另一种则在每次代码评审时把所有cursor.execute都当成高危代码一刀切。我在几个中大型后台系统里做过安全整改实际情况是——Django 的 ORM 确实把绝大多数注入风险挡在了框架层但真正出问题的从来不是 ORM 本身而是那些“ORM 表达不了、只好退回原生 SQL”的边角场景复杂报表、动态排序、批量导出、跨库统计。这些地方一旦手滑参数化就断了注入面立刻打开。这篇内容围绕 Django 中的 SQL 注入防御策略展开面向的是已经写过一段时间 Django、能看懂 QuerySet 编译结果、并且需要处理原生 SQL 的开发者。我会把注入面拆开讲清楚——从 ORM 的编译链路到raw()、extra()、RawSQL、cursor.execute这几条退路的风险差异再到表名列名这类“结构性参数”为什么天生无法参数化、只能靠白名单。后半部分给可直接抄的实操代码、数据库账号权限配置、CI 扫描门禁以及我在真实项目里踩过的几个坑和排查路径。你不需要是安全专家但需要愿意把每一行拼接 SQL 的代码都过一遍脑子。1. 先搞清楚 Django 里 SQL 注入到底从哪儿钻进来1.1 ORM 不是万能保护层三类真实的注入面很多人对 Django 安全性的信心来自一句流传很广的话“QuerySet 使用参数化查询所以不会 SQL 注入。”这句话本身没错但它描述的是一个默认路径而现实项目里偏离默认路径的地方非常多。我把这些年见过的注入面归成三类你对照自己的代码库过一遍基本能覆盖八成风险。第一类是字符串拼接进原生 SQL。典型写法是cursor.execute(SELECT * FROM t WHERE name %s % name)或者更隐蔽的fWHERE id {user_id}。这类代码在业务逻辑简单的项目里少见但在“这个查询 ORM 写不出来我直接用 SQL 吧”的报表模块里几乎是标配。危险点在于它看起来很像参数化——那个%s长得太像占位符了评审时一眼扫过去容易放过。第二类是结构性参数被当成值参数。表名、列名、ORDER BY的字段、ASC/DESC方向、LIMIT的数字这些东西在 SQL 语法里属于标识符或关键字数据库驱动不会也不能给它们加引号转义。你写ORDER BY %s传created_at进去数据库收到的是一个字符串字面量排序直接失效为了“让它工作”开发者往往改成字符串拼接注入面就此打开。第三类是二次注入。用户输入先安全地存进数据库这一步 ORM 保护得很好然后在另一个功能里被读出来、拼进 SQL 再执行。这类问题最阴因为入库时的代码干干净净扫描工具也报不出来只有把数据流串起来看才能发现。注意判断一段代码是否安全不要看它“有没有用 ORM”要看用户可控的字符串最终有没有以拼接方式进入 SQL 文本。这两件事没有必然关系。1.2 参数化查询在 Django 里到底做了什么要理解防御边界得先知道参数化到底解决了什么问题。SQL 注入的本质是数据被解析成了代码数据库收到一整个 SQL 字符串它无法区分哪部分是开发者写的结构、哪部分是用户提供的值于是用户塞进去的单引号和--就被当成了语法。参数化的做法是把“结构”和“值”分两次发送。第一次发 SQL 模板里面用占位符标记位置数据库先完成语法解析、生成执行计划第二次单独发参数值数据库把它当作纯数据填进去不参与解析。哪怕参数里全是; DROP TABLE它也只是个长得奇怪的字符串。在 Django 里这一层由数据库驱动兜底。你写Model.objects.filter(namevalue)QuerySet 在编译阶段把value放进query.where的参数列表最终由cursor.execute(sql, params)交给驱动。Django 统一使用%s风格的占位符写 SQL具体的转换由后端适配PostgreSQL 走 psycopg 系列的$1风格MySQL 保持%sSQLite 转成?。你在 Django 代码里只需要记住一件事——%s是占位符不是 Python 的字符串格式化符号两者长得一样但语义完全不同混淆它们正是很多事故的起点。# 正确%s 交给驱动处理参数单独传 with connection.cursor() as cursor: cursor.execute( SELECT id, amount FROM shop_order WHERE user_id %s AND status %s, [user_id, status], ) # 错误Python 的 % 运算符先把值拼进字符串占位符语义彻底丢失 with connection.cursor() as cursor: cursor.execute( SELECT id, amount FROM shop_order WHERE user_id %s % user_id )上面两段代码在评审时经常被看成“差不多”实际安全性差了整整一个量级。第二段里如果user_id是1 OR 11整条 SQL 的语义就被改写了。我在做代码审计时有个简单粗暴的判断方法看到cursor.execute后面只有一根参数且 SQL 字符串里出现了%、、.format(、f直接标红。2. 从查询构造到执行Django 的 SQL 安全边界拆解2.1 QuerySet 编译链路里参数是在哪一步被隔离的搞清楚 QuerySet 从链式调用到最终 SQL 的流转能帮你在出问题时快速定位。大致链路是filter()等方法把条件累积成Q对象树Query对象持有where子树调用QuerySet求值时SQLCompiler遍历where树一边生成 SQL 片段一边把值追加到params列表。最终as_sql()返回一个二元组(sql_string, params_tuple)交给cursor.execute。关键点在于sql_string里永远只有%s用户的值全在params_tuple里。这意味着只要你走的是 ORM 的查询方法无论嵌套多深的Q对象、__in列表、__range区间值都不会泄漏进 SQL 文本。你可以用str(queryset.query)验证输出里看到的是带引号的字面量这是给人看的调试表示但实际执行时用的仍是参数化。qs Order.objects.filter(user_id1, status__in[paid, shipped]) print(qs.query) # SELECT ... WHERE (shop_order.user_id 1 AND shop_order.status IN (paid, shipped)) # 注意这行只是调试输出真实执行走的是参数化这个特性带来一个很实用的调试技巧当你想确认某个复杂查询是否安全时构造一个包含特殊字符的输入跑一遍看qs.query里它是被引号包住还是被当成语法。不过别把它当成安全测试的终点——qs.query的渲染逻辑和实际执行路径不完全一致。另一个容易忽略的细节是extra()的历史包袱。它在 Django 2.0 就被标记为不推荐但直到今天仍有大量老代码在用。extra(where[...])的where参数接受的是原始 SQL 片段列表如果你把用户输入格式化进去参数化保护直接失效。相对安全的写法是用where[amount %s]配合params[value]把值交给参数列表。问题是很多老代码只写了where忘了params那个%s就变成了悬空的占位符运行时报错然后开发者顺手改成拼接——这是我在三个项目里都见过的典型演化路径。2.2 raw()、extra()、RawSQL 的风险等级对比Django 提供了好几条“退回原生 SQL”的路风险差异很大我把它们按可控性排了个序你可以拿这张表当代码评审的速查依据。写法参数化支持典型风险建议Model.objects.filter(...)完全支持几乎无注入风险首选能用就用Model.objects.raw(sql, params)支持第二参数漏传params、%转义错误可用必须传参RawSQL(expr, params)支持第二参数用在annotate/order_by里易被忽略可用检查paramsextra(where[...], params[...])支持params老代码常漏params逐步替换掉extra(where[拼接字符串])不支持高危必须改cursor.execute(sql, params)支持漏传参数、%字面量未转义可用必须传参cursor.execute(拼接字符串)不支持极高危禁止connection.ops.quote_name(user_input)标识符引用不同后端行为差异、无法挡住恶意标识符语义配合白名单使用raw()有个常被忽略的坑它接受的是完整的 SQL 语句params通过第二位置参数传。如果 SQL 里存在用户输入但你没传paramsDjango 不会报错直接执行。所以我在团队规范里加了一条硬性要求——raw()必须显式写出params关键字参数哪怕传空列表这样在 code review 时一眼能看出这个调用有没有做参数化。RawSQL的风险在于它的使用位置。它常出现在annotate()、order_by()、filter()的表达式参数里这些位置本身就容易混入用户可控内容。比如annotate(totalRawSQL(user_supplied_expr, []))第二参数传了空列表表达式却是用户给的这等于把 SQL 片段执行权交出去了。用RawSQL的前提是表达式部分是你写死的常量字符串只有值通过params传。2.3 表名、列名、排序字段为什么“结构性参数”只能靠白名单这是我最想展开说的一点因为它是“参数化万能论”最大的盲区。前面说过参数化保护的是值而 SQL 语法里的标识符表名、列名和关键字ASC/DESC不是值。你没法给ORDER BY后面传一个参数数据库会把它当字符串常量处理排序失效。有些开发者会退而求其次用connection.ops.quote_name()给标识符加引号。这个函数确实能防止标识符被拆解成多个 token但它不是安全边界如果用户输入的是id; DROP TABLE加了引号也只是变成一个不存在的列名如果输入的是某个存在的敏感列名比如password_hash加引号反而让它正常工作了造成的是信息泄露而非语法错误。加引号解决的是“语法合法性”不是“权限控制”。真正有效的做法是白名单映射。前端传sortamount后端不直接把它拼进 SQL而是查一张字典字典里没有就回退到默认字段。字典的 key 是前端约定值value 是数据库里真实存在的列名全部由开发者写死。这样即使用户传sortpassword_hash字典里查不到直接走默认攻击面归零。# 白名单key 是前端可传值value 是数据库真实列名 ORDER_FIELD_MAP { created: created_at, amount: amount, name: name, } DIRECTION_MAP {asc: ASC, desc: DESC} def build_order_clause(raw_field, raw_direction, defaultcreated_at): field ORDER_FIELD_MAP.get(raw_field or , default) direction DIRECTION_MAP.get((raw_direction or asc).lower(), ASC) # 这里的 f-string 只拼接白名单常量不含任何用户原始输入 return f{field} {direction}这段代码里确实有 f-string 拼 SQL但它拼的两个变量都来自白名单字典的取值用户输入只参与了字典查找这一动作没有进入字符串。评审时如果看到类似的 f-string先确认被拼接的变量是不是来自白名单再判断安全性——不要一看到 f-string 就判死刑那样会把大量安全代码误伤反而让真正的风险淹没在噪音里。注意白名单字典的 value 必须是硬编码的列名字符串绝不能出现ORDER_FIELD_MAP[user_input]这种“用用户输入当 key 又当 value”的写法。另外如果模型表名需要动态指定比如分表同样要建表名白名单不要让用户决定查哪张表。3. 实操把防御措施一层层落到代码里3.1 数据库账号权限与日志最外层的第一道闸代码层防御做得再好也不该把数据库账号当成最后一道防线之外的东西。SQL 注入的危害程度很大程度上取决于注入点背后那个账号能干什么。如果你的应用账号是root或者拥有CREATE、DROP、FILE权限一个注入点就可能导致整库被拖走甚至服务器被写入文件。最小权限原则落地起来并不复杂。先给应用建一个独立账号只授予业务库上必要的SELECT、INSERT、UPDATE、DELETE撤销CREATE、ALTER、DROP关掉PUBLIC的建表权限。PostgreSQL 里的典型配置是这样CREATE ROLE app_rw LOGIN PASSWORD 这里换成强随机串; GRANT CONNECT ON DATABASE appdb TO app_rw; GRANT USAGE ON SCHEMA public TO app_rw; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_rw; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw; REVOKE CREATE ON SCHEMA public FROM PUBLIC;MySQL 那边写法不同但思路一致CREATE USER app_rw10.% IDENTIFIED BY 这里换成强随机串; GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO app_rw10.%; FLUSH PRIVILEGES;再进一步是读写账号拆分。报表、导出这类“只读但 SQL 复杂”的场景单独配一个只有SELECT的账号走独立的数据库连接别名。这样即使报表模块的某条原生 SQL 出了注入问题攻击者最多也只能读改不了数据。Django 的多数据库配置天然支持这件事DATABASES里加一个replica别名报表代码统一using(replica)配置成本很低收益很直接。日志这块排查阶段一定要把django.db.backends的日志打开。它会在 DEBUG 级别打印每条 SQL 和参数是定位问题的第一手材料。LOGGING { version: 1, disable_existing_loggers: False, handlers: { sql_console: {class: logging.StreamHandler}, }, loggers: { django.db.backends: { level: DEBUG, handlers: [sql_console], propagate: False, }, }, }注意这个配置只能用在本地和预发环境。生产环境打开会带来两个问题日志量暴涨以及参数里可能包含手机号、身份证号、令牌这类敏感信息直接落盘就是二次事故。生产上如果要留痕用数据库侧的审计日志或慢查询日志配合脱敏。3.2 原生 SQL 改写实操把拼接逐个换成参数化我拿一个真实改造过的场景来演示。需求是“按关键词、状态、时间区间筛选订单并做分页统计”。最初版本长这样# 改造前三处拼接全高危 def search_orders(keyword, status, start, end, page, size): where f11 if keyword: where f AND customer_name LIKE %{keyword}% if status: where f AND status {status} if start and end: where f AND created_at BETWEEN {start} AND {end} offset (page - 1) * size sql fSELECT id, customer_name, amount FROM shop_order WHERE {where} LIMIT {size} OFFSET {offset} with connection.cursor() as cursor: cursor.execute(sql) return cursor.fetchall()这段代码的问题不止一处。keyword、status、start/end三个位置都可注入size和offset虽然是数字但如果没有类型校验同样能塞进LIMIT 1; DROP ...部分驱动支持多语句。改造思路是把条件片段和值彻底分离片段里只有%s值统一进列表。# 改造后条件片段固定值全部参数化 def search_orders(keyword, status, start, end, page, size): clauses [] params [] if keyword: clauses.append(customer_name LIKE %s) params.append(f%{escape_like(keyword)}%) if status: clauses.append(status %s) params.append(status) if start and end: clauses.append(created_at BETWEEN %s AND %s) params.extend([start, end]) where_sql AND .join(clauses) if clauses else 11 size max(1, min(int(size), 200)) page max(1, int(page)) offset (page - 1) * size sql ( SELECT id, customer_name, amount FROM shop_order fWHERE {where_sql} ORDER BY created_at DESC LIMIT %s OFFSET %s ) with connection.cursor() as cursor: cursor.execute(sql, params [size, offset]) return cursor.fetchall()改造后的关键变化where_sql里只可能出现%s和自己的固定片段用户输入全部在params里LIMIT/OFFSET也走参数化同时对size做了上下限钳制防止有人传size1000000拖垮数据库。这里用到的escape_like是下面要讲的重点。LIKE 查询有个独有的坑%和_是 LIKE 的通配符用户输入里如果带这两个字符会被当成模式的一部分导致查询范围被意外放大。严格说这不是注入但它会造成“查询条件被用户控制”的语义偏差在权限过滤场景里可能变成越权读取。处理方式是转义后再拼通配符def escape_like(value: str) - str: if value is None: return return ( value.replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_) )SQL 里对应要加ESCAPE子句MySQL 默认反斜杠就是转义符PostgreSQL 需要显式声明LIKE %s ESCAPE \。如果是 ORM 的__containsDjango 会自动处理转义但你要清楚它处理的是“值里的通配符”不是“值里的 SQL 语法”——两者是不同层面的问题别混为一谈。批量查询是另一个高频出问题的地方。拼IN列表时很多人的第一反应是,.join(ids)这就把值拼进 SQL 了。正确做法是按列表长度生成对应数量的占位符值仍然走参数。def fetch_by_ids(ids): if not ids: return [] ids [int(i) for i in ids][:500] # 类型强校验 数量上限 placeholders , .join([%s] * len(ids)) sql fSELECT id, amount FROM shop_order WHERE id IN ({placeholders}) with connection.cursor() as cursor: cursor.execute(sql, ids) return cursor.fetchall()这里的 f-string 拼的是%s的个数值依然是参数化的。这个模式可以放心用前提是ids已经做过类型转换——int()会把非数字直接抛异常等于做了一次强校验。3.3 动态排序与分页把用户可控的“结构”关进白名单ORDER BY是原生 SQL 里最容易漏的一环因为它的参数在语法位置上就不接受占位符。我见过最离谱的写法是order_by(request.GET.get(sort, id))直接把请求参数丢给 ORM 的order_by。Django 对字段名确实有校验非法字段会抛FieldError但这个行为在不同版本间有过变化而且它挡不住-field这种方向前缀也挡不住关联字段的枚举探测。更稳妥的姿势是自己建白名单把前端传的 key 映射到确定的 ORM 字段表达式。from django.db.models import F, OrderBy SORTABLE { created: created_at, amount: amount, customer: customer_name, } def apply_sort(qs, raw_key, raw_dir): field SORTABLE.get(raw_key or , created_at) descending (raw_dir or ).lower() desc expr F(field) if descending: expr expr.desc(nulls_lastTrue) else: expr expr.asc(nulls_lastTrue) return qs.order_by(expr)用F()表达式配合asc()/desc()字段名来自白名单常量整个链路没有字符串拼接也不依赖 ORM 对字段名做安全校验。nulls_lastTrue是个实用细节PostgreSQL 默认把 NULL 排在最前翻页时会让空值挤占首页显式声明一下体验会好很多。分页参数同理。page和size看起来是数字但int(1 or 11)会抛异常所以先做强转换转换后用max/min钳制范围最后再进 SQL。别小看这个钳制size100000的请求在没做限制的系统里能轻易把数据库连接池打满这属于可用性层面的防御和注入防御是一套思路——凡是用户可控的、会进入 SQL 的东西都必须先校验再使用。3.4 输入校验层把脏数据挡在进入查询之前参数化解决了“数据变代码”的问题但没解决“数据本身是恶意的”这个问题。举个具体例子某个接口接收id参数用参数化查询WHERE id %s语法上绝对安全但如果id是字符串abc数据库会做隐式类型转换某些配置下会导致全表扫描如果id是超长字符串可能触发截断或性能问题。这类问题不会造成注入但会变成另一种攻击面。Django 生态里做这件事最顺手的工具是表单和 DRF 序列化器。它们的价值不只是“校验”更在于在数据进入业务逻辑前就完成类型收敛。我的习惯是用序列化器定义一个查询参数类把page、size、sort、keyword全部声明清楚IntegerField(min_value1, max_value200)这种约束直接写在字段上视图里只处理校验通过的数据。from rest_framework import serializers class OrderQuerySerializer(serializers.Serializer): keyword serializers.CharField(max_length64, requiredFalse, allow_blankTrue) status serializers.ChoiceField( choices[pending, paid, shipped, closed], requiredFalse ) sort serializers.ChoiceField( choiceslist(SORTABLE.keys()), requiredFalse, defaultcreated ) direction serializers.ChoiceField( choices[asc, desc], requiredFalse, defaultdesc ) page serializers.IntegerField(min_value1, default1) size serializers.IntegerField(min_value1, max_value200, default20)ChoiceField这个选择在这里特别值它把白名单校验前移到了输入层非法值直接返回 400连业务代码都不用进。status用ChoiceField而不是CharField是因为状态值本来就是有限枚举收窄输入范围等于消灭了一整类边界情况。注意序列化器校验通过不等于数据干净。keyword通过了max_length校验但里面仍可能带%、_、反斜杠这些对 LIKE 有特殊含义的字符所以 LIKE 场景还是要走escape_like。校验层和数据层的防护是叠加关系不是替代关系。3.5 上线前的自检用脚本和 CI 门禁兜住漏网之鱼人眼评审会疲劳规范文档会被遗忘最终能稳定兜底的还是自动化。我在项目里放了一个轻量的 AST 扫描脚本专门找三类模式cursor.execute的参数个数、raw()是否传了params、字符串里是否出现格式化符号。import ast import sys class SqlCallVisitor(ast.NodeVisitor): def __init__(self): self.hits [] def visit_Call(self, node): func node.func name if isinstance(func, ast.Attribute): name func.attr elif isinstance(func, ast.Name): name func.id if name execute and len(node.args) 2: self.hits.append((node.lineno, cursor.execute 缺少参数列表)) if name raw and not any(k.arg params for k in node.keywords): if len(node.args) 2: self.hits.append((node.lineno, raw() 未显式传 params)) self.generic_visit(node) def scan(path): with open(path, encodingutf-8) as f: tree ast.parse(f.read(), filenamepath) visitor SqlCallVisitor() visitor.visit(tree) return visitor.hits if __name__ __main__: problems [] for file_path in sys.argv[1:]: problems.extend((file_path, line, msg) for line, msg in scan(file_path)) for item in problems: print(%s:%s %s % item) sys.exit(1 if problems else 0)这个脚本很粗糙误报肯定有——比如execute在某些非数据库对象上也有同名方法。但它的定位是提醒而非判决命中后人工看一眼成本很低。配合 CI 跑一遍能把新增代码里的低级问题挡在合并之前。除了 AST我还会加一条纯文本的 grep 门禁扫extra(where和RawSQL(的出现次数超过阈值就要求评审说明理由。rg -n --type py extra\(\s*where\s* app/ || true rg -n --type py RawSQL\( app/ || true rg -n --type py cursor\.execute\([^,)]*\) app/ || true最后一条正则匹配的是“execute括号里没有逗号”的调用也就是只传了一个参数的情况正好对应漏传参数列表的模式。这种 grep 会有误报但在几十万行的老项目里做存量梳理特别有效一条命令就能把可疑位置全列出来。4. 常见问题与排查实录4.1 典型报错与现象速查表改参数化 SQL 的过程中报错信息往往比代码本身更能说明问题。我把遇到频率最高的几类整理成表遇到时可以直接对照。报错/现象根本原因处理方式not enough arguments for format stringSQL 里有字面量%如LIKE %abc%未转义字面量%写成%%或改用参数传值unsupported format character%后跟了非法字符常见于%Y-%m-%d这类日期格式同上%需要转义为%%TypeError: format string is not a string参数传成了单个值而非序列单个参数也要包成[value]或(value,)IndexError: tuple index out of range占位符数量和参数个数不一致数一遍%s个数与参数列表长度对齐ProgrammingError: column xxx does not exist标识符拼错或表名被当成了值表名列名走白名单不要用占位符查询结果为空但 SQL 看似正确ORDER BY %s被当成字符串常量ORDER BY 不能用占位符改白名单拼接单条查询突然变慢十几倍LIKE 通配符被用户输入放大转义%、_并限制关键词长度同一段代码本地正常、线上报错数据库后端不同占位符或 ESCAPE 语法有差异按后端适配或统一走 ORM表格里第 1、2 条是最常见的。很多人第一次写LIKE %关键词%时会习惯性把它当成 Python 格式化上下文结果 Django 的驱动把它当占位符解析直接抛异常。报错本身不可怕可怕的是有些人在报错后选择“绕过去”——把那行改成 f-string 拼接问题从报错变成了漏洞。4.2 三个踩坑案例的复盘第一个案例是后台导出。需求是“按用户选的字段排序导出 Excel”前端传sort_by后端那时图省事直接qs.order_by(request.GET[sort_by])。测试时用的是正常字段名一切正常。上线两周后有异常请求带了-password_hash过来order_by处理负号前缀把密码哈希列排了序——虽然响应里没有直接返回该字段但通过二分式的分页探测理论上可以推断出哈希值的大小关系。这类问题的修复成本很低加个白名单字典就完事但发现它的过程很被动靠的是日志里一条奇怪的慢查询。第二案例是报表模块的extra。一段两年前写的代码extra(where[fdate(created_at) {start}])注释还写着“这里不会有注入因为 start 是日期”。问题是start来自一个CharField没有做日期解析只有前端做了格式校验。我用start 2024-01-01 OR 11试了一下条件恒真整个报表导出了全量数据。修复方式是把f-string换成where[date(created_at) %s], params[start]同时在后端补上日期解析双重保险。第三个案例最隐蔽是二次注入。用户昵称字段在注册时通过 ORM 安全入库但在某个运营后台的“批量操作”里昵称被读出来拼进了cursor.execute的日志查询语句。攻击者把昵称设成x UNION SELECT ...--然后在运营后台触发了那条查询。这个漏洞之所以难发现是因为入库侧的代码完全干净扫描工具只看单点调用看不出跨函数的数据流。修复后我们定了一条规矩任何从数据库读出来、又要进 SQL 的字符串一律视为不可信输入和用户直接输入同等对待。4.3 从慢查询日志到 SQL 指纹一条可复用的排查路径发现疑似注入后我通常按这个顺序排查基本能在半小时内定位到具体代码。第一步看数据库侧的慢查询日志或审计日志找那些WHERE条件明显异常、LIKE后面跟着超长字符串、或者同一模板 SQL 的参数长度剧烈变化的记录。第二步把可疑 SQL 做参数占位归并也就是把具体的值替换成?得到“SQL 指纹”按指纹聚合统计异常指纹会立刻凸显出来。第三步是回到应用日志用django.db.backends打出的 SQL 和参数反查代码。这一步的关键是别只搜 SQL 片段要搜那个拼接动作——比如日志里看到LIKE %abc%就去代码里搜LIKE大概率能找到对应的 f-string。第四步用EXPLAIN确认影响范围看这条 SQL 是走了索引还是全表扫有没有触发子查询。还有一个辅助手段是给数据库开log_min_duration_statement把超过阈值的 SQL 全部记下来跑一周做一次指纹聚合。这套东西平时看不出价值真出事时能省掉大量猜测。我给团队做的一次演练里就是靠指纹聚合发现某个报表接口存在OR 11的恒真条件而这条 SQL 在应用日志里被正常的请求量淹没了。5. 纵深防御把单点防护变成体系5.1 读写账号分离与连接级隔离前面提过读写分离这里展开说落地细节。Django 的DATABASES支持配置多个别名每个别名可以指向不同的数据库账号。把报表、导出、数据看板这类“只读但 SQL 复杂”的模块统一指向只读别名代码层面用Model.objects.using(replica)或者给管理器加个db_manager。DATABASES { default: { ENGINE: django.db.backends.postgresql, NAME: appdb, USER: app_rw, PASSWORD: 从环境变量读取, HOST: 主库地址, OPTIONS: {connect_timeout: 5}, }, replica: { ENGINE: django.db.backends.postgresql, NAME: appdb, USER: app_ro, PASSWORD: 从环境变量读取, HOST: 只读实例地址, OPTIONS: {connect_timeout: 5}, }, }配置本身不难难点在于约束团队只用对别名。我的做法是在只读模块的基类里覆写db属性把using固化进去业务代码想改都改不了。另外给只读账号单独配一个连接池上限避免报表的慢查询把主库连接占满。这套隔离不能阻止注入但能把注入的后果从“数据被篡改”压缩到“数据被读取”量级差别很大。还有一个细节是数据库账号的HOST限制。PostgreSQL 的pg_hba.conf和 MySQL 的userhost都能限定来源网段只允许应用服务器所在网段连接。这条配置的收益是挡住“数据库端口意外暴露”这类问题属于基础设施层的兜底和代码层防御互补。5.2 监控告警与定期扫描的节奏最后说说长期维护。安全防护不是一次性任务代码在演进新人在加入去年的规范今年就没人看了。我目前维持的节奏是三件事每周一次静态扫描、每月一次存量审计、每季度一次演练。每周扫描跑前面那个 AST 脚本加 grep 门禁结果发到团队频道新增的高危模式直接挂到对应提交上。每月审计抽一个模块人工过一遍所有原生 SQL重点看extra、RawSQL、cursor.execute这三类调用确认参数化到位、白名单没有缺口。每季度演练是拿一个测试环境模拟一次“注入点被利用”看监控能不能发现、告警能不能触达、止损要多久。演练比任何文档都管用跑过一次之后团队对“参数化”这三个字的理解会明显不一样。告警这块我只配了两条规则够用且不吵一是同一 SQL 指纹在短时间内出现大量不同参数典型的注入探测特征二是单条 SQL 的执行时间超过阈值且参数长度异常。这两条规则的误报率不高维护成本也低比堆一堆花哨的规则实用得多。我个人在实际操作中的体会是SQL 注入防御这件事真正的难点从来不是“知不知道要参数化”而是在业务压力下当 ORM 写不出来、当报错挡住进度时能不能守住那条线。我见过太多案例最初只是一个f-string注释里写着“临时方案后续优化”然后就再也没人动过。所以比起记一堆规则更值得养成的习惯是每次写下cursor.execute手指在回车前停半秒确认那个参数列表真的存在。这个动作坚持半年基本就成肌肉记忆了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询