package link import ( "context" "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // errDialNotExpected marks a test that must never reach the overlay, so a dial // that does happen fails the test rather than passing silently. var errDialNotExpected = errors.New("dial not expected in this test") func refuseDial(context.Context, string, string) (net.Conn, error) { return nil, errDialNotExpected } // overlayDialer stands in for the embedded client. It records what the // forwarder asked to reach and then connects to a local test server, which // lets the whole proxy path be exercised without a live network. type overlayDialer struct { target string calls atomic.Int64 asked atomic.Value } func (d *overlayDialer) dial(ctx context.Context, network, addr string) (net.Conn, error) { d.calls.Add(1) d.asked.Store(addr) var dialer net.Dialer return dialer.DialContext(ctx, network, d.target) } func (d *overlayDialer) lastAsked() string { v, _ := d.asked.Load().(string) return v } // startForwarder wires a forwarder in front of upstream and returns its local // base URL. func startForwarder(t *testing.T, upstream *httptest.Server) (string, *overlayDialer) { t.Helper() upstreamURL, err := url.Parse(upstream.URL) require.NoError(t, err) // The spec names a hostname that does not resolve on this machine, proving // the forwarder hands resolution to the dialer instead of the host. fwd, err := ParseForward("http://127.0.0.1:0=http://grafana.internal") require.NoError(t, err) dialer := &overlayDialer{target: upstreamURL.Host} f, err := newHTTPForwarder(fwd, dialer.dial) require.NoError(t, err) go func() { _ = f.Serve() }() t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = f.Close(ctx) }) return "http://" + f.Addr(), dialer } func TestForwarderProxiesThroughOverlayDialer(t *testing.T) { var gotHost, gotPath string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotHost, gotPath = r.Host, r.URL.RequestURI() _, _ = io.WriteString(w, "reached upstream") })) defer upstream.Close() base, dialer := startForwarder(t, upstream) resp, err := http.Get(base + "/dashboard?orgId=1") require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "reached upstream", string(body)) assert.Equal(t, "/dashboard?orgId=1", gotPath, "path and query should be forwarded unchanged") assert.Equal(t, "grafana.internal", gotHost, "upstream must see its own Host, not the local bind address") assert.Equal(t, int64(1), dialer.calls.Load(), "the overlay dialer should carry the request") assert.Equal(t, "grafana.internal:80", dialer.lastAsked(), "the overlay dialer should be asked for the upstream name, so it resolves in the tunnel") } func TestForwarderForwardsRequestBodyAndMethod(t *testing.T) { var gotMethod, gotBody string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method b, _ := io.ReadAll(r.Body) gotBody = string(b) w.WriteHeader(http.StatusCreated) })) defer upstream.Close() base, _ := startForwarder(t, upstream) resp, err := http.Post(base+"/api", "application/json", strings.NewReader(`{"k":"v"}`)) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.MethodPost, gotMethod) assert.Equal(t, `{"k":"v"}`, gotBody, "request bodies should reach the upstream intact") } // A forward whose upstream cannot be reached must answer 502 rather than hang // or leak the dial error to the caller. func TestForwarderReportsUnreachableUpstream(t *testing.T) { fwd, err := ParseForward("http://127.0.0.1:0=http://grafana.internal") require.NoError(t, err) refuse := func(context.Context, string, string) (net.Conn, error) { return nil, &net.OpError{Op: "dial", Err: net.UnknownNetworkError("no route in overlay")} } f, err := newHTTPForwarder(fwd, refuse) require.NoError(t, err) go func() { _ = f.Serve() }() t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = f.Close(ctx) }) resp, err := http.Get("http://" + f.Addr()) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadGateway, resp.StatusCode) } // Binding must fail at construction so a port clash surfaces before any // forward is announced as ready. func TestNewHTTPForwarderReportsBindConflict(t *testing.T) { busy, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) defer busy.Close() fwd, err := ParseForward("http://" + busy.Addr().String() + "=https://grafana.internal") require.NoError(t, err) _, err = newHTTPForwarder(fwd, refuseDial) require.Error(t, err) assert.Contains(t, err.Error(), "listen on") } // A forwarder that was built but never served still owns its socket. The // bind-all-then-serve path closes earlier forwarders when a later bind fails, // so Close has to release the port even though Serve never ran. func TestCloseReleasesAnUnservedListener(t *testing.T) { fwd, err := ParseForward("http://127.0.0.1:0=https://grafana.internal") require.NoError(t, err) f, err := newHTTPForwarder(fwd, refuseDial) require.NoError(t, err) addr := f.Addr() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() require.NoError(t, f.Close(ctx)) // The port is free again only if the listener was actually closed. reclaimed, err := net.Listen("tcp", addr) require.NoError(t, err, "Close should have released the listener") require.NoError(t, reclaimed.Close()) } // A caller that sends headers and then stalls its body must be cut off. // ReadHeaderTimeout does not cover the body and IdleTimeout applies only // between requests, so without the idle read deadline this connection would be // held open indefinitely. func TestStalledRequestBodyIsCutOff(t *testing.T) { previous := bodyIdleTimeout bodyIdleTimeout = 150 * time.Millisecond t.Cleanup(func() { bodyIdleTimeout = previous }) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) })) defer upstream.Close() base, _ := startForwarder(t, upstream) conn, err := net.Dial("tcp", strings.TrimPrefix(base, "http://")) require.NoError(t, err) defer conn.Close() // Announce a body, send one byte of it, then go quiet. _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000\r\n\r\nx")) require.NoError(t, err) require.NoError(t, conn.SetReadDeadline(time.Now().Add(10*time.Second))) _, err = io.ReadAll(conn) // The server closes the connection rather than waiting on the rest of the // body, so the read ends well before the generous deadline above. require.NoError(t, err, "the connection should be closed by the server, not time out in the test") } func TestRedactSpec(t *testing.T) { assert.Equal(t, "http://8080=https://user:xxxxx@a.internal", redactSpec("http://8080=https://user:hunter2@a.internal"), "a password must not survive into an error message") assert.Equal(t, "http://8080=https://a.internal", redactSpec("http://8080=https://a.internal"), "a spec without credentials should be unchanged") } // The error that rejects a credential-bearing upstream quotes the spec, which // must not carry the password back to the terminal. func TestParseForwardDoesNotEchoPassword(t *testing.T) { _, err := ParseForward("http://8080=https://user:hunter2@a.internal") require.Error(t, err) assert.NotContains(t, err.Error(), "hunter2", "the password must not appear anywhere in the error") assert.Contains(t, err.Error(), "must not carry credentials") } // A POST whose body is fully delivered must still receive its whole response, // even when the upstream takes longer to answer than the body idle timeout. // The deadline that guards the body must not outlive it. func TestCompletedUploadGetsFullResponse(t *testing.T) { previous := bodyIdleTimeout bodyIdleTimeout = 200 * time.Millisecond t.Cleanup(func() { bodyIdleTimeout = previous }) payload := strings.Repeat("y", 4096) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) time.Sleep(600 * time.Millisecond) _, _ = io.WriteString(w, payload) })) defer upstream.Close() base, _ := startForwarder(t, upstream) resp, err := http.Post(base+"/upload", "text/plain", strings.NewReader(strings.Repeat("x", 2048))) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err, "the response must not be cut off once the body was fully read") assert.Equal(t, len(payload), len(body), "the whole response body should arrive") } // A page that points its own hostname at this loopback listener must not be // able to reach the upstream through it. func TestLoopbackForwarderRejectsForeignHost(t *testing.T) { var reached bool upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) defer upstream.Close() base, _ := startForwarder(t, upstream) req, err := http.NewRequest(http.MethodGet, base, nil) require.NoError(t, err) req.Host = "attacker.example" resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusMisdirectedRequest, resp.StatusCode) assert.False(t, reached, "the upstream must not be reached under a foreign Host") } func TestLoopbackForwarderRejectsCrossOrigin(t *testing.T) { var reached bool upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) defer upstream.Close() base, _ := startForwarder(t, upstream) req, err := http.NewRequest(http.MethodGet, base, nil) require.NoError(t, err) req.Header.Set("Origin", "https://attacker.example") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.False(t, reached, "the upstream must not be reached from a cross-site page") } // The ordinary local callers must keep working: no Origin at all, and a // same-origin browser request. func TestLoopbackForwarderAllowsLocalCallers(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "ok") })) defer upstream.Close() base, _ := startForwarder(t, upstream) for _, origin := range []string{"", "http://" + strings.TrimPrefix(base, "http://")} { req, err := http.NewRequest(http.MethodGet, base, nil) require.NoError(t, err) if origin != "" { req.Header.Set("Origin", origin) } resp, err := http.DefaultClient.Do(req) require.NoError(t, err) body, err := io.ReadAll(resp.Body) require.NoError(t, err) _ = resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode, "origin %q should be allowed", origin) assert.Equal(t, "ok", string(body)) } } // A malformed upstream fails in the URL parser rather than in the credential // check, so both error layers have to be redacted. func TestParseForwardRedactsMalformedUpstream(t *testing.T) { for _, spec := range []string{ "http://8080=https://user:hunter2@a.internal:bad", "http://8080=https://user:hunter2@a.internal/%zz", } { _, err := ParseForward(spec) require.Error(t, err) assert.NotContains(t, err.Error(), "hunter2", "a password must not survive a parse failure either") } } // An embedded or