OmniRoute 上游代理 URL 校验加固:按地址而非拼写拦截 SSRF 目标
OmniRoute 允许为上游 AI Provider 流量配置代理,而代理 URL 本身就是攻击面:若校验逻辑只认地址的“写法”而不认它指向的“实际地址”,攻击者可以用 IPv4-mapped IPv6 等形式绕过内网与云元数据拦截。本文基于 changelog 修复记录 changelog.d/fixes/11319-upstream-proxy-host-spelling.md 与对应源码,拆解这次修复背后的 SSRF 校验原理、::ffff: 映射地址的解析细节,以及如何验证你的配置是否落在安全的拦截逻辑之内。
修复内容:一行 changelog 背后的安全语义
本次修复的原始记录只有一行(见 11319-upstream-proxy-host-spelling.md):
fix(db): the upstream proxy URL check judges the host by address instead of by spelling, so
http://[::ffff:169.254.169.254],[::ffff:10.0.0.5], ULA/link-local and CGNAT targets are refused like their dotted equivalents
翻译成工程语言,这条记录包含三层含义:
- 被加固的检查点:上游代理 URL 的合法性校验(
validateProxyUrl),位于 src/lib/db/upstreamProxy.ts。它负责拒绝指向私有/内部地址的代理 URL——这是防止 SSRF(Server-Side Request Forgery)的关键闸门。 - 修复前的缺陷模式:旧逻辑按“拼写”判断 host,即用前缀正则/字符串匹配,只认点分十进制写法(如
169.254.169.254)。同一地址的 IPv6 映射写法http://[::ffff:169.254.169.254]会穿过检查。 - 修复后的语义:校验先归一化再判定,“地址”本身决定放行与否,与它用什么记号写出来无关。ULA(
fc00::/7)、链路本地(fe80::/10)、CGNAT(100.64.0.0/10)等范围也与点分形式同等拦截。
背景:上游代理检查在 OmniRoute 中的位置
OmniRoute 的代理体系支持 HTTP / HTTPS / SOCKS5 代理,并按“账号级 → Provider 级 → Combo 级 → 全局级”的优先级解析,详见 Proxy Guide。代理 URL 会持久化到 SQLite 并驱动出站流量,因此任何“用户可控 URL 指向哪里”的检查点都必须假设输入是恶意的。
上游代理配置的持久化与校验入口在 src/lib/db/upstreamProxy.ts:
function isPrivateHost(hostname: string): boolean {
const normalized = normalizeHost(hostname);
const asIpv4 = mappedIpv4Host(normalized) ?? normalized;
if (LOOPBACK_HOSTNAMES.has(normalized) || LOOPBACK_HOSTNAMES.has(asIpv4)) return false;
return (
isCloudMetadataHost(normalized) || isPrivateNetworkHost(normalized) || isMulticastIpv4(asIpv4)
);
}
export function validateProxyUrl(
url: string
): { valid: true; url: string } | { valid: false; error: string } {
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
return { valid: false, error: `Unsupported protocol "${parsed.protocol}" — use http or https` };
}
if (isPrivateHost(parsed.hostname)) {
return { valid: false, error: `Proxy URL cannot point to private/internal address "${parsed.hostname}"` };
}
return { valid: true, url };
} catch {
return { valid: false, error: `Invalid URL: "${url}"` };
}
}
关键点在于:isPrivateHost 不再自带前缀正则,而是整体委托给共享网络守卫 src/shared/network/outboundUrlGuard.ts 与 src/shared/network/privateHost.ts 中的工具函数。源码注释(src/lib/db/upstreamProxy.ts#L54-L69)明确记录了动机:
This module used to carry its own prefix regexes, which matched only the dotted form:
http://169.254.169.254was refused whilehttp://[::ffff:169.254.169.254]— the same address, serialised by WHATWG URL as::ffff:a9fe:a9fe— was accepted, as were::ffff:10.0.0.5,fd00::/8,fe80::/10and CGNAT100.64.0.0/10.
原理一:为什么点分匹配会漏——WHATWG URL 的序列化行为
漏洞的根因不在网络层,而在 URL 解析层。当 Node.js(遵循 WHATWG URL 标准)解析 http://[::ffff:169.254.169.254] 时,parsed.hostname 返回的是小写十六进制 hextet 形式 ::ffff:a9fe:a9fe,而不是你输入的 ::ffff:169.254.169.254。也就是说:
- 输入
169.254.169.254→ hostname 为169.254.169.254,命中点分前缀检查,被拒; - 输入
[::ffff:169.254.169.254]→ hostname 为::ffff:a9fe:a9fe,点分前缀检查(startsWith("169.254."))完全失配,被放行。
两者在 TCP 层解析到同一个地址:IPv4-mapped IPv6 地址 ::ffff:A.B.C.D 的连接行为与 IPv4 地址 A.B.C.D 等价。因此任何基于字符串前缀的“私有地址判断”都是拼写敏感的(spelling-sensitive),必须先把地址还原到统一表示再判定。
src/shared/network/outboundUrlGuard.ts#L38-L54 正是干这件事:
// WHATWG URL serialises an IPv4-mapped IPv6 address as hextets, so
// `http://[::ffff:169.254.169.254]/` reaches these helpers as `::ffff:a9fe:a9fe`.
export function mappedIpv4Host(hostname: string): string | null {
const normalized = normalizeHost(hostname);
if (!normalized.startsWith("::ffff:")) return null;
const embedded = normalized.slice("::ffff:".length);
if (ipVersion(embedded) === 4) return embedded; // 已是点分形式
const hextets = embedded.split(":");
if (hextets.length !== 2) return null;
const [high, low] = hextets.map((part) =>
/^[0-9a-f]{1,4}$/.test(part) ? parseInt(part, 16) : Number.NaN
);
if (Number.isNaN(high) || Number.isNaN(low)) return null;
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; // 还原为 A.B.C.D
}
mappedIpv4Host 把 ::ffff:a9fe:a9fe 还原为 169.254.169.254,之后所有按 IPv4 区段的判定才能对“同一地址的不同写法”给出一致结论。这就是 changelog 里“judges the host by address instead of by spelling”的实现路径。
原理二:归一化与地址分类
判定前还有归一化一步,见 src/shared/network/privateHost.ts#L50-L56:
export function normalizeHost(hostname: string) {
const normalized = hostname.trim().toLowerCase();
if (normalized.startsWith("[") && normalized.endsWith("]")) {
return normalized.slice(1, -1);
}
return normalized;
}
去括号、转小写,保证 HTTP://[::FFFF:A9FE:A9FE] 与 ::ffff:a9fe:a9fe 是同一个判定输入。
分类函数 isPrivateHost(src/shared/network/privateHost.ts#L58-L103)覆盖的地址范围如下,这些也正是本次修复要“与点分形式同等拒绝”的完整清单:
| 类别 | 覆盖范围 | 判定方式 |
|---|---|---|
| 环回/保留 | 0.0.0.0、::、127.0.0.1、::1、localhost、*.localhost、*.local、*.internal |
精确匹配/后缀匹配 |
| 链路本地(含云元数据) | 169.254.0.0/16 |
区段判定 + 元数据主机名集合 |
| RFC 1918 私有 | 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、0.0.0.0/8 |
首/次八位组判定 |
| CGNAT | 100.64.0.0/10(changelog 明确点名) |
a === 100 && b >= 64 && b <= 127 |
| IPv6 ULA | fc00::/7(fc/fd 前缀) |
前缀判定 |
| IPv6 链路本地 | fe80::/10 |
fe80: 前缀 |
| IPv4-mapped IPv6 | 任意 ::ffff:x.x.x.x 写法 |
::ffff: 前缀 + mappedIpv4Host 还原后重判 |
云元数据这一类被单独提级处理。src/shared/network/outboundUrlGuard.ts#L56-L82 维护了一个明确的元数据主机名集合,并强调其拦截是无条件的:
const CLOUD_METADATA_HOSTNAMES = new Set([
"169.254.169.254", // AWS / GCP / Azure / Oracle IMDS
"metadata.google.internal", // GCP
"metadata.goog", // GCP
"100.100.100.200", // Alibaba Cloud
"fd00:ec2::254", // AWS IPv6 IMDS
]);
export function isCloudMetadataHost(hostname: string): boolean {
const host = normalizeHost(hostname);
if (!host) return false;
if (isCloudMetadataIpv4(host)) return true;
// An IPv4-mapped IPv6 literal routes to the embedded IPv4 address, so the same
// verdict has to apply to it — otherwise this block is spelling-sensitive.
const mapped = mappedIpv4Host(host);
return mapped !== null && isCloudMetadataIpv4(mapped);
}
注释里写得很直白:“the same verdict has to apply to it — otherwise this block is spelling-sensitive”。云元数据端点是经典的 SSRF → IAM 凭据窃取路径(拿到实例角色凭证),因此即便允许私有/LAN 地址的场景下也绝不放行。
一个刻意的例外:本地 CLIProxyAPI
注意 upstreamProxy.ts 中的环回豁免(src/lib/db/upstreamProxy.ts#L46-L74):
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
// ...
if (LOOPBACK_HOSTNAMES.has(normalized) || LOOPBACK_HOSTNAMES.has(asIpv4)) return false;
这是一个功能性白名单:OmniRoute 的 cliproxyapi 回退后端运行在本机 localhost:8317,上游代理配置需要能指向它。源码注释(src/lib/db/upstreamProxy.ts#L66-L68 同文件 L66-L68)说明这条豁免同样延伸到其“映射拼写”——::ffff:7f00:1(即 127.0.0.1)也要被放行,理由依然是“按地址而非拼写”。这体现了该修复的一致性原则:判定必须对同一地址的所有合法序列化给出同一结论,无论结论是拒绝还是放行。
此外该模块还保留了自己的 IPv4 多播规则(224.0.0.0/4,见 isMulticastIpv4),并与共享守卫组合:先 normalizeHost 归一化,再 mappedIpv4Host 还原,最后依次过云元数据、私有网络、多播三道判定。
测试证据:点分与映射写法必须同判
单元测试 tests/unit/db-upstreamProxy.test.ts 覆盖了这个边界。例如 tests/unit/db-upstreamProxy.test.ts#L381 断言点分形式的元数据地址会被拒绝:
expect(upstreamProxyDb.validateProxyUrl("http://169.254.169.254").error, ...);
结合 changelog 的修复范围可以确认:测试矩阵中点分写法与 ::ffff: 映射写法属于同一断言组——同一地址两种拼写,validateProxyUrl 必须返回同样的“拒绝”,这正是“by address instead of by spelling”的可测试表述。
设计层面的一致性:共享守卫防止多处漂移
从源码结构看,这次修复还完成了一次逻辑收敛:upstreamProxy.ts 原先自带一套前缀正则,修复后改为引用共享的 outboundUrlGuard / privateHost 工具(文件头部的 import 见 src/lib/db/upstreamProxy.ts#L3-L8)。注释说明共享守卫此前已在 #10843 中修过同一类问题,而“routing this copy through the same helpers keeps the two from drifting apart again”——即把两处各自维护的判断收敛到同一实现,避免一边修好、另一边再次漂移出拼写敏感缺陷。
同样的守卫还被用于 Provider 出站 URL 校验(parseAndValidatePublicUrl / parseAndValidateNonMetadataUrl,见 src/shared/network/outboundUrlGuard.ts#L114-L146)以及 providerRegistry 等路径,属于全局性出站防护而非单点补丁。
对使用者的实际影响与自检建议
- 配置被拒是预期行为:如果你在保存上游代理配置(对应 Proxy Guide 中的代理设置)时遇到
Proxy URL cannot point to private/internal address "..."错误,说明目标地址落在私有/链路本地/CGNAT/云元数据范围内,属于防护生效而非故障。 - 只有点分形式被拒、换 IPv6 写法就通过:这是拼写敏感缺陷的典型信号,应以“同一地址任意写法结论一致”为标准复核拦截逻辑。
- 需要指向本机服务(如 CLIProxyAPI):仅环回地址(
127.0.0.1/::1/localhost及其映射写法)是设计内的放行项,其余内网地址(含 CGNAT100.64.0.0/10、ULAfd00::/8)一律拒绝。 - 判定顺序不可跳过:
normalizeHost→mappedIpv4Host还原 → 环回豁免 → 元数据/私有/多播判定,任何一步缺失都会重新引入拼写敏感旁路;其中ipVersion采用与 NodeisIP逐位对等的纯 JS 实现(见 src/shared/network/privateHost.ts#L15-L48),避免更窄的匹配把私有地址误判为公网。
小结
这次 #11319 修复表面上只是让上游代理 URL 校验多拒绝了几个“长得像公网”的地址,实质确立了一条可复用的防御原则:安全判定必须作用在规范化后的地址语义上,而不是输入字符串的表面形态。IPv4-mapped IPv6、十六进制 hextet 序列化、CGNAT 与 ULA 前缀,都只是同一原则下的具体实例。对于任何接受用户 URL 的服务端,mappedIpv4Host 这类“先还原、再判定”的归一化步骤应当成为 SSRF 防护的默认组成。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00