v3.1.0 d676137

cidr_test.go RawHistoryFolder

1package middleware
2
3import (
4	"encoding/json"
5	"net"
6	"net/http"
7	"net/http/httptest"
8	"testing"
9
10	"github.com/bnema/gordon/internal/adapters/dto"
11	"github.com/bnema/gordon/internal/adapters/in/http/httphelper"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14)
15
16func TestRegistryCIDRAllowlist(t *testing.T) {
17	log := testLogger()
18	ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
19		w.WriteHeader(http.StatusOK)
20	})
21
22	tests := []struct {
23		name        string
24		allowedNets []*net.IPNet
25		trustedNets []*net.IPNet
26		remoteAddr  string
27		xff         string
28		wantStatus  int
29	}{
30		{
31			name:        "no CIDRs configured passes through",
32			allowedNets: nil,
33			remoteAddr:  "203.0.113.50:1234",
34			wantStatus:  http.StatusOK,
35		},
36		{
37			name:        "empty CIDRs passes through",
38			allowedNets: []*net.IPNet{},
39			remoteAddr:  "203.0.113.50:1234",
40			wantStatus:  http.StatusOK,
41		},
42		{
43			name:        "allowed IP returns 200",
44			allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
45			remoteAddr:  "100.100.1.1:1234",
46			wantStatus:  http.StatusOK,
47		},
48		{
49			name:        "denied IP returns 403",
50			allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
51			remoteAddr:  "203.0.113.50:1234",
52			wantStatus:  http.StatusForbidden,
53		},
54		{
55			name:        "localhost always allowed even when not in allowlist",
56			allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
57			remoteAddr:  "127.0.0.1:1234",
58			wantStatus:  http.StatusOK,
59		},
60		{
61			name:        "127.x.x.x always allowed",
62			allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
63			remoteAddr:  "127.0.0.2:1234",
64			wantStatus:  http.StatusOK,
65		},
66		{
67			name:        "IPv6 localhost always allowed",
68			allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
69			remoteAddr:  "[::1]:1234",
70			wantStatus:  http.StatusOK,
71		},
72		{
73			name:        "trusted proxy forwards allowed client IP",
74			allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
75			trustedNets: httphelper.ParseTrustedProxies([]string{"10.0.0.1"}),
76			remoteAddr:  "10.0.0.1:1234",
77			xff:         "100.100.1.1",
78			wantStatus:  http.StatusOK,
79		},
80		{
81			name:        "trusted proxy forwards denied client IP",
82			allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
83			trustedNets: httphelper.ParseTrustedProxies([]string{"10.0.0.1"}),
84			remoteAddr:  "10.0.0.1:1234",
85			xff:         "203.0.113.50",
86			wantStatus:  http.StatusForbidden,
87		},
88	}
89
90	for _, tt := range tests {
91		t.Run(tt.name, func(t *testing.T) {
92			handler := RegistryCIDRAllowlist(tt.allowedNets, tt.trustedNets, log)(ok)
93
94			req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
95			req.RemoteAddr = tt.remoteAddr
96			if tt.xff != "" {
97				req.Header.Set("X-Forwarded-For", tt.xff)
98			}
99			rr := httptest.NewRecorder()
100
101			handler.ServeHTTP(rr, req)
102
103			assert.Equal(t, tt.wantStatus, rr.Code)
104
105			if tt.wantStatus == http.StatusForbidden {
106				var errResp dto.ErrorResponse
107				require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp))
108				assert.Equal(t, "Forbidden", errResp.Error)
109			}
110		})
111	}
112}
113
114func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) {
115	log := testLogger()
116	ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
117		w.WriteHeader(http.StatusOK)
118	})
119
120	handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "app.example.com" })(ok)
121	srv := httptest.NewServer(handler)
122	defer srv.Close()
123
124	client := srv.Client()
125	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
126
127	req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
128	req.Host = "app.example.com"
129	resp, err := client.Do(req)
130	require.NoError(t, err)
131	defer resp.Body.Close()
132
133	assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
134	assert.Equal(t, "https://app.example.com/", resp.Header.Get("Location"))
135}
136
137func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) {
138	log := testLogger()
139	ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
140		w.WriteHeader(http.StatusOK)
141	})
142
143	handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "app.example.com" })(ok)
144	srv := httptest.NewServer(handler)
145	defer srv.Close()
146
147	client := srv.Client()
148	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
149
150	req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
151	req.Host = "app.example.com:8088"
152	resp, err := client.Do(req)
153	require.NoError(t, err)
154	defer resp.Body.Close()
155
156	assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
157	assert.Equal(t, "https://app.example.com:8443/", resp.Header.Get("Location"))
158}
159
160func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
161	log := testLogger()
162	ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
163		w.WriteHeader(http.StatusOK)
164	})
165
166	handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "app.example.com" })(ok)
167	srv := httptest.NewServer(handler)
168	defer srv.Close()
169
170	client := srv.Client()
171	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
172
173	req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
174	req.Host = "app.example.com:9999"
175	resp, err := client.Do(req)
176	require.NoError(t, err)
177	defer resp.Body.Close()
178
179	assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
180	assert.Equal(t, "https://app.example.com:9999/", resp.Header.Get("Location"))
181}
182
183func TestHTTPSRedirect_TrailingDotHostRedirectsToCanonicalHost(t *testing.T) {
184	log := testLogger()
185	ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
186		w.WriteHeader(http.StatusOK)
187	})
188	handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "app.example.com" })(ok)
189
190	srv := httptest.NewServer(handler)
191	defer srv.Close()
192	client := srv.Client()
193	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
194
195	req, err := http.NewRequest(http.MethodGet, srv.URL+"/path", nil)
196	require.NoError(t, err)
197	req.Host = "App.Example.Com.:8088"
198	resp, err := client.Do(req)
199	require.NoError(t, err)
200	defer resp.Body.Close()
201
202	assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
203	assert.Equal(t, "https://app.example.com:8443/path", resp.Header.Get("Location"))
204}
205
206func TestHTTPSRedirect_RejectsInvalidHost(t *testing.T) {
207	log := testLogger()
208	ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
209		w.WriteHeader(http.StatusOK)
210	})
211	handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "app.example.com" })(ok)
212
213	tests := []struct {
214		name string
215		host string
216	}{
217		{name: "invalid port", host: "app.example.com:abcd"},
218
219		{name: "localhost", host: "localhost"},
220		{name: "ipv6", host: "[::1]:8088"},
221		{name: "unconfigured public host", host: "evil.example.com"},
222	}
223
224	for _, tt := range tests {
225		t.Run(tt.name, func(t *testing.T) {
226			srv := httptest.NewServer(handler)
227			defer srv.Close()
228			req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
229			require.NoError(t, err)
230			req.Host = tt.host
231			resp, err := srv.Client().Do(req)
232			require.NoError(t, err)
233			defer resp.Body.Close()
234
235			assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
236			assert.Empty(t, resp.Header.Get("Location"))
237		})
238	}
239}
240
241// TestHTTPSRedirect_TLSAlwaysNeverServesPlaintext proves a tls=always host
242// is redirected when an HTTPS endpoint exists and refused when it does not:
243// the backend is never reached over plaintext.
244func TestHTTPSRedirect_TLSAlwaysNeverServesPlaintext(t *testing.T) {
245	log := testLogger()
246	served := false
247	backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
248		served = true
249		w.WriteHeader(http.StatusOK)
250	})
251	isHostAllowed := func(host string) bool { return host == "secure.example.com" }
252	isTLSAlways := func(host string) bool { return host == "secure.example.com" }
253
254	redirected := HTTPSRedirectWithEligibility(nil, 8088, 8443, false, log, isHostAllowed, nil, isTLSAlways)(backend)
255	srv := httptest.NewServer(redirected)
256	defer srv.Close()
257	client := srv.Client()
258	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
259
260	req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
261	require.NoError(t, err)
262	req.Host = "secure.example.com"
263	resp, err := client.Do(req)
264	require.NoError(t, err)
265	defer resp.Body.Close()
266	assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
267	assert.Equal(t, "https://secure.example.com/", resp.Header.Get("Location"))
268	assert.False(t, served, "the plaintext backend must never be reached")
269
270	// No HTTPS endpoint configured: the request is refused, not served.
271	served = false
272	refused := HTTPSRedirectWithEligibility(nil, 8088, 0, false, log, isHostAllowed, nil, isTLSAlways)(backend)
273	srv2 := httptest.NewServer(refused)
274	defer srv2.Close()
275	req2, err := http.NewRequest(http.MethodGet, srv2.URL+"/", nil)
276	require.NoError(t, err)
277	req2.Host = "secure.example.com"
278	refusedClient := srv2.Client()
279	refusedClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
280	resp2, err := refusedClient.Do(req2)
281	require.NoError(t, err)
282	defer resp2.Body.Close()
283	assert.Equal(t, http.StatusMisdirectedRequest, resp2.StatusCode)
284	assert.False(t, served, "plaintext must not be served without an HTTPS endpoint")
285}
286
287// TestHTTPSRedirect_TLSAlwaysIgnoresDisabledRedirectConfig proves tls=always
288// still redirects when redirects are otherwise disabled for untrusted
289// clients.
290func TestHTTPSRedirect_TLSAlwaysIgnoresDisabledRedirectConfig(t *testing.T) {
291	log := testLogger()
292	served := false
293	backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
294		served = true
295		w.WriteHeader(http.StatusOK)
296	})
297	isHostAllowed := func(host string) bool { return host == "secure.example.com" }
298	isTLSAlways := func(host string) bool { return host == "secure.example.com" }
299
300	handler := HTTPSRedirectWithEligibility(nil, 8088, 8443, false, log, isHostAllowed, nil, isTLSAlways)(backend)
301	srv := httptest.NewServer(handler)
302	defer srv.Close()
303	client := srv.Client()
304	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
305
306	req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
307	require.NoError(t, err)
308	req.Host = "secure.example.com"
309	resp, err := client.Do(req)
310	require.NoError(t, err)
311	defer resp.Body.Close()
312	assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
313	assert.False(t, served)
314}
315
316func TestHTTPSRedirect_IneligibleKnownHostPassesThrough(t *testing.T) {
317	log := testLogger()
318	ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
319		w.WriteHeader(http.StatusOK)
320	})
321	// plain.example.com is known but opted out of TLS (tls=never).
322	handler := HTTPSRedirectWithEligibility(nil, 8088, 8443, true, log,
323		func(host string) bool { return host == "plain.example.com" || host == "app.example.com" },
324		func(host string) bool { return host != "plain.example.com" }, nil)(ok)
325
326	srv := httptest.NewServer(handler)
327	defer srv.Close()
328	client := srv.Client()
329	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
330
331	// Ineligible known host: served plain HTTP, no redirect.
332	req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
333	require.NoError(t, err)
334	req.Host = "plain.example.com"
335	resp, err := client.Do(req)
336	require.NoError(t, err)
337	defer resp.Body.Close()
338	assert.Equal(t, http.StatusOK, resp.StatusCode)
339	assert.Empty(t, resp.Header.Get("Location"))
340
341	// Eligible known host: still redirected.
342	req, err = http.NewRequest(http.MethodGet, srv.URL+"/", nil)
343	require.NoError(t, err)
344	req.Host = "app.example.com"
345	resp2, err := client.Do(req)
346	require.NoError(t, err)
347	defer resp2.Body.Close()
348	assert.Equal(t, http.StatusPermanentRedirect, resp2.StatusCode)
349	assert.Equal(t, "https://app.example.com/", resp2.Header.Get("Location"))
350
351	// Unknown host: still 400.
352	req, err = http.NewRequest(http.MethodGet, srv.URL+"/", nil)
353	require.NoError(t, err)
354	req.Host = "evil.example.com"
355	resp3, err := client.Do(req)
356	require.NoError(t, err)
357	defer resp3.Body.Close()
358	assert.Equal(t, http.StatusBadRequest, resp3.StatusCode)
359}
360
361func TestProxyCIDRAllowlist(t *testing.T) {
362	log := testLogger()
363	redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
364		http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusPermanentRedirect)
365	})
366
367	tests := []struct {
368		name        string
369		allowedNets []*net.IPNet
370		remoteAddr  string
371		wantStatus  int
372	}{
373		{
374			name:        "no CIDRs configured passes through",
375			allowedNets: nil,
376			remoteAddr:  "203.0.113.50:1234",
377			wantStatus:  http.StatusPermanentRedirect,
378		},
379		{
380			name:        "allowed IP gets redirect",
381			allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
382			remoteAddr:  "173.245.48.1:1234",
383			wantStatus:  http.StatusPermanentRedirect,
384		},
385		{
386			name:        "blocked IP gets 403",
387			allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
388			remoteAddr:  "203.0.113.50:1234",
389			wantStatus:  http.StatusForbidden,
390		},
391		{
392			name:        "localhost always allowed",
393			allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
394			remoteAddr:  "127.0.0.1:1234",
395			wantStatus:  http.StatusPermanentRedirect,
396		},
397		{
398			name:        "ignores XFF (uses RemoteAddr only)",
399			allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
400			remoteAddr:  "203.0.113.50:1234",
401			wantStatus:  http.StatusForbidden,
402		},
403	}
404
405	for _, tt := range tests {
406		t.Run(tt.name, func(t *testing.T) {
407			handler := ProxyCIDRAllowlist(tt.allowedNets, log)(redirect)
408
409			req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
410			req.RemoteAddr = tt.remoteAddr
411			// Even with XFF from an "allowed" range, the proxy middleware
412			// must check RemoteAddr, not forwarded headers.
413			req.Header.Set("X-Forwarded-For", "173.245.48.1")
414			rr := httptest.NewRecorder()
415
416			handler.ServeHTTP(rr, req)
417
418			assert.Equal(t, tt.wantStatus, rr.Code)
419
420			if tt.wantStatus == http.StatusForbidden {
421				var errResp dto.ErrorResponse
422				require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp))
423				assert.Equal(t, "Forbidden", errResp.Error)
424			}
425		})
426	}
427}

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