Gixy origins 插件实战:用静态分析揪出 Nginx 中 Referer/Origin 校验正则的安全缺陷
Gixy origins 插件实战:用静态分析揪出 Nginx 中 Referer/Origin 校验正则的安全缺陷
本文围绕 Gixy(Nginx 配置静态安全分析器)的 origins 插件展开,系统讲解它如何检测 Nginx 配置里针对 $http_referer、$http_origin 请求头编写的校验正则中的两类典型错误——正则本身书写有误、以及正则意外放行不受信任的第三方域名,并给出从手动排查、命令行检测到源码级原理、修复方案的完整路径。读完本文,你将掌握 --origins-domains、--origins-https-only 等关键选项的用法,理解插件底层的"正则生成 + 恶意域探测"检测算法,并能在自己的 Nginx 配置中写出既安全又可被 Gixy 放行的 Referer/Origin 校验规则。
一、背景:为什么 Referer/Origin 校验会成为一个安全问题
在 Nginx 配置中,用正则表达式校验请求头 Referer 或 Origin 是很常见的做法。典型场景有两个:
- ClickJacking 防护:根据
Referer判断请求是否来自自家站点,进而有条件地设置X-Frame-Options响应头; - CORS(Cross-Origin Resource Sharing):根据
Origin决定是否回显Access-Control-Allow-Origin等跨域响应头。
原文档 docs/ru/plugins/origins.md 明确指出,这类校验最容易出现两类错误:
- 正则表达式本身书写错误:锚点缺失、点号未转义、边界未约束等,导致正则实际匹配的范围远超预期;
- 放行了不受信任的第三方域名:正则通配过宽,攻击者可以伪造一个能通过校验的
Referer/Origin值,从而骗取X-Frame-Options放行或触发 CORS 数据回显。
这两类错误的危险后果是一致的:攻击者控制的"恶意来源"被当作可信来源处理。比如 CORS 场景下,Access-Control-Allow-Origin 一旦回显了攻击者构造的 Origin,配合 Access-Control-Allow-Credentials: true,就可能造成跨域窃取带凭证的数据。
需要特别强调的是 Gixy 的一个设计边界(原文档中的明确说明):默认情况下,Gixy 不会把"正则是否匹配了第三方域名"当作问题来报——因为它并不知道你的业务里哪些域名是可信的。要启用这项检查,需要显式告诉 Gixy 可信域列表:
gixy --origins-domains example.com,foo.bar /etc/nginx/nginx.conf
二、如何发现这类问题:手动排查与 Gixy 自动检测
2.1 手动排查思路
原文档给出了两条手把手的排查步骤:
- 找到所有
if指令,它们对$http_origin或$http_referer变量做了正则比较; - 逐一检查这些正则是否存在问题。
Gixy 的 origins 插件正是把这两步自动化了。它的插件类定义(gixy/plugins/origins.py)只关心 if 指令:
class origins(Plugin):
summary = 'Validation regex for "origin" or "referrer" matches untrusted domain.'
severity = gixy.severity.MEDIUM
description = 'Improve the regular expression to match only trusted referrers.'
directives = ['if']
插件通过 directives = <a href="https://link.gitcode.com/i/c214cd0e9522752b4cfc36235815db50" target="_blank">'if'] 声明只审计 if 指令,由 [gixy/core/plugins_manager.py 的 audit() 方法按指令类型分发调用。
2.2 Gixy 自动检测:快速上手
安装并运行(安装方式见 README.md):
pip install gixy
gixy /etc/nginx/nginx.conf
只运行 origins 插件(--tests 指定插件名):
gixy --tests origins /etc/nginx/nginx.conf
跳过 origins 插件(--skips):
gixy --skips origins /etc/nginx/nginx.conf
配合严重级别过滤(-l LOW、-ll MEDIUM、-lll HIGH,见 gixy/cli/main.py):
gixy -ll --tests origins /etc/nginx/nginx.conf
origins 插件的默认严重级别是 MEDIUM;但审计发现 $http_origin 相关问题时,报告中会提升为 HIGH(见下文"严重级别"小节)。测试配置 tests/plugins/simply/origins/config.json 中 "severity": ["MEDIUM", "HIGH"] 正是对这一行为的断言。
2.3 原文档中的"坏配置"示例
原文档给出的问题配置示例:
if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)$)) {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Credentials' 'true';
}
这个正则至少可以推断出以下几处隐患(后文源码分析会印证 Gixy 的探测原理):
- 第一个分支
^https://www\.yandex\.ru只锚定了开头、没有约束尾部:https://www.yandex.ru.evil.com/这类以evil.com结尾的域名同样能通过校验,Access-Control-Allow-Origin会回显攻击者的域名; ~*不区分大小写:HTTPS://www.yandex.ru等变体也会被接受;- 两个分支锚定不一致(分支二有
$尾锚,分支一没有),规则整体语义混乱。
原文档在该处留下了两个 TODO 占位("描述编写正则时的典型问题""Regex Ninja?"),尚未展开。下文将基于插件源码与仓库测试用例,把"典型正则问题"具体化。
三、典型正则缺陷清单(仓库测试用例逐条印证)
仓库的测试用例目录 tests/plugins/simply/origins/ 以"触发(有漏洞)/ 不触发(误报防护,_fp 后缀)"成对的方式,完整覆盖了最常见的几类正则缺陷,是学习"什么正则不合格"的最佳教材:
| 缺陷类型 | 触发用例(Gixy 应报警) | 误报用例(Gixy 不应报警) | 说明 |
|---|---|---|---|
点号未转义(. 匹配任意字符) |
origin.conf(yandex.ru)、referer.conf(yandex.ru)、structure_dot.conf(example.com)、referer_subdomain.conf(some.yandex) |
origin_fp.conf、referer_fp.conf、structure_fp.conf、referer_subdomain_fp.conf(均为 yandex\.ru 转义写法) |
未转义的点号可匹配任意字符,如 yandexXru 也会通过 |
缺少 ^ 开头锚定 |
structure_prefix.conf("https://example\.com/" 无 ^) |
structure_fp.conf("^https://example\.com/") |
无开头锚定时,攻击者可构造 http://evil.com/https://example.com/ 这类前缀 |
缺少尾部边界(/ 或 $) |
structure_suffix.conf("^https://example\.com" 无尾部边界)、origin_wo_slash.conf('^https?:\/\/yandex\.ru' 无尾斜杠) |
origin_w_slash_fp.conf、origin_w_slash_anchored_fp.conf(\/$ 完整锚定) |
无尾部边界时,https://example.com.evil.com/ 这类后缀可绕过校验 |
| 允许 http 明文来源 | origin_https.conf(配合 "https_only": true 选项,正则仍允许 http://) |
origin_https_fp.conf(配合 "https_only": true,正则仅允许 https://) |
选项与正则不一致时,http://yandex.ru/ 也会被当作合法来源 |
| 复杂通配正则在多域场景下的误报 | metrika.conf、webvisor.conf(.* 通配 + 多域分支) |
—— | 多域、带通配的正则需要配合 --origins-domains 显式声明可信域才能正确判定 |
其中 metrika.conf 是一个典型的生产级复杂正则:
if ($http_referer !~ "^https?://([^/]+metrika.*yandex\.(ru|ua|com|com\.tr|by|kz)|([^/]+\.)?webvisor\.com)/"){
add_header X-Frame-Options SAMEORIGIN;
}
而 webvisor.conf 的测试用例首行用 # Options: 注释声明了插件参数,展示了多可信域场景的正确用法:
# Options: {"domains": ["webvisor.com", "yandex.com"]}
if ($http_referer !~ "^https?://([^/]+\.)?yandex\.com/|([^/]+\.)?webvisor\.com/"){
add_header X-Frame-Options SAMEORIGIN;
}
测试框架 tests/plugins/test_simply.py 会解析 conf 首行的 # Options: {...} JSON 注释作为该用例的插件选项(parse_plugin_options),随后断言:非 _fp 用例必须恰好产生 1 条报告(check_configuration),_fp 用例必须产生 0 条报告(check_configuration_fp)。它还强制要求每个插件至少有一个触发用例和一个误报用例,从机制上保证了插件行为有据可查。
四、源码级原理:origins 插件如何探测"正则匹配了不受信任的域名"
4.1 插件选项:domains 与 https_only
gixy/plugins/origins.py 定义了插件的两个可配置选项:
options = {
'domains': ['*'],
'https_only': False
}
| 选项 | 默认值 | 含义 |
|---|---|---|
domains |
['*'] |
可信域列表;逗号分隔传入。'*' 表示未声明可信域,此时插件使用宽松的通用域名模式 |
https_only |
False |
为 True 时,仅把 https:// 来源视为可信(http:// 一律不可信) |
这些选项在 CLI 上会自动映射为 --origins-domains 与 --origins-https-only(参数生成规则见 gixy/cli/main.py:'--{plugin}-{key}',_ 替换为 -)。这正是原文档中 --origins-domains example.com,foo.bar 的来源。
4.2 "可信来源"参照正则(valid_re)的构造
插件在初始化时构造一个内部"可信来源"参照正则 valid_re(gixy/plugins/origins.py 第 27-39 行):
if self.config.get('domains') and self.config.get('domains')[0] and self.config.get('domains')[0] != '*':
domains = '|'.join(re.escape(d) for d in self.config.get('domains'))
else:
domains = r'[^/.]*\.[^/]{2,7}'
scheme = 'https{http}'.format(http=('?' if not self.config.get('https_only') else ''))
regex = r'^{scheme}://(?:[^/.]*\.){{0,10}}(?P<domain>{domains})(?::\d*)?(?:/|\?|$)'.format(
scheme=scheme,
domains=domains
)
关键设计:
- 未提供可信域时,
domains退化为通用域名模式[^/.]*\.[^/]{2,7},即"任意不含/和.的域名标签 + 点 + 2~7 个字符的顶级域"。这是一种宽松兜底,因此文档才强调默认不判定第三方域问题; - 提供可信域时,每个域都会经过
re.escape转义后以|连接,如--origins-domains yandex.ru会生成yandex\.ru; - 整体结构为:
^+ 可选https?协议(https_only=True时去掉?变成仅https)+://+ 最多 10 层子域(?:[^/.]*\.){0,10}+ 命名的domain捕获组 + 可选端口(?::\d*)?+ 尾部边界/、?或字符串结尾。
这个正则描述的是"一个规范的可信来源 URL"应有的形态:有协议、有明确的域、子域数量受限、尾部有边界。
4.3 审计流程:四步探测法
插件对每个 if 指令的 audit() 逻辑(gixy/plugins/origins.py 第 41-72 行)可以拆成四步:
第一步:确认目标指令。 只有操作符属于正则比较(~、~*、!~、!~*),且被比较的变量是 $http_referer 或 $http_origin 时才继续。这一步依赖 gixy/directives/block.py 中 IfBlock 对 if ($http_origin ~* ...) 的参数拆分(variable、operand、value 三段)。
第二步:反向生成"能匹配该正则的样本值"。 插件使用 gixy/core/regexp.py 的 Regexp 类对用户正则做语法解析,并调用 regexp.generate('/', anchored=True):以 / 为"危险字符",生成一组必然能匹配该正则的样例值,并尽可能把 / 塞进可以出现的位置(如 .*、[^...] 之外的通配处)。这正是该插件区别于简单字符串匹配的核心能力——它不需要猜测攻击载荷,而是从正则本身"反推"出它能接受的所有形态。
第三步:模拟攻击者的前缀/后缀注入。 对每个生成值做两次"放宽"(gixy/plugins/origins.py 第 52-61 行):
if value.startswith('^'):
value = value[1:] # 正则自带 ^,则去掉
else:
value = 'http://evil.com/' + value # 否则前缀 evil.com,模拟开头未锚定
if value.endswith('$'):
value = value[:-1] # 正则自带 $,则去掉
elif not value.endswith('/'):
value += '.evil.com' # 否则追加 .evil.com,模拟尾部未锚定
语义非常清晰:
- 如果正则没有
^开头锚定,攻击者就可以把http://evil.com/拼在前面让整串通过匹配; - 如果正则没有
/、$等尾部边界,攻击者就可以用可信域名.evil.com这种后缀域名通过匹配。
第四步:用 valid_re 判定。 将放宽后的样本值交给 valid_re 匹配(gixy/plugins/origins.py 第 63-65 行):
valid = self.valid_re.match(value)
if not valid or valid.group('domain') == 'evil.com':
invalid_referers.add(value)
只要出现两种情况之一即判定为问题:
valid_re匹配失败——说明该样本值不在可信来源形态内;- 匹配成功但
domain捕获组恰好是evil.com——这是针对默认宽松模式(domains=['*'])的专门兜底:默认模式下evil.com本身也符合通用域名模式,必须显式识别为攻击者域。
被判定为问题的样本值会聚合成报告原因:Regex matches "..." as a valid origin. 或 ... as a valid referrer.
4.4 严重级别:Origin 比 Referrer 更危险
同一套探测逻辑,插件对两类变量给出了不同严重级别(gixy/plugins/origins.py 第 70 行):
severity = gixy.severity.HIGH if directive.variable == '$http_origin' else gixy.severity.MEDIUM
$http_origin 相关缺陷判为 HIGH(CORS 场景通常伴随 Access-Control-Allow-Credentials,跨域数据泄露风险更高),$http_referer 相关缺陷判为 MEDIUM。严重级别枚举定义见 gixy/core/severity.py。
4.5 整条检测链路
把以上环节串起来,一次检测的完整调用链是:
- gixy/parser/nginx_parser.py 将 nginx.conf 解析为指令树(
IfBlock被建模为if块,参数被拆为变量/操作符/值); - gixy/core/plugins_manager.py 按
directives=['if']把if指令分发给origins插件; - 插件
audit()走完上述四步探测,把结果封装为 Issue 交给 gixy/core/issue.py; - 最终由 formatter(console/text/json,见 gixy/formatters/)输出报告。
五、命令行与配置文件完整用法
5.1 命令行选项
| 选项 | 示例 | 说明 |
|---|---|---|
--origins-domains |
--origins-domains example.com,foo.bar |
声明可信域列表(逗号分隔),激活第三方域匹配检查 |
--origins-https-only |
--origins-https-only |
只把 https:// 来源视为可信 |
完整示例:
# 声明 example.com 与 foo.bar 为可信域,仅检测 origins 插件
gixy --tests origins --origins-domains example.com,foo.bar /etc/nginx/nginx.conf
# 只信任 https 来源
gixy --origins-https-only /etc/nginx/nginx.conf
5.2 配置文件方式
Gixy 支持通过配置文件注入插件选项(解析器实现见 gixy/cli/argparser.py,默认配置文件为 /etc/gixy/gixy.cfg 与 ~/.config/gixy/gixy.conf)。插件选项在配置文件中以 [插件名] 段组织,键为去掉 -- 前缀的选项名,列表值用方括号包裹:
[origins]
domains = [example.com, foo.bar]
https-only = true
gixy --write-config 可以生成配置模板;命令行参数、配置文件、以 GIXY_ 为前缀的环境变量均被支持(见 gixy/cli/argparser.py 中 auto_env_var_prefix='GIXY_'),三者的优先级由 configargparse 保证命令行最高。
六、修复建议:把正则写对,或干脆不用正则
原文档的"怎么办"部分给出了三个层次的建议,这里结合源码与测试用例逐条展开:
6.1 修好你的正则
对照上文第三节的缺陷清单,一个"合格"的 Referer/Origin 校验正则应满足:
- 点号全部转义:
yandex\.ru而非yandex.ru; - 开头锚定:以
^开始(或配合!~反向匹配的语义正确使用); - 尾部有明确边界:以
/、$或端口段收尾,杜绝example.com.evil.com后缀; - 子域部分显式受控:如
(?:[^/]+\.)?限一层子域,避免.*通配放行任意子域; - 明确协议:仅接受
https://(或配合--origins-https-only收紧判定)。
作为示意(非仓库自带配置),一个兼顾以上要点且能被 Gixy 判定为合格的写法大致是:
if ($http_origin !~ '^https://(?:[^/]+\.)?example\.com(:\d+)?(?:/|$)') {
add_header X-Frame-Options SAMEORIGIN;
}
仓库中与之形态一致的"合格"用例可对照 origin_w_slash_anchored_fp.conf(^https?:\/\/yandex\.ru\/$)与 origin_fp.conf(^https?:\/\/yandex\.ru\/)。
6.2 Referer 场景:考虑 ngx_http_referer_module
原文档提示:如果校验的是 Referer 请求头,在满足条件(原文注"可能存在禁忌症")的前提下,可以考虑使用 Nginx 官方模块 ngx_http_referer_module(通过 valid_referers 指令声明可信来源,由 Nginx 自身完成匹配),从而完全绕开手写正则的复杂度。仓库中另有一个专门的 valid_referers 插件用于检查该指令自身的误配置,可以配套阅读。
6.3 Origin 场景:用 map 取代正则
原文档指出,校验 Origin 时往往更推荐用 map 而完全不写正则。map 的精确匹配天然规避了正则的锚点、转义与边界问题。示意写法:
map $http_origin $cors_origin {
default "";
"https://example.com" $http_origin;
"https://app.example.com" $http_origin;
}
随后在响应头设置中直接引用 $cors_origin 即可:可信来源回显原值,其余来源得到空值。Gixy 的 gixy/directives/block.py 对 map 块也有专门的建模(MapBlock),说明这类配置是分析器支持的常见形态。
七、验证你的修复:用测试用例当"标尺"
仓库的测试体系本身就是一套可复用的验证方法:
- 每个插件目录下的
*_fp.conf是"误报防护"用例——你的修复后配置如果与这类用例形态一致,就不会误报; - 每个插件目录下的非
_fpconf 是"必须触发"用例——你可以故意把坏正则喂给 Gixy,观察它是否如实报警; - tests/plugins/test_simply.py 的断言逻辑(
check_configuration期望恰好 1 条报告、check_configuration_fp期望 0 条)可以直接当作验收标准。
实践建议:修改线上 Nginx 配置前,先在本地用 gixy --tests origins <conf> 跑一遍,把输出中的 Reason: Regex matches "..." as a valid origin. 当作排查线索——其中列出的样本值就是攻击者实际可以利用的绕过载荷形态。
结语
origins 插件解决的是一个非常具体且高频的 Nginx 安全误配置:用正则校验 $http_referer/$http_origin 时,正则的锚定、转义与边界错误会把"校验"变成"摆设"。它的检测思路(反向生成匹配样本 + 模拟 evil.com 前缀/后缀注入 + 参照可信域正则判定)在 gixy/plugins/origins.py 中完整可读,全部行为都有 tests/plugins/simply/origins/ 下的成对用例背书。修复时记住三条主线即可:修正则(锚定 + 转义 + 边界)、Referer 场景考虑 valid_referers 指令、Origin 场景优先 map。