package browser import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" ) // TestGuardURL_DeniesLoopbackAndPrivate 钉住守卫的**默认拒绝面**:回环、私网、 // 链路本地、非 http(s) 协议、云元数据端点一律拒绝 —— 这是 design/28 §4 第 1 条 // (服务端代取任意 URL 必须过闸)的可执行判据。 func TestGuardURL_DeniesLoopbackAndPrivate(t *testing.T) { t.Parallel() denied := []string{ "http://127.0.0.1/", "http://localhost/", "http://[::1]/", "http://192.168.1.10/admin", "http://10.0.0.5/", "http://172.16.3.4/", "http://169.254.169.254/latest/meta-data/", "http://0.0.0.0/", "file:///etc/passwd", "ftp://example.com/", "http:///no-host", "", } for _, raw := range denied { if _, err := GuardURL(context.Background(), raw, nil); err == nil { t.Errorf("GuardURL(%q) = nil,应当拒绝", raw) } else if !strings.Contains(err.Error(), "安全守卫拒绝") && !strings.Contains(err.Error(), "URL 被安全守卫拒绝") { t.Errorf("GuardURL(%q) 的错误不像守卫拒绝: %v", raw, err) } } } // TestGuardURL_AllowsPublicAndAllowlisted 钉住**放行面**:公网地址放行;白名单里的 // 主机(同源自取:本地 searxng / 本机 upload 路由)即使落在回环也放行。 func TestGuardURL_AllowsPublicAndAllowlisted(t *testing.T) { t.Parallel() if _, err := GuardURL(context.Background(), "https://example.com/a?b=1", nil); err != nil { t.Fatalf("公网地址被拒: %v", err) } if _, err := GuardURL(context.Background(), "http://93.184.216.34/", nil); err != nil { t.Fatalf("公网字面 IP 被拒: %v", err) } // 白名单是**按主机名字符串**比较的(与 io/ssrf 的 isHostAllowed 同口径): // 允许 `localhost` 不等于允许 `127.0.0.1` —— 两者是两个主机名,要各写一条。 exempt := []struct{ entry, raw string }{ {"127.0.0.1", "http://127.0.0.1:8080/x"}, {"http://localhost:8080", "http://localhost/x"}, {"LocalHost", "http://localhost:9999/x"}, } for _, tc := range exempt { if _, err := GuardURL(context.Background(), tc.raw, []string{tc.entry}); err != nil { t.Errorf("白名单 %q 未放行 %q: %v", tc.entry, tc.raw, err) } } } // TestGuardURL_AllowlistEntryForms 钉住白名单条目的三种写法(裸主机 / host:port / 完整 URL) // 都按主机名匹配 —— 少一种,面板里写的白名单就会静默失效。 func TestGuardURL_AllowlistEntryForms(t *testing.T) { t.Parallel() for _, entry := range []string{"127.0.0.1", "127.0.0.1:9", "http://127.0.0.1:9999/"} { if _, err := GuardURL(context.Background(), "http://127.0.0.1:7777/", []string{entry}); err != nil { t.Errorf("条目 %q 未命中: %v", entry, err) } } } // TestGuardURL_RejectsHostResolvingToPrivate 钉住 DNS 侧的判定:一个解析到私网的主机名 // 也要被拒(用 httptest 起在本机、把它的主机名当目标;`localhost` 已由字面量覆盖, // 这里覆盖「解析后才发现是私网」这条路径)。 func TestGuardURL_RejectsHostResolvingToPrivate(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })) defer srv.Close() // httptest 的 URL 形如 http://127.0.0.1:,字面 IP 路径已由上面的用例覆盖; // 这里钉住「解析结果里含私网地址」的判定函数本身。 host := strings.TrimPrefix(srv.URL, "http://") hostIP, _, _ := strings.Cut(host, ":") if _, err := GuardURL(context.Background(), srv.URL, nil); err == nil { t.Errorf("httptest 地址 %s 应被拒(字面回环)", hostIP) } } // TestGuardURL_KeepsBothErrorIdentities 钉住收口(切片 X3)之后的**双错误身份**: // 判定本体搬去了 `io.ValidateRequestURL`,但本包的契约没动 —— 组合根/工具层仍按 // `ErrBlockedURL` 分类降级(`ReasonBlockedURL` 与它同源),而排障时能顺着链回溯到**唯一** // 判定源(`io.ErrSSRF`)。少任何一个身份,「收口只搬判定、不动契约」就只是句口号。 func TestGuardURL_KeepsBothErrorIdentities(t *testing.T) { t.Parallel() _, err := GuardURL(context.Background(), "http://169.254.169.254/latest/meta-data/", nil) if !errors.Is(err, ErrBlockedURL) { t.Errorf("拒绝丢了本包契约身份 ErrBlockedURL: %v", err) } if !errors.Is(err, iotools.ErrSSRF) { t.Errorf("拒绝丢了判定源身份 iotools.ErrSSRF: %v", err) } }