diff --git a/cmd/sowerd/main.go b/cmd/sowerd/main.go index 4295217..2cdb6e0 100644 --- a/cmd/sowerd/main.go +++ b/cmd/sowerd/main.go @@ -368,24 +368,35 @@ func handleConn(conn net.Conn, fakeSite string, router siteRouter, handlers []pr slog.Debug("protocol auth or decode failed, fallback", "protocol", handler.Name(), "error", err) _ = rereadConn.SetReadDeadline(time.Time{}) rereadConn.Stop().Reread() - dur, err = fallbackConn(rereadConn, conn, fakeSite, router, &hijacked) + dur, err = fallbackConn(rereadConn, conn, fakeSite, router, &hijacked, probeBuf) return } } + probeBuf = extendHTTPRequestProbe(rereadConn, probeBuf) rereadConn.Stop().Reread() _ = rereadConn.SetReadDeadline(time.Time{}) - dur, err = fallbackConn(rereadConn, conn, fakeSite, router, &hijacked) + dur, err = fallbackConn(rereadConn, conn, fakeSite, router, &hijacked, probeBuf) } -func fallbackConn(conn net.Conn, tlsConn net.Conn, fakeSite string, router siteRouter, hijacked *atomic.Bool) (time.Duration, error) { +func fallbackConn(conn net.Conn, tlsConn net.Conn, fakeSite string, router siteRouter, hijacked *atomic.Bool, probe []byte) (time.Duration, error) { start := time.Now() if entry := router.lookup(sniFromConn(tlsConn)); entry != nil { return time.Since(start), reverseProxyConn(conn, entry, hijacked) } + if httpRequestLineProbe(probe) { + return time.Since(start), reverseProxyConn(conn, newFakeSiteEntry(fakeSite), hijacked) + } return relay.RelayTo(conn, fakeSite) } +func newFakeSiteEntry(fakeSite string) *siteEntry { + return &siteEntry{ + upstream: &url.URL{Scheme: "http", Host: fakeSite}, + preserveHost: true, + } +} + func sanitizeConfig(cfg config.SowerdConfig) map[string]any { return map[string]any{ "log_level": cfg.LogLevel.String(), @@ -481,8 +492,9 @@ type pathRoute struct { // siteEntry is the routing target for one site route: a default upstream plus // optional path-prefix overrides. type siteEntry struct { - upstream *url.URL - paths []pathRoute + upstream *url.URL + paths []pathRoute + preserveHost bool // fake-site fallback keeps the original Host header } // resolve returns the upstream URL for the given request path. A configured @@ -546,6 +558,31 @@ func sniFromConn(conn net.Conn) string { return tlsConn.ConnectionState().ServerName } +func stripResidualForwardingHeaders(h http.Header) { + h.Del("X-Real-IP") + for k := range h { + if strings.HasPrefix(k, "X-Forwarded-") { + h.Del(k) + } + } +} + +// setEdgeProxyHeaders overwrites forwarding headers from the TLS-edge peer. +// ReverseProxy.Rewrite already deleted Forwarded / X-Forwarded-For / +// X-Forwarded-Host / X-Forwarded-Proto and does not auto-populate them. +// X-Forwarded-Proto is https because this path is the 443 listener after TLS +// termination (req.TLS is nil on the decrypted conn). +func setEdgeProxyHeaders(req *http.Request, forwardedHost, remoteAddr string) { + if forwardedHost != "" { + req.Header.Set("X-Forwarded-Host", forwardedHost) + } + req.Header.Set("X-Forwarded-Proto", "https") + if clientIP, _, err := net.SplitHostPort(remoteAddr); err == nil { + req.Header.Set("X-Forwarded-For", clientIP) + req.Header.Set("X-Real-IP", clientIP) + } +} + // reverseProxyConn serves the decrypted HTTP connection through a reverse // proxy to the upstream selected per request by the site entry's path routes. // Reverse proxies and their transports are created lazily per upstream and @@ -559,11 +596,17 @@ func reverseProxyConn(conn net.Conn, entry *siteEntry, hijacked *atomic.Bool) er if proxy, ok := proxies[key]; ok { return proxy } - proxy := httputil.NewSingleHostReverseProxy(upstream) - baseDirector := proxy.Director - proxy.Director = func(req *http.Request) { - baseDirector(req) - req.Host = upstream.Host + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(upstream) + if entry.preserveHost { + pr.Out.Host = pr.In.Host + } else { + pr.Out.Host = upstream.Host + } + stripResidualForwardingHeaders(pr.Out.Header) + setEdgeProxyHeaders(pr.Out, pr.In.Host, pr.In.RemoteAddr) + }, } transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, diff --git a/cmd/sowerd/main_test.go b/cmd/sowerd/main_test.go index d0b68c8..b82d721 100644 --- a/cmd/sowerd/main_test.go +++ b/cmd/sowerd/main_test.go @@ -1,12 +1,10 @@ package main import ( - "bufio" "crypto/sha1" "crypto/tls" "encoding/base64" "errors" - "fmt" "log/slog" "net" "net/http" @@ -274,6 +272,125 @@ func TestReverseProxyConnHTTP(t *testing.T) { } } +type connWithRemoteAddr struct { + net.Conn + addr net.Addr +} + +func (c connWithRemoteAddr) RemoteAddr() net.Addr { return c.addr } + +func TestReverseProxyConnForwardingHeaders(t *testing.T) { + const clientIP = "203.0.113.10" + gotCh := make(chan *http.Request, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCh <- r.Clone(r.Context()) + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + upstreamURL, _ := url.Parse(upstream.URL) + rawServer, clientConn := net.Pipe() + serverConn := connWithRemoteAddr{ + Conn: rawServer, + addr: &net.TCPAddr{IP: net.ParseIP(clientIP), Port: 54321}, + } + + errCh := make(chan error, 1) + go func() { + errCh <- reverseProxyConn(serverConn, &siteEntry{upstream: upstreamURL}, &atomic.Bool{}) + }() + go func() { + _, _ = clientConn.Write([]byte( + "GET /test HTTP/1.1\r\n" + + "Host: a.example.com\r\n" + + "X-Forwarded-For: 1.2.3.4\r\n" + + "X-Forwarded-Proto: http\r\n" + + "X-Forwarded-Host: spoofed.example\r\n" + + "X-Forwarded-Port: 80\r\n" + + "X-Real-IP: 1.2.3.4\r\n\r\n")) + }() + + _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 4096) + _, _ = clientConn.Read(buf) + _ = clientConn.Close() + + select { + case got := <-gotCh: + if got.Host != upstreamURL.Host { + t.Errorf("Host = %q, want upstream %q", got.Host, upstreamURL.Host) + } + if got.Header.Get("X-Forwarded-For") != clientIP { + t.Errorf("X-Forwarded-For = %q, want %q", got.Header.Get("X-Forwarded-For"), clientIP) + } + if got.Header.Get("X-Real-IP") != clientIP { + t.Errorf("X-Real-IP = %q, want %q", got.Header.Get("X-Real-IP"), clientIP) + } + if got.Header.Get("X-Forwarded-Proto") != "https" { + t.Errorf("X-Forwarded-Proto = %q, want https", got.Header.Get("X-Forwarded-Proto")) + } + if got.Header.Get("X-Forwarded-Host") != "a.example.com" { + t.Errorf("X-Forwarded-Host = %q, want a.example.com", got.Header.Get("X-Forwarded-Host")) + } + if got.Header.Get("X-Forwarded-Port") != "" { + t.Errorf("X-Forwarded-Port = %q, want empty", got.Header.Get("X-Forwarded-Port")) + } + case <-time.After(2 * time.Second): + t.Fatal("upstream did not receive request") + } + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("reverseProxyConn error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("reverseProxyConn timed out") + } +} + +func TestReverseProxyConnPreserveHost(t *testing.T) { + gotCh := make(chan string, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCh <- r.Host + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + upstreamURL, _ := url.Parse(upstream.URL) + serverConn, clientConn := net.Pipe() + errCh := make(chan error, 1) + go func() { + errCh <- reverseProxyConn(serverConn, &siteEntry{upstream: upstreamURL, preserveHost: true}, &atomic.Bool{}) + }() + go func() { + _, _ = clientConn.Write([]byte("GET / HTTP/1.1\r\nHost: miss.example.com\r\n\r\n")) + }() + + _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 4096) + _, _ = clientConn.Read(buf) + _ = clientConn.Close() + + select { + case got := <-gotCh: + if got != "miss.example.com" { + t.Fatalf("Host = %q, want miss.example.com", got) + } + case <-time.After(2 * time.Second): + t.Fatal("upstream did not receive request") + } + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("reverseProxyConn error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("reverseProxyConn timed out") + } +} + func TestHandleConnRoutesFallbackBySNI(t *testing.T) { var upstreamHost string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -588,7 +705,13 @@ func websocketAcceptKey(key string) string { } func TestHandleConnFallsBackToFakeSiteWhenSNIMisses(t *testing.T) { - fakeSite := startHTTP1TCPServer(t, "fake-site-fallback") + gotCh := make(chan *http.Request, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCh <- r.Clone(r.Context()) + _, _ = w.Write([]byte("fake-site-fallback")) + })) + defer upstream.Close() + upstreamURL, _ := url.Parse(upstream.URL) certServer := httptest.NewTLSServer(http.NotFoundHandler()) cert := certServer.TLS.Certificates[0] @@ -609,14 +732,21 @@ func TestHandleConnFallsBackToFakeSiteWhenSNIMisses(t *testing.T) { router := newSiteRouter([]config.SiteRoute{ {Domains: []string{"route.example.com"}, Upstream: "http://127.0.0.1:1"}, }) - go handleConn(serverTLS, fakeSite, router, []proxyProtocolHandler{newSowerProtocolHandler(transportSower.New("secret"))}) + go handleConn(serverTLS, upstreamURL.Host, router, []proxyProtocolHandler{newSowerProtocolHandler(transportSower.New("secret"))}) if err := clientTLS.Handshake(); err != nil { t.Fatalf("tls handshake: %v", err) } padding := strings.Repeat("x", 512) - _, _ = clientTLS.Write([]byte("GET /fallback HTTP/1.1\r\nHost: miss.example.com\r\nX-Pad: " + padding + "\r\n\r\n")) + _, _ = clientTLS.Write([]byte( + "GET /fallback HTTP/1.1\r\n" + + "Host: miss.example.com\r\n" + + "X-Forwarded-For: 1.2.3.4\r\n" + + "X-Forwarded-Proto: http\r\n" + + "X-Forwarded-Host: spoofed.example\r\n" + + "X-Real-IP: 1.2.3.4\r\n" + + "X-Pad: " + padding + "\r\n\r\n")) _ = clientTLS.SetReadDeadline(time.Now().Add(2 * time.Second)) buf := make([]byte, 4096) @@ -628,6 +758,123 @@ func TestHandleConnFallsBackToFakeSiteWhenSNIMisses(t *testing.T) { if !strings.Contains(resp, "fake-site-fallback") { t.Fatalf("response does not contain fake site body: %q", resp) } + + select { + case got := <-gotCh: + if got.Host != "miss.example.com" { + t.Errorf("Host = %q, want miss.example.com", got.Host) + } + if got.Header.Get("X-Forwarded-Proto") != "https" { + t.Errorf("X-Forwarded-Proto = %q, want https", got.Header.Get("X-Forwarded-Proto")) + } + if got.Header.Get("X-Forwarded-Host") != "miss.example.com" { + t.Errorf("X-Forwarded-Host = %q, want miss.example.com", got.Header.Get("X-Forwarded-Host")) + } + if got.Header.Get("X-Forwarded-For") == "1.2.3.4" { + t.Errorf("X-Forwarded-For still spoofed: %q", got.Header.Get("X-Forwarded-For")) + } + if got.Header.Get("X-Real-IP") == "1.2.3.4" { + t.Errorf("X-Real-IP still spoofed: %q", got.Header.Get("X-Real-IP")) + } + case <-time.After(2 * time.Second): + t.Fatal("upstream did not receive request") + } +} + +func TestHandleConnFakeSiteHTTPSplitRequestLineSetsProxyHeaders(t *testing.T) { + gotCh := make(chan *http.Request, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCh <- r.Clone(r.Context()) + _, _ = w.Write([]byte("split-fallback")) + })) + defer upstream.Close() + upstreamURL, _ := url.Parse(upstream.URL) + + certServer := httptest.NewTLSServer(http.NotFoundHandler()) + cert := certServer.TLS.Certificates[0] + certServer.Close() + + serverRaw, clientRaw := net.Pipe() + serverTLS := tls.Server(serverRaw, &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"http/1.1"}, + }) + clientTLS := tls.Client(clientRaw, &tls.Config{ + InsecureSkipVerify: true, + ServerName: "miss.example.com", + NextProtos: []string{"http/1.1"}, + }) + defer clientTLS.Close() + + go handleConn(serverTLS, upstreamURL.Host, siteRouter{}, []proxyProtocolHandler{newSowerProtocolHandler(transportSower.New("secret"))}) + + if err := clientTLS.Handshake(); err != nil { + t.Fatalf("tls handshake: %v", err) + } + + _, _ = clientTLS.Write([]byte("GET /fall")) + _, _ = clientTLS.Write([]byte("back HTTP/1.1\r\nHost: miss.example.com\r\n\r\n")) + + _ = clientTLS.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 4096) + n, err := clientTLS.Read(buf) + if err != nil { + t.Fatalf("read response: %v", err) + } + if !strings.Contains(string(buf[:n]), "split-fallback") { + t.Fatalf("response does not contain fake site body: %q", buf[:n]) + } + + select { + case got := <-gotCh: + if got.Header.Get("X-Forwarded-Proto") != "https" { + t.Errorf("X-Forwarded-Proto = %q, want https", got.Header.Get("X-Forwarded-Proto")) + } + if got.Host != "miss.example.com" { + t.Errorf("Host = %q, want miss.example.com", got.Host) + } + case <-time.After(2 * time.Second): + t.Fatal("upstream did not receive request") + } +} + +func TestHandleConnRelaysNonHTTPFakeSiteWhenProbeMisses(t *testing.T) { + fakeSite := startRawTCPServer(t, "raw-fallback") + + certServer := httptest.NewTLSServer(http.NotFoundHandler()) + cert := certServer.TLS.Certificates[0] + certServer.Close() + + serverRaw, clientRaw := net.Pipe() + serverTLS := tls.Server(serverRaw, &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"http/1.1"}, + }) + clientTLS := tls.Client(clientRaw, &tls.Config{ + InsecureSkipVerify: true, + ServerName: "miss.example.com", + NextProtos: []string{"http/1.1"}, + }) + defer clientTLS.Close() + + go handleConn(serverTLS, fakeSite, siteRouter{}, []proxyProtocolHandler{newSowerProtocolHandler(transportSower.New("secret"))}) + + if err := clientTLS.Handshake(); err != nil { + t.Fatalf("tls handshake: %v", err) + } + _ = clientTLS.SetDeadline(time.Now().Add(2 * time.Second)) + if _, err := clientTLS.Write([]byte("not-http-payload")); err != nil { + t.Fatalf("write non-http payload: %v", err) + } + + buf := make([]byte, 4096) + n, err := clientTLS.Read(buf) + if err != nil { + t.Fatalf("read fallback response: %v", err) + } + if !strings.Contains(string(buf[:n]), "raw-fallback") { + t.Fatalf("response does not contain raw fallback body: %q", buf[:n]) + } } func TestHandleConnFallsBackAfterSowerAuthFailure(t *testing.T) { @@ -670,33 +917,98 @@ func TestHandleConnFallsBackAfterSowerAuthFailure(t *testing.T) { } } -func startHTTP1TCPServer(t *testing.T, body string) string { - t.Helper() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen fake site: %v", err) +func TestHTTPRequestLineProbe(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"GET / HTTP/1.1\r\nHost: x\r\n\r\n", true}, + {"POST /submit HTTP/1.0\r\n\r\n", true}, + {"CONNECT example.com:443 HTTP/1.1\r\n", true}, + {"GET / HTTP/1.1\nHost: x\n\n", true}, + {"GET / HTTP/1.1", true}, + {"GET / HTTP/1.1\r", true}, + {"GET / HTTP/1.10\r\n", false}, + {"GET / HTTP/1.1 extra\r\n", false}, + {"GET /fall", false}, + {"hello", false}, + {"\x80xxxx", false}, + {"GETX / HTTP/1.1\r\n", false}, } - t.Cleanup(func() { _ = ln.Close() }) + for _, tt := range tests { + if got := httpRequestLineProbe([]byte(tt.in)); got != tt.want { + t.Errorf("httpRequestLineProbe(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestCouldBeHTTPRequestLine(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"G", true}, + {"GET", true}, + {"GET /fall", true}, + {"GET / HTTP/1.", true}, + {"GET / HTTP/1.1\r", true}, + {"GET / HTTP/1.1\r\n", false}, + {"hello", false}, + {"\x80", false}, + {"GET / HTTP/1.10", false}, + } + for _, tt := range tests { + if got := couldBeHTTPRequestLine([]byte(tt.in)); got != tt.want { + t.Errorf("couldBeHTTPRequestLine(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestNewFakeSiteEntry(t *testing.T) { + e := newFakeSiteEntry("127.0.0.1:9080") + if e.upstream.String() != "http://127.0.0.1:9080" { + t.Fatalf("upstream = %q, want http://127.0.0.1:9080", e.upstream.String()) + } + if !e.preserveHost { + t.Fatal("preserveHost = false, want true") + } + v6 := newFakeSiteEntry("[::1]:8080") + if v6.upstream.Host != "[::1]:8080" { + t.Fatalf("ipv6 host = %q, want [::1]:8080", v6.upstream.Host) + } +} + +func TestExtendHTTPRequestProbeReadsSplitRequestLine(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() go func() { - conn, err := ln.Accept() - if err != nil { - return - } - defer conn.Close() - - r := bufio.NewReader(conn) - for { - line, err := r.ReadString('\n') - if err != nil || line == "\r\n" || line == "\n" { - break - } - } - _, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: " + fmt.Sprint(len(body)) + "\r\n\r\n" + body)) + _, _ = client.Write([]byte("GET /fall")) + _, _ = client.Write([]byte("back HTTP/1.1\r\nHost: x\r\n\r\n")) }() - return ln.Addr().String() + _ = server.SetReadDeadline(time.Now().Add(2 * time.Second)) + first := make([]byte, protocolProbeMaxBytes) + n, err := server.Read(first) + if err != nil { + t.Fatalf("first read: %v", err) + } + got := extendHTTPRequestProbe(server, first[:n]) + if !httpRequestLineProbe(got) { + t.Fatalf("extended probe not HTTP request line: %q", got) + } +} + +func TestExtendHTTPRequestProbeSkipsNonHTTP(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + got := extendHTTPRequestProbe(server, []byte("not-http")) + if string(got) != "not-http" { + t.Fatalf("probe = %q, want not-http", got) + } } func startRawTCPServer(t *testing.T, body string) string { diff --git a/cmd/sowerd/probe.go b/cmd/sowerd/probe.go index 81a56f9..7ce181b 100644 --- a/cmd/sowerd/probe.go +++ b/cmd/sowerd/probe.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "net" "time" @@ -57,6 +58,105 @@ func (h sowerProtocolHandler) Unwrap(conn net.Conn) (net.Addr, error) { return h.transport.Unwrap(conn) } +func httpRequestLine(buf []byte) (line []byte, complete bool) { + i := bytes.IndexByte(buf, '\n') + if i < 0 { + return buf, false + } + line = buf[:i] + if len(line) > 0 && line[len(line)-1] == '\r' { + line = line[:len(line)-1] + } + return line, true +} + +func httpRequestLineProbe(buf []byte) bool { + line, _ := httpRequestLine(buf) + method, rest, ok := bytes.Cut(line, []byte{' '}) + if !ok || !isExactHTTPMethod(method) { + return false + } + target, version, ok := bytes.Cut(rest, []byte{' '}) + if !ok || len(target) == 0 { + return false + } + version = bytes.TrimSuffix(version, []byte{'\r'}) + if bytes.IndexByte(version, ' ') >= 0 { + return false + } + return string(version) == "HTTP/1.1" || string(version) == "HTTP/1.0" +} + +func isExactHTTPMethod(m []byte) bool { + switch string(m) { + case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT": + return true + default: + return false + } +} + +func isHTTPMethodPrefix(b []byte) bool { + if len(b) == 0 { + return true + } + for _, method := range []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"} { + if bytes.HasPrefix([]byte(method), b) { + return true + } + } + return false +} + +func isHTTPVersionPrefix(b []byte) bool { + b = bytes.TrimSuffix(b, []byte{'\r'}) + return bytes.HasPrefix([]byte("HTTP/1.1"), b) || bytes.HasPrefix([]byte("HTTP/1.0"), b) +} + +func couldBeHTTPRequestLine(buf []byte) bool { + line, complete := httpRequestLine(buf) + if complete { + return false + } + method, rest, hasSpace := bytes.Cut(line, []byte{' '}) + if !hasSpace { + return isHTTPMethodPrefix(method) + } + if !isExactHTTPMethod(method) { + return false + } + _, version, hasVersion := bytes.Cut(rest, []byte{' '}) + if !hasVersion { + return true + } + if bytes.IndexByte(version, ' ') >= 0 { + return false + } + return isHTTPVersionPrefix(version) +} + +func extendHTTPRequestProbe(conn net.Conn, probe []byte) []byte { + if httpRequestLineProbe(probe) || !couldBeHTTPRequestLine(probe) { + return probe + } + buf := make([]byte, protocolProbeMaxBytes) + n := copy(buf, probe) + for n < protocolProbeMaxBytes { + nr, err := conn.Read(buf[n:]) + if nr > 0 { + n += nr + if httpRequestLineProbe(buf[:n]) || !couldBeHTTPRequestLine(buf[:n]) { + return buf[:n] + } + continue + } + if err != nil { + return buf[:n] + } + } + return buf[:n] +} + func readProtocolProbe(conn net.Conn) ([]byte, error) { buf := make([]byte, protocolProbeMaxBytes) for {