v3.3.2 9697f9d

forwarder_test.go RawHistoryFolder

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}
373
374func TestNewReverseProxy_ClientIPHeaders(t *testing.T) {
375	trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8", "fd00::/8", "173.245.48.0/20"})
376
377	tests := []struct {
378		name       string
379		remoteAddr string
380		headers    http.Header
381		nets       []*net.IPNet
382		wantIP     string // expected X-Real-IP and X-Forwarded-For, empty = absent
383		wantCF     string // expected Cf-Connecting-Ip, empty = absent
384	}{
385		{
386			name:       "untrusted IPv4 peer forged headers replaced",
387			remoteAddr: "203.0.113.7:4321",
388			headers: http.Header{
389				"X-Real-IP":        {"1.2.3.4"},
390				"X-Forwarded-For":  {"1.2.3.4"},
391				"Cf-Connecting-Ip": {"1.2.3.4"}},
392			nets:   trustedNets,
393			wantIP: "203.0.113.7",
394		},
395		{
396			name:       "untrusted IPv6 peer forged X-Real-IP replaced",
397			remoteAddr: "[2001:db8::7]:4321",
398			headers:    http.Header{"X-Real-IP": {"1.2.3.4"}},
399			nets:       trustedNets,
400			wantIP:     "2001:db8::7",
401		},
402		{
403			name:       "untrusted IPv4-mapped IPv6 peer normalized to IPv4",
404			remoteAddr: "[::ffff:203.0.113.7]:4321",
405			headers:    http.Header{"X-Real-IP": {"1.2.3.4"}},
406			nets:       trustedNets,
407			wantIP:     "203.0.113.7",
408		},
409		{
410			name:       "no trusted nets ignores proxy headers",
411			remoteAddr: "10.0.0.1:4321",
412			headers:    http.Header{"X-Forwarded-For": {"198.51.100.9"}},
413			nets:       nil,
414			wantIP:     "10.0.0.1",
415		},
416		{
417			name:       "trusted Cloudflare peer uses Cf-Connecting-Ip",
418			remoteAddr: "173.245.48.10:4321",
419			headers: http.Header{
420				"Cf-Connecting-Ip": {"198.51.100.9"},
421				"X-Real-IP":        {"1.2.3.4"},
422				"X-Forwarded-For":  {"1.2.3.4"}},
423			nets:   trustedNets,
424			wantIP: "198.51.100.9",
425			wantCF: "198.51.100.9",
426		},
427		{
428			name:       "trusted Cloudflare peer with IPv6 client",
429			remoteAddr: "173.245.48.10:4321",
430			headers:    http.Header{"Cf-Connecting-Ip": {"2001:db8::9"}},
431			nets:       trustedNets,
432			wantIP:     "2001:db8::9",
433			wantCF:     "2001:db8::9",
434		},
435		{
436			name:       "trusted non-Cloudflare proxy uses right-most untrusted XFF hop",
437			remoteAddr: "10.0.0.1:4321",
438			headers: http.Header{
439				"X-Forwarded-For":  {"1.2.3.4, 198.51.100.9, 10.0.0.2"},
440				"Cf-Connecting-Ip": {"1.2.3.4"}},
441			nets:   trustedNets,
442			wantIP: "198.51.100.9",
443		},
444		{
445			name:       "trusted IPv6 proxy with IPv4-mapped XFF hop",
446			remoteAddr: "[fd00::1]:4321",
447			headers:    http.Header{"X-Forwarded-For": {"::ffff:198.51.100.9"}},
448			nets:       trustedNets,
449			wantIP:     "198.51.100.9",
450		},
451		{
452			name:       "trusted proxy X-Real-IP honored without XFF",
453			remoteAddr: "10.0.0.1:4321",
454			headers:    http.Header{"X-Real-IP": {"2001:db8::9"}},
455			nets:       trustedNets,
456			wantIP:     "2001:db8::9",
457		},
458		{
459			name:       "trusted proxy adding a new XFF line uses that line",
460			remoteAddr: "10.0.0.1:4321",
461			headers:    http.Header{"X-Forwarded-For": {"1.2.3.4", "198.51.100.9"}},
462			nets:       trustedNets,
463			wantIP:     "198.51.100.9",
464		},
465		{
466			name:       "trusted proxy repeated X-Real-IP ignored",
467			remoteAddr: "10.0.0.1:4321",
468			headers:    http.Header{"X-Real-IP": {"1.2.3.4", "198.51.100.9"}},
469			nets:       trustedNets,
470			wantIP:     "10.0.0.1",
471		},
472		{
473			name:       "underscore header variants removed",
474			remoteAddr: "203.0.113.7:4321",
475			headers: http.Header{
476				"X_Real_IP":        {"1.2.3.4"},
477				"X_Forwarded_For":  {"1.2.3.4"},
478				"Cf_Connecting_Ip": {"1.2.3.4"},
479			},
480			nets:   trustedNets,
481			wantIP: "203.0.113.7",
482		},
483		{
484			name:       "Cloudflare peer not in trusted proxies strips Cf-Connecting-Ip",
485			remoteAddr: "173.245.48.10:4321",
486			headers:    http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}},
487			nets:       gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"}),
488			wantIP:     "173.245.48.10",
489		},
490		{
491			name:       "IPv4-mapped trusted Cloudflare peer uses Cf-Connecting-Ip",
492			remoteAddr: "[::ffff:173.245.48.10]:4321",
493			headers:    http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}},
494			nets:       trustedNets,
495			wantIP:     "198.51.100.9",
496			wantCF:     "198.51.100.9",
497		},
498		{
499			name:       "trusted Cloudflare peer with invalid Cf-Connecting-Ip falls back",
500			remoteAddr: "173.245.48.10:4321",
501			headers: http.Header{
502				"Cf-Connecting-Ip": {"not-an-ip"},
503				"X-Forwarded-For":  {"198.51.100.9"},
504			},
505			nets:   trustedNets,
506			wantIP: "198.51.100.9",
507		},
508		{
509			name:       "unparsable peer sends no client IP",
510			remoteAddr: "@",
511			headers:    http.Header{"X-Real-IP": {"1.2.3.4"}, "X-Forwarded-For": {"1.2.3.4"}},
512			nets:       trustedNets,
513		},
514	}
515
516	for _, tt := range tests {
517		t.Run(tt.name, func(t *testing.T) {
518			var got http.Header
519			backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
520				got = r.Header.Clone()
521				w.WriteHeader(http.StatusOK)
522			}))
523			defer backend.Close()
524
525			backendURL, err := url.Parse(backend.URL)
526			require.NoError(t, err)
527
528			incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
529			incomingReq.RemoteAddr = tt.remoteAddr
530			for k, v := range tt.headers {
531				incomingReq.Header[http.CanonicalHeaderKey(k)] = v
532			}
533
534			proxy := newReverseProxy(reverseProxyOptions{
535				targetURL:   backendURL,
536				transport:   http.DefaultTransport,
537				incomingReq: incomingReq,
538				trustedNets: tt.nets,
539				errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
540					http.Error(w, err.Error(), http.StatusBadGateway)
541				},
542			})
543
544			rec := httptest.NewRecorder()
545			proxy.ServeHTTP(rec, incomingReq)
546			require.Equal(t, http.StatusOK, rec.Code)
547
548			assert.Equal(t, tt.wantIP, got.Get("X-Real-IP"))
549			assert.Equal(t, tt.wantIP, got.Get("X-Forwarded-For"))
550			assert.Equal(t, tt.wantCF, got.Get("Cf-Connecting-Ip"))
551			for key := range got {
552				assert.NotContains(t, key, "_", "underscore header %q forwarded", key)
553			}
554		})
555	}
556}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close