1package proxy
2
3import (
4 "context"
5 "fmt"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "strconv"
11 "testing"
12
13 "github.com/bnema/zerowrap"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17
18 gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
19 "github.com/bnema/gordon/internal/boundaries/in"
20 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
21 "github.com/bnema/gordon/internal/domain"
22)
23
24type roundTripFunc func(*http.Request) (*http.Response, error)
25
26func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
27 return f(r)
28}
29
30func TestForwardToTarget_H2CEndToEnd(t *testing.T) {
31 var h2cProtos http.Protocols
32 h2cProtos.SetUnencryptedHTTP2(true)
33
34 h2cBackend := &http.Server{
35 Protocols: &h2cProtos,
36 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37 w.Header().Set("X-Proto", r.Proto)
38 w.WriteHeader(http.StatusOK)
39 _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
40 }),
41 }
42
43 ln, err := net.Listen("tcp", "127.0.0.1:0")
44 require.NoError(t, err)
45 defer ln.Close()
46
47 go func() {
48 _ = h2cBackend.Serve(ln)
49 }()
50 defer h2cBackend.Close()
51
52 _, portStr, splitErr := net.SplitHostPort(ln.Addr().String())
53 require.NoError(t, splitErr)
54 port, atoiErr := strconv.Atoi(portStr)
55 require.NoError(t, atoiErr)
56
57 proxySvc := inmocks.NewMockProxyService(t)
58
59 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
60 proxySvc.EXPECT().IsRegistryDomain("grpc.example.com").Return(false)
61 proxySvc.EXPECT().GetTarget(mock.Anything, "grpc.example.com").Return(&domain.ProxyTarget{
62 Host: "127.0.0.1",
63 Port: port,
64 ContainerID: "grpc-1",
65 Scheme: "http",
66 Protocol: "h2c",
67 }, nil)
68 proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {})
69
70 handler := NewHandler(proxySvc, nil, zerowrap.Default())
71
72 req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil)
73 req.Host = "grpc.example.com"
74 rec := httptest.NewRecorder()
75
76 handler.ServeHTTP(rec, req)
77
78 assert.Equal(t, http.StatusOK, rec.Code)
79 assert.Contains(t, rec.Body.String(), "HTTP/2")
80}
81
82func TestForwardToTarget_HTTP1StillWorks(t *testing.T) {
83 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84 _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
85 }))
86 defer backend.Close()
87
88 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
89
90 proxySvc := inmocks.NewMockProxyService(t)
91
92 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
93 proxySvc.EXPECT().IsRegistryDomain("web.example.com").Return(false)
94 proxySvc.EXPECT().GetTarget(mock.Anything, "web.example.com").Return(&domain.ProxyTarget{
95 Host: "127.0.0.1",
96 Port: backendPort,
97 ContainerID: "web-1",
98 Scheme: "http",
99 }, nil)
100 proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {})
101
102 handler := NewHandler(proxySvc, nil, zerowrap.Default())
103
104 req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil)
105 req.Host = "web.example.com"
106 rec := httptest.NewRecorder()
107
108 handler.ServeHTTP(rec, req)
109
110 assert.Equal(t, http.StatusOK, rec.Code)
111 assert.Contains(t, rec.Body.String(), "HTTP/1.1")
112}
113
114func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) {
115 handler := NewHandler(inmocks.NewMockProxyService(t), nil, zerowrap.Default())
116
117 h2cTarget := &domain.ProxyTarget{Protocol: "h2c"}
118 httpTarget := &domain.ProxyTarget{Protocol: ""}
119
120 assert.Equal(t, handler.h2cTransport, handler.transportForTarget(h2cTarget))
121 assert.Equal(t, handler.appTransport, handler.transportForTarget(httpTarget))
122}
123
124func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
125 var receivedHost string
126 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
127 receivedHost = r.Host
128 w.WriteHeader(http.StatusOK)
129 }))
130 defer backend.Close()
131
132 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
133
134 proxySvc := inmocks.NewMockProxyService(t)
135
136 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
137 proxySvc.EXPECT().IsRegistryDomain("external.example.com").Return(false)
138 proxySvc.EXPECT().GetTarget(mock.Anything, "external.example.com").Return(&domain.ProxyTarget{
139 Host: "127.0.0.1",
140 Port: backendPort,
141 ContainerID: "",
142 Scheme: "http",
143 OriginalHost: "upstream.example.com",
144 }, nil)
145 proxySvc.EXPECT().TrackInFlight("").Return(func() {})
146
147 handler := NewHandler(proxySvc, nil, zerowrap.Default())
148
149 req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil)
150 req.Host = "external.example.com"
151 w := httptest.NewRecorder()
152 handler.ServeHTTP(w, req)
153
154 assert.Equal(t, http.StatusOK, w.Code)
155 assert.Equal(t, net.JoinHostPort("upstream.example.com", strconv.Itoa(backendPort)), receivedHost)
156}
157
158func TestForwardToTarget_RouteHostPreservedForManagedTarget(t *testing.T) {
159 var receivedHost string
160 var receivedForwardedHost string
161 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
162 receivedHost = r.Host
163 receivedForwardedHost = r.Header.Get("X-Forwarded-Host")
164 w.WriteHeader(http.StatusOK)
165 }))
166 defer backend.Close()
167
168 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
169
170 proxySvc := inmocks.NewMockProxyService(t)
171
172 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
173 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
174 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
175 Host: "127.0.0.1",
176 Port: backendPort,
177 ContainerID: "c-1",
178 Scheme: "http",
179 RouteHost: "app.example.com",
180 }, nil)
181 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
182
183 handler := NewHandler(proxySvc, nil, zerowrap.Default())
184
185 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
186 req.Host = "App.Example.com:443"
187 w := httptest.NewRecorder()
188 handler.ServeHTTP(w, req)
189
190 assert.Equal(t, http.StatusOK, w.Code)
191 assert.Equal(t, "app.example.com", receivedHost)
192 assert.Equal(t, "app.example.com", receivedForwardedHost)
193}
194
195func TestForwardToTarget_ContextCanceled_Returns499(t *testing.T) {
196 proxySvc := inmocks.NewMockProxyService(t)
197
198 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
199 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
200 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
201 Host: "127.0.0.1",
202 Port: 8080,
203 ContainerID: "c-sse",
204 Scheme: "http",
205 }, nil)
206 proxySvc.EXPECT().TrackInFlight("c-sse").Return(func() {})
207
208 handler := NewHandler(proxySvc, nil, zerowrap.Default())
209 handler.appTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
210 return nil, context.Canceled
211 })
212
213 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/api/notifications/sent-events", nil)
214 req.Host = "app.example.com"
215 w := httptest.NewRecorder()
216 handler.ServeHTTP(w, req)
217
218 assert.Equal(t, statusClientClosedRequest, w.Code)
219 assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
220 assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", w.Header().Get("Content-Security-Policy"))
221 assert.Empty(t, w.Body.String())
222}
223
224func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
225 proxySvc := inmocks.NewMockProxyService(t)
226
227 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
228 proxySvc.EXPECT().IsRegistryDomain("down.example.com").Return(false)
229 proxySvc.EXPECT().GetTarget(mock.Anything, "down.example.com").Return(&domain.ProxyTarget{
230 Host: "127.0.0.1",
231 Port: 1,
232 ContainerID: "c-down",
233 Scheme: "http",
234 }, nil)
235 proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {})
236
237 handler := NewHandler(proxySvc, nil, zerowrap.Default())
238
239 req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil)
240 req.Host = "down.example.com"
241 w := httptest.NewRecorder()
242 handler.ServeHTTP(w, req)
243
244 assert.Equal(t, http.StatusServiceUnavailable, w.Code)
245}
246
247func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
248 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
249 w.WriteHeader(http.StatusOK)
250 }))
251 defer backend.Close()
252
253 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
254
255 proxySvc := inmocks.NewMockProxyService(t)
256
257 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
258 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
259 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
260 Host: "127.0.0.1",
261 Port: backendPort,
262 ContainerID: "c-1",
263 Scheme: "http",
264 }, nil)
265 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
266
267 handler := NewHandler(proxySvc, nil, zerowrap.Default())
268
269 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
270 req.Host = "app.example.com"
271 w := httptest.NewRecorder()
272 handler.ServeHTTP(w, req)
273
274 assert.Equal(t, http.StatusOK, w.Code)
275}
276func TestNewReverseProxy_XForwardedProto(t *testing.T) {
277 trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"})
278
279 tests := []struct {
280 name string
281 remoteAddr string
282 incomingProto string // X-Forwarded-Proto on incoming request, empty = not set
283 wantProto string // expected X-Forwarded-Proto at backend
284 nets []*net.IPNet
285 }{
286 {
287 name: "trusted proxy with https preserved",
288 remoteAddr: "10.0.0.1:12345",
289 incomingProto: "https",
290 wantProto: "https",
291 nets: trustedNets,
292 },
293 {
294 name: "untrusted source proto overwritten",
295 remoteAddr: "1.2.3.4:12345",
296 incomingProto: "https",
297 wantProto: "http",
298 nets: trustedNets,
299 },
300 {
301 name: "trusted proxy without proto set normally",
302 remoteAddr: "10.0.0.1:12345",
303 incomingProto: "",
304 wantProto: "http",
305 nets: trustedNets,
306 },
307 {
308 name: "no trusted nets proto not preserved",
309 remoteAddr: "10.0.0.1:12345",
310 incomingProto: "https",
311 wantProto: "http",
312 nets: nil,
313 },
314 {
315 name: "bare IPv4 no port trusted source preserves proto",
316 remoteAddr: "10.0.0.1",
317 incomingProto: "https",
318 wantProto: "https",
319 nets: trustedNets,
320 },
321 {
322 name: "bare IPv6 with brackets trusted source preserves proto",
323 remoteAddr: "[::1]",
324 incomingProto: "https",
325 wantProto: "https",
326 nets: gordonhttp.ParseTrustedProxies([]string{"::1/128"}),
327 },
328 {
329 name: "bare IPv4 no port untrusted source strips proto",
330 remoteAddr: "1.2.3.4",
331 incomingProto: "https",
332 wantProto: "http",
333 nets: trustedNets,
334 },
335 }
336
337 for _, tt := range tests {
338 t.Run(tt.name, func(t *testing.T) {
339 var gotProto string
340 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
341 gotProto = r.Header.Get("X-Forwarded-Proto")
342 w.WriteHeader(http.StatusOK)
343 }))
344 defer backend.Close()
345
346 backendURL, err := url.Parse(backend.URL)
347 require.NoError(t, err)
348
349 // Build incoming request (http scheme, no TLS)
350 incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
351 incomingReq.RemoteAddr = tt.remoteAddr
352 if tt.incomingProto != "" {
353 incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto)
354 }
355
356 proxy := newReverseProxy(reverseProxyOptions{
357 targetURL: backendURL,
358 transport: http.DefaultTransport,
359 incomingReq: incomingReq,
360 trustedNets: tt.nets,
361 errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
362 http.Error(w, err.Error(), http.StatusBadGateway)
363 },
364 })
365
366 rec := httptest.NewRecorder()
367 proxy.ServeHTTP(rec, incomingReq)
368
369 assert.Equal(t, tt.wantProto, gotProto)
370 })
371 }
372}