首页
/ OmniRoute 上游代理 URL 校验加固:按地址而非拼写拦截 SSRF 目标

OmniRoute 上游代理 URL 校验加固:按地址而非拼写拦截 SSRF 目标

2026-09-06 12:29:37作者:卓艾滢Kingsley

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

翻译成工程语言,这条记录包含三层含义:

  1. 被加固的检查点:上游代理 URL 的合法性校验(validateProxyUrl),位于 src/lib/db/upstreamProxy.ts。它负责拒绝指向私有/内部地址的代理 URL——这是防止 SSRF(Server-Side Request Forgery)的关键闸门。
  2. 修复前的缺陷模式:旧逻辑按“拼写”判断 host,即用前缀正则/字符串匹配,只认点分十进制写法(如 169.254.169.254)。同一地址的 IPv6 映射写法 http://[::ffff:169.254.169.254] 会穿过检查。
  3. 修复后的语义:校验先归一化再判定,“地址”本身决定放行与否,与它用什么记号写出来无关。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.tssrc/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.254 was refused while http://[::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::/10 and CGNAT 100.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::1localhost*.localhost*.local*.internal 精确匹配/后缀匹配
链路本地(含云元数据) 169.254.0.0/16 区段判定 + 元数据主机名集合
RFC 1918 私有 10.0.0.0/8172.16.0.0/12192.168.0.0/160.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 及其映射写法)是设计内的放行项,其余内网地址(含 CGNAT 100.64.0.0/10、ULA fd00::/8)一律拒绝。
  • 判定顺序不可跳过:normalizeHostmappedIpv4Host 还原 → 环回豁免 → 元数据/私有/多播判定,任何一步缺失都会重新引入拼写敏感旁路;其中 ipVersion 采用与 Node isIP 逐位对等的纯 JS 实现(见 src/shared/network/privateHost.ts#L15-L48),避免更窄的匹配把私有地址误判为公网。

小结

这次 #11319 修复表面上只是让上游代理 URL 校验多拒绝了几个“长得像公网”的地址,实质确立了一条可复用的防御原则:安全判定必须作用在规范化后的地址语义上,而不是输入字符串的表面形态。IPv4-mapped IPv6、十六进制 hextet 序列化、CGNAT 与 ULA 前缀,都只是同一原则下的具体实例。对于任何接受用户 URL 的服务端,mappedIpv4Host 这类“先还原、再判定”的归一化步骤应当成为 SSRF 防护的默认组成。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388