Files
webproxy/docs/ARCHITECTURE.md
yexuejc a9ebb5e2cd
CI / Build / Vet / Test (push) Canceled after 0s
chore: 开源化准备(LICENSE/CI/测试/文档)+ 全量中文注释
首个提交。webproxy 是部署在服务器上的 Web 正向代理:打开网页输入任意
URL,由服务器代为请求目标站点,经 HTML/CSS 重写、静态资源代理、
重定向/Cookie/表单处理后回填浏览器,核心场景是经服务器访问其所在
内网的客户网站。

- 开源件:MIT LICENSE、根级 SECURITY.md、CONTRIBUTING.md、
  CHANGELOG.md、Makefile、CI(gofmt/vet/build/test)
- 安全止损:.gitignore 排除 debug.log(含内网 IP)/二进制/调试散文件/
  本地 config.json;config.example.json 默认 blacklist,不关 SSRF
- module 路径规范化为 github.com/yexuejc/webproxy
- 测试:新增 proxy_test.go(URL 编解码/子域解析/SSRF 分类/Origin 改写);
  tests/cookietest 手工 E2E 程序加 build tag 并拆为独立子包,不污染
  go test ./...
- 注释:全部 Go 源码按函数作用/场景/入参/出参 + 分支 + 变量标准补齐
- 清理:删除旧"浏览器持有 Cookie"方案弃用代码(rewriteSetCookie/
  stripDomainSecure/reCookieDomain/reCookieSecure);会话 Cookie 改由
  服务端按 origin 的 jar 持有,绕开跨站 iframe 的 SameSite 扣发
2026-10-20 21:33:28 +08:00

7.9 KiB
Raw Permalink Blame History

架构设计

1. 模块划分

模块 文件 职责
入口/路由 main.go 加载 config.json(缺失则用内置安全默认),注册 /(UI)与 /p/(代理入口,前缀由 base_path 决定)
转发引擎 proxy.go 解码目标 URL → SSRF 前置检查 → 构造上游请求(方法/Header/Body 透传)→ 重定向改写 → 按 Content-Type 分流回填
重写引擎 rewriter.go HTML DOM 遍历改写 URL 属性;CSS url() 改写;生成并注入 JS 代理桩;base64url 编解码
SSRF 防护 ssrf.go 协议检查、DNS 解析、IP 分类(loopback/link-local/private/reserved/denied/whitelist)、CIDR 匹配
会话隔离 session.go sid Cookie → 独立 http.CookieJar(sync.Map),按浏览者隔离登录态
前端 UI static/index.html URL 输入栏 + iframe;输入后编码为 /p/<b64url> 加载到 iframe
部署 Dockerfile / docker-compose.yml / deploy.sh 多阶段构建、compose、一键脚本

2. 一次请求的完整生命周期

以浏览器访问 http://192.168.1.10:8080/login 为例:

[浏览器]  iframe.src = /p/aHR0cDovLzE5Mi4xNjguMS4xMDo4MDgwL2xvZ2lu
   │
   ▼
[webproxy] proxyHandler
   │
   ├─1. getSessionID(sid Cookie,无则下发 HttpOnly 新 sid)
   │
   ├─2. base64url(RawURLEncoding) 解码 → 目标 URL
   │      解码失败/无 Scheme/无 Host          → 400
   │      目标 Host == 代理自身 Host          → 400(防自引用死循环)
   │
   ├─3. ssrfCfg.resolveAndCheck(target)
   │      net.LookupIP 解析 → 逐 IP 分类校验(模式见 CONFIG.md)
   │      命中拦截                              → 403(响应体给出原因)
   │
   ├─4. 构造上游请求
   │      方法透传;非 GET/HEAD 透传 Body(表单/JSON 登录正常)
   │      Header 透传(剥 hop-by-hop);
   │      强制 Accept-Encoding: identity(拿原文自己做重写)
   │
   ├─5. http.Client 发起请求
   │      Transport.DialContext 用第 3 步解析出的 IP 直连(pin)
   │        └─ 防 DNS 重绑定 TOCTOU:校验后域名即使改解析也不影响本次连接
   │      Jar = 会话 CookieJar(跨请求保持登录态)
   │      CheckRedirect = ErrUseLastResponse(不自动跟随,自己改写)
   │      TLS MinVersion 1.2;InsecureSkipVerify 可配(内网自签证书)
   │      DisableKeepAlives = true(每个上游连接一次即弃,简单优先)
   │
   ├─6. 收到响应后分流
   │      3xx 且有 Location:解析成绝对 URL → 改写成 /p/... → 回 302
   │      text/html:全量读入 → DOM 重写 → 注入 JS 桩 → 输出
   │      text/css :全量读入 → url() 重写 → 输出
   │      其他(图片/JS/字体/音视频):按原 StatusCode + 原 Header 流式 io.Copy
   │
   ▼
[目标站点 192.168.1.10:8080]

3. URL 改写规则(重写引擎)

3.1 HTML 属性

遍历改写(urlAttrs):href、src、action、formaction、data、poster、cite、background、longdesc、xlink:href、lowsrc。

  • 相对/绝对 URL 先 base.ResolveReference 解析成绝对 URL,再编码为 /p/<b64url>;
  • 只改写 http/https 协议(其他协议即便解析出来也不碰);
  • 不改写:# 锚点、javascript:、data:、blob:、mailto:、tel:、about:、chrome:、filesystem:、已带 /p/ 前缀的(防重复包装);
  • 解析结果 Host 等于代理自身时跳过(防把 UI 自身资源再代理一次)。

3.2 CSS

<style> 内联文本、style 属性的 url(...)、以及 text/css 响应体,统一走同一改写函数:url(inner) → url("/p/<b64url(abs)>)。保留原引号风格语义,输出统一双引号形式。

3.3 编码自适应

上游返回先用 charset.NewReader 按 Content-Type/首字节嗅探字符集(GBK/UTF-8 等),解析重写后再以 UTF-8 输出——所以中文页面重写不乱码。

4. JS 代理桩(覆盖动态请求)

JS 桩通过 DOM 方式注入:HTML 解析重写完成后,在 <head> 末尾追加一个 <script> 节点(桩代码作为其文本子节点),再整体 Render 输出。在页面上下文内重写:

目标 行为
window.fetch 字符串/Request 入参的 URL 先过 px() 转 /p/ 再请求
XMLHttpRequest.prototype.open 同上
window.WebSocket 非 http(s) 协议(ws:///wss://)原样透传;服务端未实现 WS 双向通道
window.open 新窗口 URL 转 /p/
document submit(捕获阶段) 表单 action 解析后指向非代理主机时才改写,覆盖 JS 动态设置的 action

px() 逻辑:相对 URL 先 new URL(u, BASE) 解析(BASE = 当前代理页面对应的目标 origin),非 http(s) 原样返回,否则编码为 /p/<b64url>。UI 输入框的 enc() 与注入桩的 enc() 算法一致(UTF-8 → base64 → +/→-_ → 去尾部 =)。

为什么必须 DOM 注入:字节级搜索 </head> 定位注入点不可靠——页面内联 <script>/模板字符串里出现 </head> 字面量时(document.write 整页模板的常见写法),桩会被插进页面自身脚本内部:落在 // 注释里则静默失效,落在字符串/表达式里则引号失衡直接语法报错(线上实例出现过 Unexpected token ')')。解析后以 DOM 节点追加到 <head> 末尾,结构上不可能落进脚本文本区。

5. 关键设计决策(为什么这么做)

决策 理由
pin 解析后的 IP 直连 先校验再连接的经典 TOCTOU 漏洞:域名解析一次、连接时再解析一次,中间可换 IP 绕过黑名单。pin 后攻击者无法借 DNS 重绑定打到内网
不自动跟随重定向(ErrUseLastResponse) 302 是页面流程的一部分,交给浏览器在代理内继续跳转,服务端无状态、无重定向风暴;同时每次跳转都重新过 SSRF 检查,跳转链上的每一跳都受控
剥离目标站的 X-Frame-Options / CSP / COOP UI 用 iframe 承载。目标站这些响应头会阻止被嵌入/阻止注入 JS 生效,与代理形态冲突,必须剥
顶层导航 302 改道到外框(/?url=) target="_blank" 链接/window.open 会在新标签顶层打开 /p/xxx,若无处理则新标签没有 UI 外框。服务端按 Sec-Fetch-Dest: document(老浏览器按 Accept: text/html 前缀兜底)识别顶层导航后 302 到 /?url=xxx,由外框 iframe 重新加载(UI 的 iframe 带 ?_wpframe=1 标记防循环);AJAX(Accept: */*)与 iframe 内导航不受影响,POST 顶层不改道以保表单语义
强制 Accept-Encoding: identity 若让目标站返回 gzip/br,本地重写前还要解压;直接要原文,逻辑单一、开销小
按 sid 隔离 CookieJar 多人用同一实例时登录态不串号;本地单人可以忽略但架构保留
CSS/HTML 全量读内存,其他流式 重写必须拿到完整文档;而图片/视频等大文件走 io.Copy 流式,不全量缓存
SSRF 默认放行 RFC1918、拦死链路本地 访问内网站点是核心诉求(不能一刀切禁内网);而 169.254.169.254 云元数据、127.0.0.1 管理口是最典型的被打的面,默认拦

6. 数据流总览(简化)

浏览器 ──GET /p/<b64>──▶ webproxy ──GET 目标URL──▶ 目标站
       ◀──HTML(已重写)──            ◀──HTML(原文)──
浏览器 ──GET /p/<b64>(img)──▶ webproxy ──GET 图片──▶ 目标站
       ◀──image/png 流式──          ◀──image/png──
浏览器 ──POST /p/<b64>(表单)──▶ webproxy ──POST 表单──▶ 目标站
       ◀──302 /p/<b64>(下跳页)──    ◀──302 原文Location──