package httpmw_test

import (
	"crypto/tls"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/coderd/httpmw"
)

// TestExtractAddress checks the ExtractAddress function.
func TestExtractAddress(t *testing.T) {
	t.Parallel()

	tests := []struct {
		Name               string
		Config             *httpmw.RealIPConfig
		Header             http.Header
		RemoteAddr         string
		TLS                bool
		ExpectedRemoteAddr string
		ExpectedTLS        bool
	}{
		{
			Name:               "default-nil-config",
			RemoteAddr:         "123.45.67.89",
			ExpectedRemoteAddr: "123.45.67.89",
		},
		{
			Name:               "default-empty-config",
			RemoteAddr:         "123.45.67.89",
			ExpectedRemoteAddr: "123.45.67.89",
			Config:             &httpmw.RealIPConfig{},
		},
		{
			Name: "default-filter-headers",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("10.0.0.0"),
						Mask: net.CIDRMask(8, 32),
					},
				},
			},
			RemoteAddr: "123.45.67.89",
			Header: http.Header{
				"X-Forwarded-For": []string{
					"127.0.0.1",
					"10.0.0.5",
					"10.0.0.5,4.4.4.4",
				},
			},
			ExpectedRemoteAddr: "123.45.67.89",
		},
		{
			Name: "multiple-x-forwarded-for",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("0.0.0.0"),
						Mask: net.CIDRMask(0, 32),
					},
				},
				TrustedHeaders: []string{
					"X-Forwarded-For",
				},
			},
			RemoteAddr: "123.45.67.89",
			Header: http.Header{
				"X-Forwarded-For": []string{
					"10.24.1.1,1.2.3.4,1.1.1.1,4.5.6.7",
					"10.0.0.5",
					"10.0.0.5,4.4.4.4",
				},
			},
			ExpectedRemoteAddr: "10.24.1.1",
		},
		{
			Name: "single-real-ip",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("0.0.0.0"),
						Mask: net.CIDRMask(0, 32),
					},
				},
				TrustedHeaders: []string{
					"X-Real-Ip",
				},
			},
			RemoteAddr: "123.45.67.89",
			TLS:        true,
			Header: http.Header{
				"X-Real-Ip": []string{"8.8.8.8"},
			},
			ExpectedRemoteAddr: "8.8.8.8",
			ExpectedTLS:        true,
		},
		{
			Name: "multiple-real-ip",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("0.0.0.0"),
						Mask: net.CIDRMask(0, 32),
					},
				},
				TrustedHeaders: []string{
					"X-Real-Ip",
				},
			},
			RemoteAddr: "123.45.67.89",
			Header: http.Header{
				"X-Real-Ip": []string{"4.4.4.4", "8.8.8.8"},
			},
			ExpectedRemoteAddr: "4.4.4.4",
		},
		{
			// Has X-Forwarded-For and X-Real-Ip, prefers X-Real-Ip
			Name: "prefer-real-ip",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("0.0.0.0"),
						Mask: net.CIDRMask(0, 32),
					},
				},
				TrustedHeaders: []string{
					"X-Real-Ip",
					"X-Forwarded-For",
				},
			},
			RemoteAddr: "123.45.67.89",
			Header: http.Header{
				"X-Forwarded-For": []string{"8.8.8.8"},
				"X-Real-Ip":       []string{"4.4.4.4"},
			},
			ExpectedRemoteAddr: "4.4.4.4",
		},
		{
			// Has X-Forwarded-For, X-Real-Ip, and True-Client-Ip, prefers
			// True-Client-Ip
			Name: "prefer-true-client-ip",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("123.45.0.0"),
						Mask: net.CIDRMask(16, 32),
					},
				},
				TrustedHeaders: []string{
					"True-Client-Ip",
					"X-Forwarded-For",
					"X-Real-Ip",
				},
			},
			RemoteAddr: "123.45.67.89",
			TLS:        true,
			Header: http.Header{
				"X-Forwarded-For": []string{"1.2.3.4"},
				"X-Real-Ip":       []string{"4.4.4.4", "8.8.8.8"},
				"True-Client-Ip":  []string{"5.6.7.8", "9.8.7.6"},
			},
			ExpectedRemoteAddr: "5.6.7.8",
			ExpectedTLS:        true,
		},
		{
			// Has X-Forwarded-For, X-Real-Ip, True-Client-Ip, and
			// Cf-Connecting-Ip, prefers Cf-Connecting-Ip
			Name: "prefer-cf-connecting-ip",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("123.45.67.89"),
						Mask: net.CIDRMask(32, 32),
					},
				},
				TrustedHeaders: []string{
					"Cf-Connecting-Ip",
					"X-Forwarded-For",
					"X-Real-Ip",
					"True-Client-Ip",
				},
			},
			RemoteAddr: "123.45.67.89",
			Header: http.Header{
				"X-Forwarded-For":  []string{"1.2.3.4,100.12.1.3,10.10.10.10"},
				"X-Real-Ip":        []string{"4.4.4.4", "8.8.8.8"},
				"True-Client-Ip":   []string{"5.6.7.8", "9.8.7.6"},
				"Cf-Connecting-Ip": []string{"100.10.2.2"},
			},
			ExpectedRemoteAddr: "100.10.2.2",
		},
	}

	for _, test := range tests {
		test := test
		t.Run(test.Name, func(t *testing.T) {
			t.Parallel()

			req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)

			// Default to a direct (unproxied) connection over HTTP
			req.RemoteAddr = test.RemoteAddr
			if test.TLS {
				req.TLS = &tls.ConnectionState{}
			} else {
				req.TLS = nil
			}
			req.Header = test.Header

			info, err := httpmw.ExtractRealIPAddress(test.Config, req)
			require.NoError(t, err, "unexpected error in ExtractAddress")
			require.Equal(t, test.ExpectedRemoteAddr, info.String(), "expected info.String() to match")
		})
	}
}

// TestTrustedOrigins tests different settings for TrustedOrigins.
func TestTrustedOrigins(t *testing.T) {
	t.Parallel()

	// Remote client protocol: HTTP or HTTPS
	for _, proto := range []string{"http", "https"} {
		// Trusted origin
		// all: default behavior, trust all origins
		// none: use an empty set (nothing will be accepted in this case)
		// ipv4: trust an IPv6 network
		// ipv6: trust an IPv4 network
		for _, trusted := range []string{"none", "ipv4", "ipv6"} {
			for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} {
				trusted := trusted
				header := header
				proto := proto
				name := fmt.Sprintf("%s-%s-%s", trusted, proto, strings.ToLower(header))

				t.Run(name, func(t *testing.T) {
					t.Parallel()

					remoteAddr := "10.10.10.10"
					actualAddr := "12.34.56.78"

					config := &httpmw.RealIPConfig{
						TrustedHeaders: []string{
							"Cf-Connecting-Ip",
							"X-Forwarded-For",
							"X-Real-Ip",
							"True-Client-Ip",
						},
					}
					switch trusted {
					case "none":
						config.TrustedOrigins = []*net.IPNet{}
					case "ipv4":
						config.TrustedOrigins = []*net.IPNet{
							{
								IP:   net.ParseIP("10.0.0.0"),
								Mask: net.CIDRMask(24, 32),
							},
						}
						remoteAddr = "10.0.0.1"
					case "ipv6":
						config.TrustedOrigins = []*net.IPNet{
							{
								IP:   net.ParseIP("2606:4700::0"),
								Mask: net.CIDRMask(32, 128),
							},
						}
						remoteAddr = "2606:4700:4700::1111"
					}

					middleware := httpmw.ExtractRealIP(config)
					req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
					req.Header.Set(header, actualAddr)
					req.RemoteAddr = remoteAddr
					if proto == "https" {
						req.TLS = &tls.ConnectionState{}
					}

					handlerCalled := false

					nextHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
						// If nothing is trusted, the remoteAddr should be unchanged
						if trusted == "none" {
							require.Equal(t, remoteAddr, req.RemoteAddr, "remote address should be unchanged")
						} else {
							require.Equal(t, actualAddr, req.RemoteAddr, "actual address should be trusted")
						}

						handlerCalled = true
					})

					middleware(nextHandler).ServeHTTP(httptest.NewRecorder(), req)

					require.True(t, handlerCalled, "expected handler to be invoked")
				})
			}
		}
	}
}

// TestCorruptedHeaders tests the middleware when the reverse proxy
// supplies unparsable content.
func TestCorruptedHeaders(t *testing.T) {
	t.Parallel()

	for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} {
		header := header
		name := strings.ToLower(header)

		t.Run(name, func(t *testing.T) {
			t.Parallel()

			remoteAddr := "10.10.10.10"

			config := &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("10.0.0.0"),
						Mask: net.CIDRMask(8, 32),
					},
				},
				TrustedHeaders: []string{
					"Cf-Connecting-Ip",
					"X-Forwarded-For",
					"X-Real-Ip",
					"True-Client-Ip",
				},
			}

			middleware := httpmw.ExtractRealIP(config)

			req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
			req.Header.Set(header, "12.34.56!78")
			req.RemoteAddr = remoteAddr

			handlerCalled := false

			nextHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
				// Since the header is unparsable, the remoteAddr should be unchanged
				require.Equal(t, remoteAddr, req.RemoteAddr, "remote address should be unchanged")

				handlerCalled = true
			})

			middleware(nextHandler).ServeHTTP(httptest.NewRecorder(), req)

			require.True(t, handlerCalled, "expected handler to be invoked")
		})
	}
}

// TestAddressFamilies tests the middleware using different combinations of
// address families for remote and proxy endpoints.
func TestAddressFamilies(t *testing.T) {
	t.Parallel()

	for _, clientFamily := range []string{"ipv4", "ipv6"} {
		for _, proxyFamily := range []string{"ipv4", "ipv6"} {
			for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} {
				clientFamily := clientFamily
				proxyFamily := proxyFamily
				header := header
				name := fmt.Sprintf("%s-%s-%s", strings.ToLower(header), clientFamily, proxyFamily)

				t.Run(name, func(t *testing.T) {
					t.Parallel()

					clientAddr := "123.123.123.123"
					if clientFamily == "ipv6" {
						clientAddr = "2a03:2880:f10c:83:face:b00c:0:25de"
					}

					proxyAddr := "4.4.4.4"
					if proxyFamily == "ipv6" {
						proxyAddr = "2001:4860:4860::8888"
					}

					config := &httpmw.RealIPConfig{
						TrustedOrigins: []*net.IPNet{
							{
								IP:   net.ParseIP("0.0.0.0"),
								Mask: net.CIDRMask(0, 32),
							},
							{
								IP:   net.ParseIP("0::"),
								Mask: net.CIDRMask(0, 128),
							},
						},
						TrustedHeaders: []string{
							"Cf-Connecting-Ip",
							"X-Forwarded-For",
							"X-Real-Ip",
							"True-Client-Ip",
						},
					}

					middleware := httpmw.ExtractRealIP(config)

					req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
					req.Header.Set(header, clientAddr)
					req.RemoteAddr = proxyAddr

					handlerCalled := false

					nextHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
						require.Equal(t, clientAddr, req.RemoteAddr, "remote address should match remote client")

						handlerCalled = true
					})

					middleware(nextHandler).ServeHTTP(httptest.NewRecorder(), req)

					require.True(t, handlerCalled, "expected handler to be invoked")
				})
			}
		}
	}
}

// TestFilterUntrusted tests that untrusted headers are removed from the request.
func TestFilterUntrusted(t *testing.T) {
	t.Parallel()

	tests := []struct {
		Name               string
		Config             *httpmw.RealIPConfig
		Header             http.Header
		RemoteAddr         string
		ExpectedHeader     http.Header
		ExpectedRemoteAddr string
	}{
		{
			Name: "untrusted-origin",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: nil,
				TrustedHeaders: []string{
					"Cf-Connecting-Ip",
					"X-Forwarded-For",
					"X-Real-Ip",
					"True-Client-Ip",
				},
			},
			Header: http.Header{
				"X-Forwarded-For":   []string{"1.2.3.4,123.45.67.89"},
				"X-Forwarded-Proto": []string{"https"},
				"X-Real-Ip":         []string{"4.4.4.4"},
				"True-Client-Ip":    []string{"5.6.7.8"},
				"Authorization":     []string{"Bearer 123"},
				"Accept-Encoding":   []string{"gzip", "compress", "deflate", "identity"},
			},
			RemoteAddr: "1.2.3.4",
			ExpectedHeader: http.Header{
				"Authorization":     []string{"Bearer 123"},
				"Accept-Encoding":   []string{"gzip", "compress", "deflate", "identity"},
				"X-Forwarded-Proto": []string{"https"},
			},
			ExpectedRemoteAddr: "1.2.3.4",
		},
	}

	for _, test := range tests {
		test := test
		t.Run(test.Name, func(t *testing.T) {
			t.Parallel()

			req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
			req.Header = test.Header
			req.RemoteAddr = test.RemoteAddr

			httpmw.FilterUntrustedOriginHeaders(test.Config, req)
			require.Equal(t, test.ExpectedRemoteAddr, req.RemoteAddr, "remote address should match")
			require.Equal(t, test.ExpectedHeader, req.Header, "filtered headers should match")
		})
	}
}

// TestApplicationProxy checks headers passed to DevURL services are as expected.
func TestApplicationProxy(t *testing.T) {
	t.Parallel()

	tests := []struct {
		Name               string
		Config             *httpmw.RealIPConfig
		Header             http.Header
		RemoteAddr         string
		TLS                bool
		ExpectedHeader     http.Header
		ExpectedRemoteAddr string
	}{
		{
			Name:   "untrusted-origin-http",
			Config: nil,
			Header: http.Header{
				"X-Forwarded-For": []string{"123.45.67.89,10.10.10.10"},
			},
			RemoteAddr: "17.18.19.20",
			TLS:        false,
			ExpectedHeader: http.Header{
				"X-Forwarded-For":   []string{"17.18.19.20"},
				"X-Forwarded-Proto": []string{"http"},
			},
			ExpectedRemoteAddr: "17.18.19.20",
		},
		{
			Name:   "untrusted-origin-https",
			Config: nil,
			Header: http.Header{
				"X-Forwarded-For": []string{"123.45.67.89,10.10.10.10"},
			},
			RemoteAddr: "17.18.19.20",
			TLS:        true,
			ExpectedHeader: http.Header{
				"X-Forwarded-For":   []string{"17.18.19.20"},
				"X-Forwarded-Proto": []string{"https"},
			},
			ExpectedRemoteAddr: "17.18.19.20",
		},
		{
			Name: "trusted-real-ip",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("0.0.0.0"),
						Mask: net.CIDRMask(0, 32),
					},
				},
				TrustedHeaders: []string{
					"X-Real-Ip",
				},
			},
			Header: http.Header{
				"X-Real-Ip":         []string{"99.88.77.66"},
				"X-Forwarded-For":   []string{"123.45.67.89,10.10.10.10"},
				"X-Forwarded-Proto": []string{"https"},
			},
			RemoteAddr: "17.18.19.20",
			TLS:        true,
			ExpectedHeader: http.Header{
				"X-Real-Ip":         []string{"99.88.77.66"},
				"X-Forwarded-For":   []string{"99.88.77.66,17.18.19.20"},
				"X-Forwarded-Proto": []string{"https"},
			},
			ExpectedRemoteAddr: "99.88.77.66",
		},
		{
			Name: "trusted-real-ip-and-forwarded-conflict",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("0.0.0.0"),
						Mask: net.CIDRMask(0, 32),
					},
				},
				TrustedHeaders: []string{
					"X-Forwarded-For",
					"X-Real-Ip",
				},
			},
			Header: http.Header{
				"X-Real-Ip":         []string{"99.88.77.66"},
				"X-Forwarded-For":   []string{"123.45.67.89,10.10.10.10"},
				"X-Forwarded-Proto": []string{"https"},
			},
			RemoteAddr: "17.18.19.20",
			TLS:        false,
			ExpectedHeader: http.Header{
				"X-Real-Ip": []string{"99.88.77.66"},
				// Even though X-Real-Ip and X-Forwarded-For are both trusted,
				// ignore the value of X-Forwarded-For, since they conflict
				"X-Forwarded-For":   []string{"123.45.67.89,10.10.10.10,17.18.19.20"},
				"X-Forwarded-Proto": []string{"https"},
			},
			ExpectedRemoteAddr: "123.45.67.89",
		},
		{
			Name: "trusted-real-ip-and-forwarded-same",
			Config: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{
					{
						IP:   net.ParseIP("0.0.0.0"),
						Mask: net.CIDRMask(0, 32),
					},
				},
				TrustedHeaders: []string{
					"X-Forwarded-For",
					"X-Real-Ip",
				},
			},
			Header: http.Header{
				"X-Real-Ip": []string{"99.88.77.66"},
				// X-Real-Ip and X-Forwarded-For are both trusted, and since
				// they match, append the proxy address to X-Forwarded-For
				"X-Forwarded-For":   []string{"99.88.77.66,123.45.67.89,10.10.10.10"},
				"X-Forwarded-Proto": []string{"https"},
			},
			RemoteAddr: "17.18.19.20",
			TLS:        false,
			ExpectedHeader: http.Header{
				"X-Real-Ip":         []string{"99.88.77.66"},
				"X-Forwarded-For":   []string{"99.88.77.66,123.45.67.89,10.10.10.10,17.18.19.20"},
				"X-Forwarded-Proto": []string{"https"},
			},
			ExpectedRemoteAddr: "99.88.77.66",
		},
	}

	for _, test := range tests {
		test := test
		t.Run(test.Name, func(t *testing.T) {
			t.Parallel()

			req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
			req.Header = test.Header
			req.RemoteAddr = test.RemoteAddr
			if test.TLS {
				req.TLS = &tls.ConnectionState{}
			} else {
				req.TLS = nil
			}

			middleware := httpmw.ExtractRealIP(test.Config)

			handlerCalled := false

			nextHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
				require.Equal(t, test.ExpectedRemoteAddr, req.RemoteAddr, "remote address should match")

				httpmw.FilterUntrustedOriginHeaders(test.Config, req)
				err := httpmw.EnsureXForwardedForHeader(req)
				require.NoError(t, err, "ensure X-Forwarded-For should be successful")

				require.Equal(t, test.ExpectedHeader, req.Header, "filtered headers should match")

				handlerCalled = true
			})

			middleware(nextHandler).ServeHTTP(httptest.NewRecorder(), req)

			require.True(t, handlerCalled, "expected handler to be invoked")
		})
	}
}
