Gixy origins 插件实战:用静态分析揪出 Nginx 中 Referer/Origin 校验正则的安全缺陷

原创2026-09-24 18:44:501,978 阅读
文章标签:静态分析应用安全

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 明确指出,这类校验最容易出现两类错误:

  1. 正则表达式本身书写错误:锚点缺失、点号未转义、边界未约束等,导致正则实际匹配的范围远超预期;
  2. 放行了不受信任的第三方域名:正则通配过宽,攻击者可以伪造一个能通过校验的 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 手动排查思路

原文档给出了两条手把手的排查步骤:

  1. 找到所有 if 指令,它们对 $http_origin 或 $http_referer 变量做了正则比较;
  2. 逐一检查这些正则是否存在问题。

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 整条检测链路

把以上环节串起来,一次检测的完整调用链是:

  1. gixy/parser/nginx_parser.py 将 nginx.conf 解析为指令树(IfBlock 被建模为 if 块,参数被拆为变量/操作符/值);
  2. gixy/core/plugins_manager.py 按 directives=['if'] 把 if 指令分发给 origins 插件;
  3. 插件 audit() 走完上述四步探测,把结果封装为 Issue 交给 gixy/core/issue.py;
  4. 最终由 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 是"误报防护"用例——你的修复后配置如果与这类用例形态一致,就不会误报;
  • 每个插件目录下的非 _fp conf 是"必须触发"用例——你可以故意把坏正则喂给 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。

登录后查看全文
gixy