From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/proxy/mitm_test.go | 281 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 internal/proxy/mitm_test.go (limited to 'internal/proxy/mitm_test.go') diff --git a/internal/proxy/mitm_test.go b/internal/proxy/mitm_test.go new file mode 100644 index 0000000..c683c70 --- /dev/null +++ b/internal/proxy/mitm_test.go @@ -0,0 +1,281 @@ +package proxy_test + +import ( + "bufio" + "bytes" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/shiv/internal/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// startProxyServer starts the proxy as a real TCP listener and returns its address. +// httptest.NewRecorder does not support hijacking so we need a real listener for CONNECT. +func startProxyServer(t *testing.T, st *store.Store) string { + t.Helper() + bus := newTestBus(t, st) + p := newTestProxy(t, bus) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + srv := &http.Server{Handler: p} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + + return ln.Addr().String() +} + +// tlsUpstream starts an httptest TLS server. +func tlsUpstream(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + io.WriteString(w, body) + })) + t.Cleanup(srv.Close) + return srv +} + +// doConnectTunnel sends CONNECT to the proxy and returns the raw conn after the 200 response. +func doConnectTunnel(t *testing.T, proxyAddr, targetHost string) net.Conn { + t.Helper() + conn, err := net.DialTimeout("tcp", proxyAddr, 5*time.Second) + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + + fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", targetHost, targetHost) + + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "proxy must respond 200 to CONNECT") + + return conn +} + +// tlsOverTunnel wraps conn in a TLS client trusting the proxy's self-signed cert. +func tlsOverTunnel(t *testing.T, conn net.Conn, serverName string) *tls.Conn { + t.Helper() + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: serverName, + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, tlsConn.Handshake()) + t.Cleanup(func() { tlsConn.Close() }) + return tlsConn +} + +// sendHTTPOverConn writes a raw HTTP/1.1 request and reads the response. +func sendHTTPOverConn(t *testing.T, conn io.ReadWriter, method, path, host, body string) *http.Response { + t.Helper() + var req strings.Builder + fmt.Fprintf(&req, "%s %s HTTP/1.1\r\n", method, path) + fmt.Fprintf(&req, "Host: %s\r\n", host) + if body != "" { + fmt.Fprintf(&req, "Content-Length: %d\r\n", len(body)) + } + req.WriteString("Connection: close\r\n\r\n") + if body != "" { + req.WriteString(body) + } + + _, err := io.WriteString(conn, req.String()) + require.NoError(t, err) + + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + return resp +} + +// waitForTransaction blocks until a transaction appears on st.Updates or times out. +func waitForTransaction(t *testing.T, st *store.Store) store.Transaction { + t.Helper() + select { + case tx := <-st.Updates: + return tx + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for transaction to be logged") + return store.Transaction{} + } +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +func TestMITM_ConnectTunnel_ForwardsRequest(t *testing.T) { + st := newTestStore(t) + upstream := tlsUpstream(t, http.StatusOK, "mitm works") + + upstreamAddr := strings.TrimPrefix(upstream.URL, "https://") + host, port, err := net.SplitHostPort(upstreamAddr) + require.NoError(t, err) + targetHost := net.JoinHostPort(host, port) + + proxyAddr := startProxyServer(t, st) + conn := doConnectTunnel(t, proxyAddr, targetHost) + tlsConn := tlsOverTunnel(t, conn, host) + + resp := sendHTTPOverConn(t, tlsConn, "GET", "/", targetHost, "") + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + b, _ := io.ReadAll(resp.Body) + assert.Equal(t, "mitm works", string(b)) +} + +func TestMITM_ConnectTunnel_LogsTransactionWithTLSTrue(t *testing.T) { + st := newTestStore(t) + upstream := tlsUpstream(t, http.StatusOK, "logged") + + upstreamAddr := strings.TrimPrefix(upstream.URL, "https://") + host, port, err := net.SplitHostPort(upstreamAddr) + require.NoError(t, err) + targetHost := net.JoinHostPort(host, port) + + proxyAddr := startProxyServer(t, st) + conn := doConnectTunnel(t, proxyAddr, targetHost) + tlsConn := tlsOverTunnel(t, conn, host) + + resp := sendHTTPOverConn(t, tlsConn, "GET", "/log-test", targetHost, "") + resp.Body.Close() + + tx := waitForTransaction(t, st) + assert.True(t, tx.TLS, "transaction must be flagged as TLS") + assert.Equal(t, "GET", tx.Method) + assert.Equal(t, http.StatusOK, tx.StatusCode) +} + +func TestMITM_ConnectTunnel_CacheHeadersStripped(t *testing.T) { + st := newTestStore(t) + + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `"abc"`) + w.Header().Set("Cache-Control", "max-age=3600") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstream.Close) + + upstreamAddr := strings.TrimPrefix(upstream.URL, "https://") + host, port, err := net.SplitHostPort(upstreamAddr) + require.NoError(t, err) + targetHost := net.JoinHostPort(host, port) + + proxyAddr := startProxyServer(t, st) + conn := doConnectTunnel(t, proxyAddr, targetHost) + tlsConn := tlsOverTunnel(t, conn, host) + + resp := sendHTTPOverConn(t, tlsConn, "GET", "/", targetHost, "") + resp.Body.Close() + + assert.Empty(t, resp.Header.Get("ETag")) + assert.Equal(t, "no-store, no-cache, must-revalidate", resp.Header.Get("Cache-Control")) +} + +func TestMITM_ConnectTunnel_InterceptDrop_Returns403(t *testing.T) { + st := newTestStore(t) + upstream := tlsUpstream(t, http.StatusOK, "should not reach") + + upstreamAddr := strings.TrimPrefix(upstream.URL, "https://") + host, port, err := net.SplitHostPort(upstreamAddr) + require.NoError(t, err) + targetHost := net.JoinHostPort(host, port) + + st.Intercept.SetEnabled(true) + go func() { + pending := <-st.Intercept.Queue() + pending.Reply <- store.Decision{Forward: false} + }() + + proxyAddr := startProxyServer(t, st) + conn := doConnectTunnel(t, proxyAddr, targetHost) + tlsConn := tlsOverTunnel(t, conn, host) + + resp := sendHTTPOverConn(t, tlsConn, "GET", "/secret", targetHost, "") + resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestMITM_ConnectTunnel_InterceptForward_ModifiesRequest(t *testing.T) { + st := newTestStore(t) + + var receivedHeader string + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeader = r.Header.Get("X-Injected") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstream.Close) + + upstreamAddr := strings.TrimPrefix(upstream.URL, "https://") + host, port, err := net.SplitHostPort(upstreamAddr) + require.NoError(t, err) + targetHost := net.JoinHostPort(host, port) + + st.Intercept.SetEnabled(true) + go func() { + pending := <-st.Intercept.Queue() + pending.Request.Header.Set("X-Injected", "mitm-injected") + pending.Reply <- store.Decision{ + Forward: true, + Request: pending.Request, + Body: pending.Body, + } + }() + + proxyAddr := startProxyServer(t, st) + conn := doConnectTunnel(t, proxyAddr, targetHost) + tlsConn := tlsOverTunnel(t, conn, host) + + resp := sendHTTPOverConn(t, tlsConn, "GET", "/inject", targetHost, "") + resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "mitm-injected", receivedHeader) +} + +func TestMITM_ConnectTunnel_PostWithBody(t *testing.T) { + st := newTestStore(t) + + var receivedBody string + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + receivedBody = string(b) + w.WriteHeader(http.StatusCreated) + })) + t.Cleanup(upstream.Close) + + upstreamAddr := strings.TrimPrefix(upstream.URL, "https://") + host, port, err := net.SplitHostPort(upstreamAddr) + require.NoError(t, err) + targetHost := net.JoinHostPort(host, port) + + proxyAddr := startProxyServer(t, st) + conn := doConnectTunnel(t, proxyAddr, targetHost) + tlsConn := tlsOverTunnel(t, conn, host) + + body := `{"key":"value"}` + var req bytes.Buffer + fmt.Fprintf(&req, "POST /api HTTP/1.1\r\n") + fmt.Fprintf(&req, "Host: %s\r\n", targetHost) + fmt.Fprintf(&req, "Content-Type: application/json\r\n") + fmt.Fprintf(&req, "Content-Length: %d\r\n", len(body)) + fmt.Fprintf(&req, "Connection: close\r\n\r\n") + fmt.Fprintf(&req, "%s", body) + _, err = tlsConn.Write(req.Bytes()) + require.NoError(t, err) + + resp, err := http.ReadResponse(bufio.NewReader(tlsConn), nil) + require.NoError(t, err) + resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.Equal(t, body, receivedBody) +} -- cgit v1.2.3