Files
2026-07-13 13:00:08 +08:00

350 lines
10 KiB
Go

package builtin
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/netclient"
)
func startCONNECTProxy(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
br := bufio.NewReader(c)
req, err := http.ReadRequest(br)
if err != nil {
return
}
if req.Method != http.MethodConnect {
return
}
targetConn, err := net.DialTimeout("tcp", req.Host, 5*time.Second)
if err != nil {
c.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
return
}
defer targetConn.Close()
c.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
go func() { io.Copy(targetConn, c) }()
io.Copy(c, targetConn)
}(conn)
}
}()
return fmt.Sprintf("http://%s", listener.Addr().String())
}
func startRespondingCONNECTProxy(t *testing.T, respond func(hit int32, req *http.Request) *http.Response) (string, *int32) {
t.Helper()
var hits int32
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
br := bufio.NewReader(c)
req, err := http.ReadRequest(br)
if err != nil || req.Method != http.MethodConnect {
return
}
hit := atomic.AddInt32(&hits, 1)
c.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
req, err = http.ReadRequest(br)
if err != nil {
return
}
_ = respond(hit, req).Write(c)
}(conn)
}
}()
return fmt.Sprintf("http://%s", listener.Addr().String()), &hits
}
func textProxyResponse(req *http.Request, statusCode int, status string, body string) *http.Response {
resp := &http.Response{
StatusCode: statusCode,
Status: status,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}
resp.Header.Set("Content-Type", "text/plain")
resp.ContentLength = int64(len(body))
return resp
}
func startInterceptingCONNECTProxy(t *testing.T, body string) (string, *int32) {
t.Helper()
return startRespondingCONNECTProxy(t, func(_ int32, req *http.Request) *http.Response {
return textProxyResponse(req, http.StatusOK, "200 OK", body)
})
}
func startRedirectingCONNECTProxy(t *testing.T, location string) (string, *int32) {
t.Helper()
return startRespondingCONNECTProxy(t, func(hit int32, req *http.Request) *http.Response {
if hit == 1 {
resp := textProxyResponse(req, http.StatusFound, "302 Found", "")
resp.Header.Set("Location", location)
return resp
}
return textProxyResponse(req, http.StatusOK, "200 OK", "proxied after redirect")
})
}
func startTestHTTPServer(t *testing.T, body string) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
u := fmt.Sprintf("http://%s/", listener.Addr().String())
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(body))
})
server := &http.Server{Handler: mux}
t.Cleanup(func() { server.Close() })
go server.Serve(listener)
return u
}
func TestWebFetchUsesEnvProxyAtRequestTime(t *testing.T) {
t.Setenv("http_proxy", "")
t.Setenv("HTTPS_PROXY", "")
t.Setenv("https_proxy", "")
t.Setenv("NO_PROXY", "")
t.Setenv("no_proxy", "")
proxyURL, proxyHits := startInterceptingCONNECTProxy(t, "from env proxy")
wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeEnv}}
t.Setenv("HTTP_PROXY", proxyURL)
args, _ := json.Marshal(map[string]string{"url": "http://service.test/resource"})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := wf.Execute(ctx, args)
if err != nil {
t.Fatalf("webFetch.Execute through env proxy: %v", err)
}
if !strings.Contains(result, "from env proxy") {
t.Fatalf("expected env proxy response, got: %s", result)
}
if got := atomic.LoadInt32(proxyHits); got != 1 {
t.Fatalf("proxy hits = %d, want 1", got)
}
}
func TestWebFetchProxySpecHonorsNoProxy(t *testing.T) {
targetURL := startTestHTTPServer(t, "direct no_proxy")
target, err := url.Parse(targetURL)
if err != nil {
t.Fatalf("parse target URL: %v", err)
}
proxyURL, proxyHits := startInterceptingCONNECTProxy(t, "from proxy")
wf := webFetch{
proxySpec: netclient.ProxySpec{
Mode: netclient.ModeCustom,
URL: proxyURL,
NoProxy: target.Hostname(),
},
}
args, _ := json.Marshal(map[string]string{"url": targetURL})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := wf.Execute(ctx, args)
if err != nil {
t.Fatalf("webFetch.Execute with no_proxy: %v", err)
}
if !strings.Contains(result, "direct no_proxy") {
t.Fatalf("expected direct response, got: %s", result)
}
if got := atomic.LoadInt32(proxyHits); got != 0 {
t.Fatalf("proxy hits = %d, want 0", got)
}
}
func TestWebFetchRechecksProxySpecAfterRedirect(t *testing.T) {
targetURL := startTestHTTPServer(t, "redirect target direct")
target, err := url.Parse(targetURL)
if err != nil {
t.Fatalf("parse target URL: %v", err)
}
proxyURL, proxyHits := startRedirectingCONNECTProxy(t, targetURL)
wf := webFetch{
proxySpec: netclient.ProxySpec{
Mode: netclient.ModeCustom,
URL: proxyURL,
NoProxy: target.Hostname(),
},
}
args, _ := json.Marshal(map[string]string{"url": "http://service.test/start"})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := wf.Execute(ctx, args)
if err != nil {
t.Fatalf("webFetch.Execute with redirect: %v", err)
}
if !strings.Contains(result, "redirect target direct") {
t.Fatalf("expected redirected request to bypass proxy, got: %s", result)
}
if got := atomic.LoadInt32(proxyHits); got != 1 {
t.Fatalf("proxy hits = %d, want 1", got)
}
}
func TestWebFetchThroughCONNECTProxy(t *testing.T) {
targetURL := startTestHTTPServer(t, "hello from target")
proxyURL := startCONNECTProxy(t)
t.Logf("proxy: %s target: %s", proxyURL, targetURL)
wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxyURL}}
args, _ := json.Marshal(map[string]string{"url": targetURL})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := wf.Execute(ctx, args)
if err != nil {
t.Fatalf("webFetch.Execute through proxy: %v", err)
}
if !strings.Contains(result, "hello from target") {
t.Errorf("expected 'hello from target', got: %s", result)
}
}
func TestWebFetchWithoutProxy(t *testing.T) {
targetURL := startTestHTTPServer(t, "direct fetch OK")
wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeOff}}
args, _ := json.Marshal(map[string]string{"url": targetURL})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := wf.Execute(ctx, args)
if err != nil {
t.Fatalf("webFetch without proxy: %v", err)
}
if !strings.Contains(result, "direct fetch OK") {
t.Errorf("expected 'direct fetch OK', got: %s", result)
}
}
func TestSSRFStillBlocksPrivateThroughProxy(t *testing.T) {
proxyURL := startCONNECTProxy(t)
wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxyURL}}
blocked := []string{
"http://169.254.169.254/latest/meta-data",
"http://10.0.0.1/",
"http://192.168.1.1/",
}
for _, u := range blocked {
t.Run(u, func(t *testing.T) {
args, _ := json.Marshal(map[string]string{"url": u})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := wf.Execute(ctx, args)
if err == nil {
t.Error("expected SSRF error, got nil")
} else {
t.Logf("blocked: %v", err)
}
})
}
}
func TestProxyBasicAuthURLParsing(t *testing.T) {
u, _ := url.Parse("http://user:pass@127.0.0.1:7897")
if u.User == nil {
t.Fatal("expected user info")
}
if u.User.Username() != "user" {
t.Errorf("username = %q", u.User.Username())
}
pass, _ := u.User.Password()
if pass != "pass" {
t.Errorf("password = %q", pass)
}
}
func TestWebFetchSOCKS5Proxy(t *testing.T) {
wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "socks5://127.0.0.1:1"}}
args, _ := json.Marshal(map[string]string{"url": "https://example.com"})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := wf.Execute(ctx, args)
if err == nil {
t.Log("unexpected success (no SOCKS5 running)")
} else {
t.Logf("expected error (no SOCKS5 server): %v", err)
}
}
func TestSSRFBlocksPrivateTargetThroughSOCKS5(t *testing.T) {
wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "socks5://127.0.0.1:1080"}}
for _, u := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.1/", "http://192.168.1.1/"} {
t.Run(u, func(t *testing.T) {
args, _ := json.Marshal(map[string]string{"url": u})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := wf.Execute(ctx, args)
if err == nil || !strings.Contains(err.Error(), "refusing to fetch internal address") {
t.Errorf("want SSRF block for %s, got %v", u, err)
}
})
}
}
func TestSOCKS5ProxyOnPrivateAddressNotSSRFBlocked(t *testing.T) {
// A SOCKS proxy commonly lives on a private/LAN address; the SSRF guard must
// not reject the proxy itself. Reaching the (absent) proxy fails, but never
// with an SSRF "internal address" error.
wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "socks5://10.0.0.1:1080"}}
args, _ := json.Marshal(map[string]string{"url": "https://example.com"})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := wf.Execute(ctx, args)
if err != nil && strings.Contains(err.Error(), "refusing to fetch internal address") {
t.Fatalf("proxy on private address was SSRF-blocked: %v", err)
}
}