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

116 lines
7.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构设计
## 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──
```