mirror of
https://github.com/go-gost/x.git
synced 2026-10-20 08:18:33 +00:00
A valid Gost-Target / X-Gost-Target header replaced only req.Host while leaving req.URL.Host untouched. Destination-policy checks (bypass) evaluated the header-derived authority, but http.Transport dialed the unchanged absolute-URL authority. A client could label a forbidden URL with an allowed authority and reach the blocked destination (403 -> 200 with backend body). setRequestHost now applies the decoded target to both req.Host and req.URL.Host so policy evaluation and transport dialing always resolve to the same authority.
483 lines
13 KiB
Go
483 lines
13 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"hash/crc32"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-gost/core/bypass"
|
|
"github.com/go-gost/core/handler"
|
|
xstats "github.com/go-gost/x/observer/stats"
|
|
xrecorder "github.com/go-gost/x/recorder"
|
|
)
|
|
|
|
func TestProxyRoundTrip_BasicGET(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Connection", "close")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: ts.Client().Transport,
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL, nil)
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
closeConn, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
if err != nil {
|
|
t.Fatalf("proxyRoundTrip error: %v", err)
|
|
}
|
|
if !closeConn {
|
|
t.Error("expected close=true with Connection: close response")
|
|
}
|
|
}
|
|
|
|
func TestProxyRoundTrip_WithBypass(t *testing.T) {
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
Bypass: &testBypass{contains: true},
|
|
},
|
|
}
|
|
h.md.proxyAgent = defaultProxyAgent
|
|
|
|
req, _ := http.NewRequest("GET", "http://blocked.example.com", nil)
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
close, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
if err == nil {
|
|
t.Error("expected bypass error")
|
|
}
|
|
if !close {
|
|
t.Error("expected close=true for bypass")
|
|
}
|
|
}
|
|
|
|
func TestProxyRoundTrip_WithoutBypass(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Connection", "close")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
Bypass: &testBypass{contains: false},
|
|
},
|
|
transport: ts.Client().Transport,
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL, nil)
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
closeConn, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
if err != nil {
|
|
t.Fatalf("proxyRoundTrip error: %v", err)
|
|
}
|
|
if !closeConn {
|
|
t.Error("expected close=true with Connection: close response")
|
|
}
|
|
}
|
|
|
|
func TestProxyRoundTrip_TransportError(t *testing.T) {
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: &testRoundTripper{err: true},
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", "http://error.example.com", nil)
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
close, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
if err == nil {
|
|
t.Error("expected transport error")
|
|
}
|
|
if !close {
|
|
t.Error("expected close=true on transport error")
|
|
}
|
|
}
|
|
|
|
func TestProxyRoundTrip_HTTP10(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: ts.Client().Transport,
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL, nil)
|
|
req.ProtoMajor = 1
|
|
req.ProtoMinor = 0
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
if err != nil {
|
|
t.Fatalf("proxyRoundTrip error with HTTP/1.0: %v", err)
|
|
}
|
|
}
|
|
|
|
|
|
func TestHandleUpgradeResponse_Mismatch(t *testing.T) {
|
|
h := &httpHandler{}
|
|
h.md.proxyAgent = defaultProxyAgent
|
|
|
|
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
|
req.Header.Set("Connection", "Upgrade")
|
|
req.Header.Set("Upgrade", "websocket")
|
|
|
|
res := &http.Response{
|
|
Header: http.Header{
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"h2c"},
|
|
},
|
|
}
|
|
|
|
err := h.handleUpgradeResponse(context.Background(), &testReadWriteCloser{}, req, res, &xrecorder.HandlerRecorderObject{}, &testLogger{})
|
|
if err == nil {
|
|
t.Error("expected error for upgrade type mismatch")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpgradeResponse_NonReadWriteCloserBody(t *testing.T) {
|
|
h := &httpHandler{}
|
|
h.md.proxyAgent = defaultProxyAgent
|
|
|
|
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
|
req.Header.Set("Connection", "Upgrade")
|
|
req.Header.Set("Upgrade", "websocket")
|
|
|
|
res := &http.Response{
|
|
Header: http.Header{
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"websocket"},
|
|
},
|
|
Body: io.NopCloser(strings.NewReader("not a rwc")),
|
|
}
|
|
|
|
err := h.handleUpgradeResponse(context.Background(), &testReadWriteCloser{}, req, res, &xrecorder.HandlerRecorderObject{}, &testLogger{})
|
|
if err == nil {
|
|
t.Error("expected error for non-io.ReadWriteCloser body")
|
|
}
|
|
}
|
|
|
|
func TestProxyRoundTrip_HeaderCleanup(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify headers are cleaned
|
|
if r.Header.Get("Proxy-Authorization") != "" {
|
|
t.Error("Proxy-Authorization should be removed")
|
|
}
|
|
if r.Header.Get("Proxy-Connection") != "" {
|
|
t.Error("Proxy-Connection should be removed")
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: ts.Client().Transport,
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL, nil)
|
|
req.Header.Set("Proxy-Authorization", "Basic xyz")
|
|
req.Header.Set("Proxy-Connection", "keep-alive")
|
|
req.Header.Set("Gost-Target", "hidden")
|
|
req.Header.Set("X-Gost-Target", "also-hidden")
|
|
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
if err != nil {
|
|
t.Fatalf("proxyRoundTrip error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProxyRoundTrip_OriginFormHost(t *testing.T) {
|
|
// Origin-form request (GET / + Host header), as sent by nginx
|
|
// proxy_pass. Mirrors go-gost/gost#679: the transport must not fail
|
|
// with "http: no Host in request URL".
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Host != "myoniondomain.onion" {
|
|
t.Errorf("unexpected Host header: %q", r.Host)
|
|
}
|
|
w.Header().Set("Connection", "close")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
// Transport must dial the test server regardless of the target host,
|
|
// like the handler's real transport does via the proxy chain.
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return net.Dial(network, ts.Listener.Addr().String())
|
|
},
|
|
},
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
req.Host = "myoniondomain.onion"
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
if err != nil {
|
|
t.Fatalf("proxyRoundTrip error: %v", err)
|
|
}
|
|
}
|
|
|
|
// testBypass implements bypass.Bypass for testing.
|
|
type testBypass struct {
|
|
contains bool
|
|
}
|
|
|
|
func (b *testBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
|
|
return b.contains
|
|
}
|
|
|
|
func (b *testBypass) IsWhitelist() bool { return false }
|
|
|
|
// testRoundTripper implements http.RoundTripper for testing.
|
|
type testRoundTripper struct {
|
|
err bool
|
|
status int
|
|
body string
|
|
}
|
|
|
|
func (rt *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if rt.err {
|
|
return nil, &testError{msg: "transport error"}
|
|
}
|
|
if rt.status == 0 {
|
|
rt.status = http.StatusOK
|
|
}
|
|
return &http.Response{
|
|
StatusCode: rt.status,
|
|
Body: io.NopCloser(strings.NewReader(rt.body)),
|
|
Header: http.Header{},
|
|
}, nil
|
|
}
|
|
|
|
func TestHandleProxy_SingleRequest(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Connection", "close")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: ts.Client().Transport,
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL, nil)
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
|
|
// Drain response in a goroutine
|
|
go func() {
|
|
io.ReadAll(server)
|
|
}()
|
|
|
|
err := h.handleProxy(context.Background(), client, req, ro, &testLogger{})
|
|
if err != nil {
|
|
t.Logf("handleProxy returned: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProxyRoundTrip_SwitchingProtocols(t *testing.T) {
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: &testRoundTripper{status: http.StatusSwitchingProtocols, body: ""},
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", "http://example.com", nil)
|
|
req.Header.Set("Connection", "Upgrade")
|
|
req.Header.Set("Upgrade", "websocket")
|
|
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
// Switching protocols with a non-RWC body should error
|
|
if err == nil {
|
|
t.Log("expected error for switching protocols with non-RWC body")
|
|
}
|
|
}
|
|
|
|
type testError struct {
|
|
msg string
|
|
}
|
|
|
|
func (e *testError) Error() string { return e.msg }
|
|
func (e *testError) Timeout() bool { return false }
|
|
func (e *testError) Temporary() bool { return true }
|
|
|
|
func TestProxyRoundTrip_HTTP10_KeepAlive(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// For HTTP/1.0 keep-alive, client sends Connection: keep-alive
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: ts.Client().Transport,
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL, nil)
|
|
req.ProtoMajor = 1
|
|
req.ProtoMinor = 0
|
|
req.Header.Set("Connection", "keep-alive") // client wants keep-alive
|
|
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
RemoteAddr: "127.0.0.1:12345",
|
|
}
|
|
pStats := xstats.Stats{}
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
if err != nil {
|
|
t.Fatalf("proxyRoundTrip error: %v", err)
|
|
}
|
|
}
|
|
|
|
|
|
// TestProxyRoundTrip_TargetHeaderDialsHeaderAuthority is a regression test for
|
|
// the destination-policy bypass where a valid Gost-Target header replaced
|
|
// req.Host (checked by bypass) but left req.URL.Host (dialed by the transport)
|
|
// as the absolute-URL authority. The transport must dial the same authority
|
|
// the policy evaluated.
|
|
func TestProxyRoundTrip_TargetHeaderDialsHeaderAuthority(t *testing.T) {
|
|
encodeName := func(name string) string {
|
|
v := []byte(name)
|
|
b := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(b, crc32.ChecksumIEEE(v))
|
|
inner := base64.RawURLEncoding.EncodeToString(v)
|
|
return base64.RawURLEncoding.EncodeToString(append(b, []byte(inner)...))
|
|
}
|
|
|
|
var dialed string
|
|
h := &httpHandler{
|
|
options: handler.Options{
|
|
Logger: &testLogger{},
|
|
},
|
|
transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
dialed = addr
|
|
return nil, &testError{msg: "stop after dial"}
|
|
},
|
|
},
|
|
}
|
|
h.md.readTimeout = 15
|
|
|
|
req, _ := http.NewRequest("GET", "http://blocked.example.com/secret", nil)
|
|
req.Header.Set("Gost-Target", encodeName("allowed.example.com:80"))
|
|
normalizeRequest(req)
|
|
|
|
ro := &xrecorder.HandlerRecorderObject{RemoteAddr: "127.0.0.1:12345"}
|
|
pStats := xstats.Stats{}
|
|
rw := &testReadWriteCloser{buf: new(strings.Builder)}
|
|
|
|
_, _ = h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
|
|
|
|
if dialed != "allowed.example.com:80" {
|
|
t.Fatalf("transport dialed %q, want allowed.example.com:80 (policy and dial destination must match)", dialed)
|
|
}
|
|
}
|
|
|
|
// testReadWriteCloser implements io.ReadWriteCloser for testing.
|
|
type testReadWriteCloser struct {
|
|
buf *strings.Builder
|
|
}
|
|
|
|
func (rw *testReadWriteCloser) Read(p []byte) (int, error) { return 0, io.EOF }
|
|
func (rw *testReadWriteCloser) Write(p []byte) (int, error) {
|
|
if rw.buf != nil {
|
|
return rw.buf.Write(p)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
func (rw *testReadWriteCloser) Close() error { return nil }
|