Anubis 反向代理的分号查询参数兼容:gitweb 烟雾测试与 Rewrite 模式迁移实战
【免费下载链接】anubisWeighs the soul of incoming HTTP requests to stop AI crawlers项目地址: https://gitcode.com/gh_mirrors/anubis4/anubis
导读
本篇技术指南围绕仓库中 test/gitweb/README.md 所记录的 smoke test 展开,核心主题是:Anubis 在 v1.26.0 从 Go 标准库httputil.ReverseProxy.Director(已废弃)迁移到Rewrite模式后,如何保证以分号;分隔 URL 查询参数的旧式 Web 应用(典型如 gitweb 的/?p=repo.git;a=summary)仍然能够正常反代。读完本文,你将理解Rewrite模式重编码查询串的底层行为、它为何会静默丢弃;参数,以及 Anubis 通过端到端烟雾测试与源码级修复双重手段守住这一兼容性的完整实践。
背景:gitweb 与它的世纪之交遗留行为
gitweb 是 Git 官方生态中的 Perl CGI 脚本,为浏览器提供 git 仓库的 Web 界面。它诞生于世纪之交("a CGI script from before the turn of the century"),因此带着一个独特的遗留行为:使用分号;作为 URL 查询参数分隔符,例如请求一个仓库的摘要页时使用:
/?p=testing.git;a=summary在标准 HTTP 实践中,查询参数的分隔符是&(如?a=1&b=2),但早期 CGI 应用常以;分隔。这个行为在黑客圈子里还有一种特殊地位:它曾是 WAF 绕过技术——一些配置不佳的 Web 应用会接受分号分隔参数,而 WAF 只检查&分隔符,导致规则失明。gitweb 正是这类"按分号解析参数"的古老应用之一。
无论历史成因如何,一个反代在转发请求到 gitweb 时,必须原样保留请求行中的原始查询串(raw query),否则 gitweb 就读不到p=testing.git这个参数。
问题根源:Director 到 Rewrite 的迁移
Anubis 在 v1.26.0 时将目标上游反向代理从已废弃的httputil.ReverseProxy.Director迁移到了Rewrite。这一迁移的背景是 Go 1.26 兼容性与 API 现代化(见 docs/docs/CHANGELOG.md 中 v1.26.0 的变更记录)。
Go 官方对Rewrite的定位是"在正确性和遵循 HTTP 实践方面更激进"——它严格按照今天 HTTP 世界的规范处理请求,而不是迁就多年前的旧行为。其中一项激进行为正是:Rewrite 模式在构造出站请求时会用url.ParseQuery重新编码查询字符串,从而静默丢弃以;分隔的参数。
后果是灾难性的:当 Anubis 把/?p=testing.git;a=summary转发给 gitweb 时,如果参数被丢弃,gitweb 将收不到p=testing.git,于是回退到根路径/渲染项目列表页。用户看到的是一个"摘要页"实为"首页"的错误响应——请求悄悄失败了。
这个问题被记录为 docs/docs/CHANGELOG.md 中 v1.26.1 的修复项:
修复从
ReverseProxy.Director(已废弃)迁移到ReverseProxy.Rewrite时丢失的分号分隔查询参数支持,重新启用对 gitweb 这类上游的支持,并添加功能测试防止复发。
烟雾测试设计:如何证明分号参数没被丢掉
防复发的手段就是 test/gitweb/README.md 所描述的这套烟雾测试。它的聪明之处在于利用 gitweb 的失败模式本身作为断言依据:
- 如果 Anubis 丢掉了分号参数,gitweb 的
summary响应会逐字节等同于首页/的响应(因为两者都回退到项目列表渲染); - 因此测试同时断言两件事:
/?p=testing.git;a=summary返回的正文不等于首页正文——证明参数确实到达了 gitweb;- 摘要页正文中包含指向
/?p=testing.git;a=commit的链接片段——证明 gitweb 真的渲染了该仓库,而不是兜底页面。
具体实现见 test/gitweb/test.mjs:它以固定 UA(Mozilla/5.0 (compatible; AnubisGitwebSmoke/1.0))请求http://localhost:8005下的首页与摘要页,逐个断言状态码为 200、正文互不相同、且包含期望的 commit 链接片段,任一断言失败即process.exit(1)。测试通过redirect: "manual"防止自动跟随重定向,确保挑战流程之外的响应语义得到严格检查。
测试环境搭建:docker-compose 三服务编排
烟雾测试跑在一个 test/gitweb/docker-compose.yaml 定义的本地环境里,包含三个服务:
| 服务 | 镜像 | 作用 |
|---|---|---|
anubis | ko.local/anubis | 被测反代,监听:8005,目标指向http://gitweb:80 |
repo | jgiannuzzi/gitolite | gitolite 仓库托管,通过共享卷repo-data向 gitweb 暴露 git 裸仓库 |
gitweb | mlan/gitweb | 真实 gitweb 上游,depends_on: repo,以只读方式挂载仓库卷 |
Anubis 侧的关键环境变量:
BIND: ":8005":绑定端口,与 test.mjs 中的BASE一致;TARGET: http://gitweb:80:上游目标,测试中即 gitweb;USE_REMOTE_ADDRESS: "true":信任真实远端地址(本地测试场景);POLICY_FNAME: /etc/techaro/anubis.yaml:策略文件挂载路径。
策略文件 test/gitweb/anubis.yaml 极简但完整,定义了唯一一条规则:
bots: - name: challenge user_agent_regex: CHALLENGE action: CHALLENGE status_codes: CHALLENGE: 200 DENY: 403user_agent_regex: CHALLENGE是 Anubis 的"无匹配默认动作"语法:所有不匹配任何特定规则的请求都落入CHALLENGE动作(此处在策略模型中默认对陌生 UA 执行挑战),而测试 UA 不携带任何已被放行的 cookie,从而可以验证"挑战通过前的请求仍能被正确反代到上游"这一语义。status_codes段则显式声明了 CHALLENGE 响应应返回 200、DENY 返回 403,保证测试脚本对状态码的断言与策略语义一致。
测试执行流程:test.sh 与指数退避重试
test/gitweb/test.sh 是整个测试的驱动器,流程为:
- 设置
VERSION与KO_DOCKER_REPO=ko.local,source ../lib/lib.sh; - 调用
build_anubis_ko用 ko 构建 Anubis 容器镜像(该函数定义在 test/lib/lib.sh,会先npm ci && npm run assets生成 Web 资产,再ko build ./cmd/anubis --local); docker compose up -d拉起三服务;backoff-retry node ./test.mjs运行烟雾测试。
最后一步的backoff-retry是仓库自带的工具 utils/cmd/backoff-retry/main.go:默认从 250ms 起步、最多重试 5 次、每次失败等待时间翻倍,用于容忍容器启动阶段的短暂竞态。整个lib.sh还通过trap cleanup EXIT SIGINT保证测试结束后清理 compose 资源。
源码深处的修复:makeReverseProxy 的原始查询串还原
测试所守护的修复实现位于 cmd/anubis/main.go 的makeReverseProxy(L140-L216)。该函数手工构造httputil.ReverseProxy并自定义Rewrite回调,源码注释完整记载了修复动机:
Rewrite 模式会通过
url.ParseQuery重新编码出站查询,从而静默丢弃以;分隔的参数。某些上游(典型如 gitweb 的/?p=repo.git;a=summary)使用;作为查询分隔符,因此这里将客户端的原始查询串逐字还原,以匹配此前NewSingleHostReverseProxy的行为。这修复了 issue #1763。
核心逻辑是:
if tq := targetUri.RawQuery; tq == "" || r.In.URL.RawQuery == "" { r.Out.URL.RawQuery = tq + r.In.URL.RawQuery } else { r.Out.URL.RawQuery = tq + "&" + r.In.URL.RawQuery }即:目标 URL 自带的查询串与客户端原始查询串按需以&拼接,且客户端部分逐字保留(r.In.URL.RawQuery不被解析重编码),从根上规避了ParseQuery丢弃分号参数的问题。
同一回调中还包含两个与迁移配套的细节:
- 保留入站 Host:
r.Out.Host = r.In.Host。注释说明SetURL会清空Out.Host,此处恢复入站 Host 以匹配旧NewSingleHostReverseProxy的默认行为(除非显式设置了targetHost); - 透传转发头:Rewrite 模式在回调执行前会剥离转发头,而 Anubis 在上游由 internal/headers.go 的
XForwardedForUpdate等中间件负责设置Forwarded、X-Forwarded-For、X-Forwarded-Host、X-Forwarded-Proto,因此回调中将这些头从入站请求拷贝到出站请求,保证目标仍能看到完整的转发信息。
单元测试:从源头锁定行为
除端到端烟雾测试外,同层修复还有单元测试把关:cmd/anubis/main_test.go 的TestMakeReverseProxy用httptest起了一个回显服务器,向代理发起请求并断言上游收到的Host、路径、查询串与转发头。其中专门有一个用例:
name: "semicolon-delimited query is preserved", reqPath: "/?p=testing.git;a=summary",该用例直接断言上游收到的RawQuery与客户端原始请求一致——它把"分号参数不被丢弃"从端到端场景下沉为可独立运行的代理层契约,任何未来对Rewrite回调的改动一旦破坏此行为,单测会立即失败。
从测试到生产:给你的反代场景带来的启示
这套测试对自建反代或网关的维护者有直接借鉴意义:
- 升级 Go 标准库反代 API 时,行为差异清单要提前盘点:
Director与Rewrite在查询串、Host、转发头处理上的差异是真实存在的坑,迁移前应像 Anubis 一样用单测锁定每一条语义; - 针对遗留上游设计"失败特征断言":gitweb 的"丢参数即回退首页"特征让测试可以仅凭正文比较就做出判定,无需 mock 内部实现——这是端到端测试中非常实用的技巧;
- 双层防线:单元测试锁源码行为(cmd/anubis/main_test.go),烟雾测试验证真实上游链路(test/gitweb/test.mjs),修复 #1763 正是依靠这一组合防止回归。
如果你正在部署 Anubis 并需要为 gitweb、或任何使用;分隔查询参数的旧式 CGI 应用做反代,本仓库的修复与测试就是现成的兼容性保障:从 cmd/anubis/main.go 的makeReverseProxy可以看到如何逐字透传原始查询串,从 test/gitweb/ 的整套测试可以看到如何持续验证这一行为不再复发。
【免费下载链接】anubisWeighs the soul of incoming HTTP requests to stop AI crawlers项目地址: https://gitcode.com/gh_mirrors/anubis4/anubis
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考