在Django项目里,全局请求参数清洗这件事,真正落地的核心从来不是写个中间件就完事了,而是你如何设计一个既不过度影响性能、又能覆盖绝大多数注入攻击面的清洗链路。很多人上来就写一个中间件,把request.GET和request.POST遍历一遍,用正则替换掉危险字符,这种做法在低并发场景看着没问题,一旦请求量上来,每次请求都全量遍历和正则匹配的开销会直接拉高响应延迟。更合理的做法是分层清洗:先做类型约束,再做模式过滤,最后才上规则引擎。

请求参数注入的入口比你想象的多

别只盯着GET和POST参数。Django的request对象里,能携带用户输入的地方至少有五处:request.GET、request.POST、request.COOKIES、request.META里的HTTP头,以及URL路径中的命名参数。攻击者完全可以在User-Agent、Referer甚至自定义HTTP头里塞入SQL片段或XSS向量,如果你的中间件只处理查询参数,那等于把大门敞开了一半。全局清洗必须覆盖request.META中以HTTP_开头的所有键,同时处理Cookie值,因为Session ID以外的自定义Cookie经常被业务代码直接读进数据库查询。

先做类型约束,再谈清洗

很多注入漏洞的根源不是没过滤,而是参数类型被意外转换。比如一个本应是整数的id参数,业务代码里直接用字符串拼接进SQL,这时候即便你过滤了单引号,攻击者仍然可以通过数值型注入绕过。在中间件层面,如果你能拿到参数的预期类型定义,就先把参数强制转换:整数参数用int()转换失败直接拦截,浮点数同理,布尔值只接受明确的true/false或1/0。对于没有类型定义的通用清洗,至少要做一层判断——如果参数值看起来完全由数字组成,就不要走字符串过滤逻辑,直接放行,减少不必要的正则开销。

清洗规则要分层,别一把梭

把SQL注入防护和XSS防护混在同一套正则里是常见的错误。SQL注入关注的是单引号、双引号、反斜杠、注释符、UNION关键字等;XSS关注的是尖括号、script标签、事件处理器、javascript伪协议。这两类攻击的特征完全不同,混在一起不仅正则效率低,还容易产生误杀。建议在中间件里维护两个独立的规则链:SQL注入规则链和XSS规则链,每个链内部按优先级排序,命中即停止。对于文件上传场景,还要单独处理文件名参数,防止路径穿越和命令注入。

中间件的实现骨架

下面给出一个生产可用的中间件实现思路,核心是请求入口统一清洗、响应出口不做处理,避免二次编码问题。中间件在process_request阶段拦截,对request.GET、request.POST、request.COOKIES、request.META进行遍历清洗。注意request.GET是QueryDict,它是不可变的,需要先拷贝再赋值。

import re
import copy
from django.utils.deprecation import MiddlewareMixin
from django.http import HttpResponseForbidden

class GlobalParamCleanMiddleware(MiddlewareMixin):
    # SQL注入特征规则,按危险程度排序
    SQL_RULES = [
        (re.compile(r"(?i)(\bUNION\b.*\bSELECT\b)"), "SQL_UNION"),
        (re.compile(r"(?i)(\bSELECT\b.*\bFROM\b)"), "SQL_SELECT"),
        (re.compile(r"(\bOR\b\s+\d+\s*=\s*\d+)"), "SQL_OR_INJECT"),
        (re.compile(r"(--[^\n]*$)"), "SQL_COMMENT"),
        (re.compile(r"(;\s*(DROP|DELETE|UPDATE|INSERT)\b)"), "SQL_DANGER"),
        (re.compile(r"('|\")\s*(OR|AND)\s*('|\")"), "SQL_QUOTE"),
    ]
    # XSS特征规则
    XSS_RULES = [
        (re.compile(r"(?i)(]*>)"), "XSS_SCRIPT"),
        (re.compile(r"(?i)(javascript\s*:)"), "XSS_JS"),
        (re.compile(r"(?i)(on\w+\s*=\s*[\"'])"), "XSS_EVENT"),
        (re.compile(r"(?i)(]*>)"), "XSS_IFRAME"),
    ]
    
    def process_request(self, request):
        # 清洗GET参数
        if request.GET:
            request.GET = self._clean_querydict(request.GET)
        # 清洗POST参数
        if request.POST:
            request.POST = self._clean_querydict(request.POST)
        # 清洗Cookie
        if request.COOKIES:
            request.COOKIES = self._clean_dict(request.COOKIES)
        # 清洗HTTP头
        if request.META:
            self._clean_meta(request)
    
    def _clean_querydict(self, qd):
        cleaned = {}
        for key, values in qd.lists():
            safe_key = self._clean_value(key)
            safe_values = [self._clean_value(v) for v in values]
            cleaned[safe_key] = safe_values
        from django.http import QueryDict
        new_qd = QueryDict(mutable=True)
        for k, v_list in cleaned.items():
            for v in v_list:
                new_qd.appendlist(k, v)
        new_qd._mutable = False
        return new_qd
    
    def _clean_dict(self, d):
        return {self._clean_value(k): self._clean_value(v) for k, v in d.items()}
    
    def _clean_meta(self, request):
        for key in list(request.META.keys()):
            if key.startswith('HTTP_'):
                request.META[key] = self._clean_value(request.META[key])
    
    def _clean_value(self, value):
        if not isinstance(value, str):
            return value
        # 先检查SQL注入规则
        for pattern, rule_name in self.SQL_RULES:
            if pattern.search(value):
                # 记录日志或直接拦截,这里选择拦截
                from django.core.exceptions import PermissionDenied
                raise PermissionDenied(f"Malicious request detected: {rule_name}")
        # 再检查XSS规则
        for pattern, rule_name in self.XSS_RULES:
            if pattern.search(value):
                raise PermissionDenied(f"XSS detected: {rule_name}")
        return value

上面这段代码有几个关键设计点。第一,规则用编译好的正则对象存储,避免每次请求重复编译。第二,SQL规则按危险程度排序,UNION SELECT这种高确定性攻击放前面,命中后直接抛异常,减少后续匹配。第三,对QueryDict的处理保留了多值特性,用lists()方法遍历,不会丢失同名参数的多值情况。第四,清洗失败直接抛出PermissionDenied异常,Django会返回403,比静默替换参数值更安全,因为静默替换可能改变业务逻辑,而直接拦截能让攻击者无法探测清洗规则。

白名单路径豁免机制

不是所有请求都需要全量清洗。比如管理后台的富文本编辑器提交的HTML内容,本身就包含合法的标签和事件属性,如果走XSS规则链必然被误杀。你需要在中间件里维护一个豁免路径列表,支持精确匹配和前缀匹配。更精细的做法是在视图层面加装饰器标记,中间件读取视图的标记属性来决定是否跳过清洗。Django的CBV可以通过as_view()传参或者在类属性上设置标记,中间件用request.resolver_match.func获取视图函数后,用getattr检查标记。

性能优化的三个关键点

第一,对请求体大小做上限检查。如果POST数据超过一定大小(比如1MB),先截断或直接拒绝,避免大文件上传或恶意大payload消耗CPU做正则匹配。第二,对重复请求做缓存。如果同一个Session在短时间内发送完全相同的参数组合,可以缓存清洗结果,但要注意缓存键必须包含参数原始值的哈希,且缓存时间极短(秒级),否则可能被利用做缓存投毒。第三,用C扩展或预编译模式。Python的re模块在匹配复杂正则时性能一般,可以考虑把核心规则用re2或 hyperscan 这类高性能正则引擎实现,或者把规则编译成DFA一次加载。

不要忽视JSON请求体

现在大量API请求的Content-Type是application/json,Django默认不会把JSON body解析到request.POST里,而是需要从request.body手动解析。如果你的中间件只处理request.POST,那JSON参数就完全绕过了清洗。正确的做法是在中间件里检查Content-Type,如果是JSON,先解析再递归清洗所有键值,然后重新序列化放回request.body。但这里有个陷阱:修改request.body后,后续的Django REST framework等框架可能因为body已被读取而无法再次解析。解决办法是用一个自定义的request属性存储清洗后的JSON字典,业务代码统一从这个属性取值,或者在中间件里替换request的_stream为BytesIO重新封装。

日志和监控不可或缺

清洗中间件拦截到的恶意请求,必须完整记录原始参数、来源IP、时间戳、命中的规则名称。这些日志不仅是安全审计的依据,更是调整规则的反馈数据。如果你发现某个规则误杀率过高,就需要细化或放宽;如果某个新攻击手法频繁出现,就需要增加规则。建议把拦截日志输出到独立的文件或日志收集系统,和业务日志分离,方便做告警和趋势分析。同时,对拦截率做监控,突然的飙升可能意味着正在遭受扫描攻击。

和ORM防护的关系

有人会问,Django ORM本身就有参数化查询,为什么还要在中间件做SQL注入清洗?因为现实项目里不可避免地存在原生SQL、extra()、RawSQL表达式,甚至有些老旧代码直接拼接字符串。中间件的清洗是纵深防御的一环,它不能替代ORM的正确使用,但能在开发人员疏忽时提供一层兜底。同样,模板引擎的自动转义能防XSS,但如果有人用mark_safe或者在前端用innerHTML渲染后端返回的数据,中间件的XSS清洗就能起到最后一道防线的作用。

规则维护的持续迭代

安全规则不是一劳永逸的。新的绕过手法不断出现,比如用反引号替代单引号、用十六进制编码绕过关键字检测、用注释分割关键词。你的规则库需要定期更新,可以参考OWASP的ModSecurity核心规则集,把经过验证的通用规则移植过来。同时,针对业务特有的参数格式做定制规则,比如某个参数只允许字母数字和下划线,那就直接加白名单校验,比黑名单过滤可靠得多。

全局请求参数清洗这件事,说到底是一个权衡:安全性和性能的权衡、通用性和误杀率的权衡、实时拦截和业务连续性的权衡。没有完美的方案,只有根据实际业务场景持续调优的实践。把中间件搭好只是第一步,持续的日志观察、规则迭代、性能监控才是让这套机制真正生效的关键。