Files
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

297 lines
11 KiB
Go
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.
// 本文件是 webproxy 的单元测试集(package main,与源码同包,可直接
// 访问未导出符号,如 encodeURL / classify / truncate)。
//
// 覆盖范围(按被测试的模块划分):
// - rewriter.go:encodeURL/proxyURL/resolveURL 的编解码与 URL 解析;
// - proxy.go:isSubdomain/decodeSubdomainOrigin 的子域判定、
// rewriteOriginInURL 的头改写、truncate 的日志截断;
// - ssrf.go:inCIDR 的网段判断、classify 的黑/白名单分类。
//
// 运行方式:go test ./... (cookietest 的 E2E 脚本走 build tag,
// 不在此列,见 tests/README.md)。
package main
import (
"encoding/base64"
"encoding/hex"
"net"
"net/url"
"strings"
"testing"
)
// init 在测试进程启动时给包级变量兜底初值。
//
// 作用:go test 不会调用 main(),而 basePath 等变量是在 main() 里
// 赋值的;这里为空则补默认值,保证测试不依赖启动顺序。
// 场景:每个测试进程启动时执行一次。
func init() {
// basePath 为空时补默认值 "/p/"(与 main.go 的缺省行为一致)
if basePath == "" {
basePath = "/p/"
}
}
// TestEncodeURLRoundtrip 校验 base64url 编解码的往返一致性。
//
// 作用:encodeURL 是全代理地址拼接(/p/<enc>)与前端 JS enc() 的
// 公共契约,必须保证往返无损且无 '=' 填充。
// 场景:断言四类典型 URL(官网、带端口路径的内网站点、带查询串的
// 字体资源)都能编出去、解回来。
// 断言点:
// 1. 编码结果不含 '='(RawURLEncoding 无填充约定,前端也要去 '=');
// 2. RawURLEncoding.DecodeString 能解回原串;
// 3. 解回结果与输入逐字节相等。
func TestEncodeURLRoundtrip(t *testing.T) {
// cases 覆盖:普通官网、深层路径、内网+端口+查询串、长静态资源 URL
cases := []string{
"https://example.com/",
"https://www.runoob.com/python/python-nested-loops.html",
"http://192.168.0.107:5666/app/token?a=b&c=d",
"https://static.jyshare.com/assets/font-awesome-4.7.0/fonts/fontawesome-webfont.woff2?v=4.7.0",
}
for _, u := range cases {
// 编码
enc := encodeURL(u)
// /p/ 路径用的就是 RawURLEncoding,且不能有 '=' 填充(与前端 JS enc() 对齐)
if strings.Contains(enc, "=") {
t.Fatalf("encodeURL 含 '=' 填充,会破坏 base64url: %q", enc)
}
// 解码并比对
raw, err := base64.RawURLEncoding.DecodeString(enc)
if err != nil {
t.Fatalf("解码失败 %q: %v", enc, err)
}
if string(raw) != u {
t.Fatalf("roundtrip 不一致: 入=%q 出=%q", u, string(raw))
}
}
}
// TestProxyURL 校验 proxyURL 拼出的代理路径形如 "<basePath><base64>"。
//
// 作用:proxyURL 是 rewriter.go 所有 URL 改写的最终产出形态,
// 拼接规则错则全站改写全错。
// 场景:固定 basePath 后比对拼接结果与手算期望值。
// 断言点:输出等于 "/p/" + encodeURL(目标)。
func TestProxyURL(t *testing.T) {
// 先把全局变量钉死为默认前缀,避免其它测试改动后互相干扰
basePath = "/p/"
got := proxyURL("https://example.com/x")
want := "/p/" + encodeURL("https://example.com/x")
if got != want {
t.Fatalf("proxyURL 错误: 得=%q 期望=%q", got, want)
}
}
// TestResolveURL 校验相对/绝对/跨站三类引用的解析。
//
// 作用:resolveURL 是改写前把页面里的相对地址变绝对的第一步。
// 场景:以 "https://e.com/a/b" 为基准分别校验 "../c"(相对上级)、
// "/abs"(根相对)、跨站绝对地址。
// 断言点:三类输入分别解析为期望的绝对 URL。
func TestResolveURL(t *testing.T) {
base, _ := url.Parse("https://e.com/a/b")
// 相对上级路径:"/a/b" 的上级是 "/c"
if got := resolveURL(base, "../c"); got != "https://e.com/c" {
t.Fatalf("相对解析错误: %q", got)
}
// 根相对路径:直接挂到 origin 下
if got := resolveURL(base, "/abs"); got != "https://e.com/abs" {
t.Fatalf("绝对解析错误: %q", got)
}
// 跨站绝对地址:原样保留(只解析不改写)
if got := resolveURL(base, "https://other.com/z"); got != "https://other.com/z" {
t.Fatalf("跨站解析错误: %q", got)
}
}
// TestIsSubdomain 校验子域名反代入口的 Host 判定。
//
// 作用:isSubdomain 决定 rootHandler 把请求分去子域反代还是 UI,
// 误判会让整个反代链路走错分支。
// 场景:构造合法 hex 标签 + 一批非法形态。
// 断言点:
// - "<hex>.localhost:8765" 判定为子域;
// - 公网域名、裸 localhost、非 hex 标签(用户自建 *.localhost)、
// 带端口裸 localhost 均判定为非子域。
func TestIsSubdomain(t *testing.T) {
// 用 encodeURL 同款 hex 编码生成合法标签(模拟 proxySubdomain 生成的地址)
hexLabel := hex.EncodeToString([]byte("https://example.com"))
valid := hexLabel + ".localhost:8765"
if !isSubdomain(valid) {
t.Fatalf("应识别为子域代理: %q", valid)
}
// bad 集:公网域名/裸域/非hex标签/带端口裸域
for _, bad := range []string{"example.com", "localhost", "zzzz.localhost", "localhost:8765"} {
if isSubdomain(bad) {
t.Fatalf("不应识别为子域代理: %q", bad)
}
}
}
// TestDecodeSubdomainOrigin 校验从子域 Host 反解目标 origin。
//
// 作用:proxySubdomain 的第一步依赖它拿目标;解错=整个请求发错人。
// 场景:合法 hex 标签应解回 scheme+host;非法 hex 返回 nil。
// 断言点:解出的 Scheme/Host 正确;"nothex" 标签返回 nil。
func TestDecodeSubdomainOrigin(t *testing.T) {
origin := "https://example.com"
hexLabel := hex.EncodeToString([]byte(origin))
got := decodeSubdomainOrigin(hexLabel + ".localhost:8765")
if got == nil {
t.Fatalf("解码子域 origin 失败")
}
if got.Scheme != "https" || got.Host != "example.com" {
t.Fatalf("解码结果错误: scheme=%q host=%q", got.Scheme, got.Host)
}
// 非十六进制标签(非本代理生成)必须返回 nil,交回 UI 处理
if decodeSubdomainOrigin("nothex.localhost:8765") != nil {
t.Fatalf("非法 hex 应返回 nil")
}
}
// TestInCIDR 校验网段包含判断与非法输入容错。
//
// 作用:classify 的黑/白名单全部网段判定最终都落到这个函数。
// 场景:在段内、段外、非法 CIDR 三种输入。
// 断言点:段内 true、段外 false、非法文本 false(配置写错不 panic)。
func TestInCIDR(t *testing.T) {
ip := net.ParseIP("192.168.1.5")
// 在 192.168.1.0/24 内
if !inCIDR(ip, "192.168.1.0/24") {
t.Fatalf("应在 CIDR 内")
}
// 不在 10.0.0.0/8 内
if inCIDR(ip, "10.0.0.0/8") {
t.Fatalf("不应在 CIDR 内")
}
// 非法 CIDR 文本返回 false(而非 panic)
if inCIDR(ip, "not-a-cidr") {
t.Fatalf("非法 CIDR 应返回 false")
}
}
// TestSSRFClassify 校验 SSRF 分类器在两种模式下的行为。
//
// 作用:classify 是 SSRF 防护的判定内核,规则错=防护形同虚设。
// 场景:先用线上同款黑名单配置(拦 loopback/link-local、放行私网),
// 再切白名单配置验证"默认全拒、命中才放"。
// 断言点(黑名单部分):
// - 127.0.0.1 -> "loopback";
// - 169.254.169.254(云元数据)-> "linklocal";
// - 192.168.0.107(RFC1918)-> ""(默认放行,符合项目约定);
// - 0.0.0.0 -> "unspecified";
// - 打开 block_private 后 10.0.0.1 -> "private"。
//
// 断言点(白名单部分):
// - 192.168.0.107 在 allowed_ips 段内 -> "";
// - 10.0.0.1 既不在段内也不在域名表 -> "whitelist";
// - example.com 在 allowed_domains 中 -> ""(域名兜底放行)。
func TestSSRFClassify(t *testing.T) {
// 黑名单配置:与 config.example.json 默认值一致
cfg := &SSRFConfig{
Mode: "blacklist",
BlockLoopback: true,
BlockLinkLocal: true,
BlockPrivate: false,
}
// 回环地址必须拦截
if r := cfg.classify(net.ParseIP("127.0.0.1"), nil); r != "loopback" {
t.Fatalf("loopback 未拦: %q", r)
}
// 云元数据(link-local)必须拦截
if r := cfg.classify(net.ParseIP("169.254.169.254"), nil); r != "linklocal" {
t.Fatalf("linklocal 未拦: %q", r)
}
// 内网 RFC1918(默认放行,这是"访问内网站点"的核心诉求)
if r := cfg.classify(net.ParseIP("192.168.0.107"), nil); r != "" {
t.Fatalf("RFC1918 不应被拦(默认): %q", r)
}
// 未指定地址
if r := cfg.classify(net.ParseIP("0.0.0.0"), nil); r != "unspecified" {
t.Fatalf("unspecified 未拦: %q", r)
}
// 开启 block_private 后应拦内网(验证开关本身生效)
cfg.BlockPrivate = true
if r := cfg.classify(net.ParseIP("10.0.0.1"), nil); r != "private" {
t.Fatalf("private 未拦: %q", r)
}
// 白名单模式:仅放行 allowed_ips 段内或 allowed_domains 命中
wcfg := &SSRFConfig{
Mode: "whitelist",
AllowedIPs: []string{"192.168.0.0/24"},
AllowedDomains: []string{"example.com"},
}
// IP 命中白名单段 -> 放行
if r := wcfg.classify(net.ParseIP("192.168.0.107"), mustURL("http://192.168.0.107")); r != "" {
t.Fatalf("白名单 IP 应放行: %q", r)
}
// 两者都不命中 -> 拦
if r := wcfg.classify(net.ParseIP("10.0.0.1"), mustURL("http://10.0.0.1")); r != "whitelist" {
t.Fatalf("白名单外应拦: %q", r)
}
// IP 不在段内但域名在白名单 -> 域名兜底放行
if r := wcfg.classify(net.ParseIP("93.184.216.34"), mustURL("http://example.com")); r != "" {
t.Fatalf("白名单域名应放行: %q", r)
}
}
// TestRewriteOriginInURL 校验 Referer 类头部的源站改写。
//
// 作用:doProxy 对 Referer 只换源不换路径(尽量保留可读的路径信息),
// 该函数是策略的唯一实现。
// 场景:绝对 URL 换源;相对值原样。
// 断言点:
// - "http://localhost:8765/foo?a=1" -> "https://example.com/foo?a=1"
// (scheme+host 换成目标,path/query 保留);
// - "/relative/path" 无 scheme/host,原样返回(相对引用改写反而失真)。
func TestRewriteOriginInURL(t *testing.T) {
target, _ := url.Parse("https://example.com")
got := rewriteOriginInURL("http://localhost:8765/foo?a=1", target)
if got != "https://example.com/foo?a=1" {
t.Fatalf("改写 Origin 错误: %q", got)
}
// 无法解析时原样返回
if got := rewriteOriginInURL("/relative/path", target); got != "/relative/path" {
t.Fatalf("相对值应原样返回: %q", got)
}
}
// TestTruncate 校验日志截断函数。
//
// 作用:dbg 打印 Cookie/Authorization 等长值都要过它,规则很简单
// 但错一点日志就会刷屏或信息缺失。
// 场景:短串不截断、超长串截断并加尾标记。
// 断言点:len<=n 原样;len>n 得 s[:n]+"...[trunc]"。
func TestTruncate(t *testing.T) {
// 短于上限:原样
if truncate("hello", 10) != "hello" {
t.Fatalf("短串不应截断")
}
// 超上限:截到 5 字符并追加标记
if truncate("hello world", 5) != "hello...[trunc]" {
t.Fatalf("长串截断错误: %q", truncate("hello world", 5))
}
}
// mustURL 解析失败即 panic 的 URL 构造助手。
//
// 作用:测试里把字面量 URL 变成 *url.URL,失败时立刻炸掉而不是
// 带着 nil 走进断言,掩盖真实问题。
// 场景:TestSSRFClassify 等处批量构造目标 URL。
// 入参:
// - s:URL 字面量。
//
// 出参:
// - *url.URL:解析结果;解析失败直接 panic。
func mustURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}