350 lines
10 KiB
Go
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)
|
|
}
|
|
}
|