1package middleware
2
3import (
4 "encoding/json"
5 "fmt"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "github.com/bnema/gordon/internal/adapters/dto"
12 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15)
16
17func TestRegistryCIDRAllowlist(t *testing.T) {
18 log := testLogger()
19 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
20 w.WriteHeader(http.StatusOK)
21 })
22
23 tests := []struct {
24 name string
25 allowedNets []*net.IPNet
26 trustedNets []*net.IPNet
27 remoteAddr string
28 xff string
29 wantStatus int
30 }{
31 {
32 name: "no CIDRs configured passes through",
33 allowedNets: nil,
34 remoteAddr: "203.0.113.50:1234",
35 wantStatus: http.StatusOK,
36 },
37 {
38 name: "empty CIDRs passes through",
39 allowedNets: []*net.IPNet{},
40 remoteAddr: "203.0.113.50:1234",
41 wantStatus: http.StatusOK,
42 },
43 {
44 name: "allowed IP returns 200",
45 allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
46 remoteAddr: "100.100.1.1:1234",
47 wantStatus: http.StatusOK,
48 },
49 {
50 name: "denied IP returns 403",
51 allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
52 remoteAddr: "203.0.113.50:1234",
53 wantStatus: http.StatusForbidden,
54 },
55 {
56 name: "localhost always allowed even when not in allowlist",
57 allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
58 remoteAddr: "127.0.0.1:1234",
59 wantStatus: http.StatusOK,
60 },
61 {
62 name: "127.x.x.x always allowed",
63 allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
64 remoteAddr: "127.0.0.2:1234",
65 wantStatus: http.StatusOK,
66 },
67 {
68 name: "IPv6 localhost always allowed",
69 allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
70 remoteAddr: "[::1]:1234",
71 wantStatus: http.StatusOK,
72 },
73 {
74 name: "trusted proxy forwards allowed client IP",
75 allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
76 trustedNets: httphelper.ParseTrustedProxies([]string{"10.0.0.1"}),
77 remoteAddr: "10.0.0.1:1234",
78 xff: "100.100.1.1",
79 wantStatus: http.StatusOK,
80 },
81 {
82 name: "trusted proxy forwards denied client IP",
83 allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
84 trustedNets: httphelper.ParseTrustedProxies([]string{"10.0.0.1"}),
85 remoteAddr: "10.0.0.1:1234",
86 xff: "203.0.113.50",
87 wantStatus: http.StatusForbidden,
88 },
89 }
90
91 for _, tt := range tests {
92 t.Run(tt.name, func(t *testing.T) {
93 handler := RegistryCIDRAllowlist(tt.allowedNets, tt.trustedNets, log)(ok)
94
95 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
96 req.RemoteAddr = tt.remoteAddr
97 if tt.xff != "" {
98 req.Header.Set("X-Forwarded-For", tt.xff)
99 }
100 rr := httptest.NewRecorder()
101
102 handler.ServeHTTP(rr, req)
103
104 assert.Equal(t, tt.wantStatus, rr.Code)
105
106 if tt.wantStatus == http.StatusForbidden {
107 var errResp dto.ErrorResponse
108 require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp))
109 assert.Equal(t, "Forbidden", errResp.Error)
110 }
111 })
112 }
113}
114
115func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) {
116 log := testLogger()
117 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
118 w.WriteHeader(http.StatusOK)
119 })
120
121 handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
122 srv := httptest.NewServer(handler)
123 defer srv.Close()
124
125 client := srv.Client()
126 client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
127
128 req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
129 req.Host = "o2.bnema.dev"
130 resp, err := client.Do(req)
131 require.NoError(t, err)
132 defer resp.Body.Close()
133
134 assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
135 assert.Equal(t, "https://o2.bnema.dev/", resp.Header.Get("Location"))
136}
137
138func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) {
139 log := testLogger()
140 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
141 w.WriteHeader(http.StatusOK)
142 })
143
144 handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
145 srv := httptest.NewServer(handler)
146 defer srv.Close()
147
148 client := srv.Client()
149 client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
150
151 req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
152 req.Host = "o2.bnema.dev:8088"
153 resp, err := client.Do(req)
154 require.NoError(t, err)
155 defer resp.Body.Close()
156
157 assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
158 assert.Equal(t, "https://o2.bnema.dev:8443/", resp.Header.Get("Location"))
159}
160
161func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
162 log := testLogger()
163 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
164 w.WriteHeader(http.StatusOK)
165 })
166
167 handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
168 srv := httptest.NewServer(handler)
169 defer srv.Close()
170
171 client := srv.Client()
172 client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
173
174 req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
175 req.Host = "o2.bnema.dev:9999"
176 resp, err := client.Do(req)
177 require.NoError(t, err)
178 defer resp.Body.Close()
179
180 assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode)
181 assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location"))
182}
183
184func TestHTTPSRedirect_InvalidExplicitPort_IsPreserved(t *testing.T) {
185 log := testLogger()
186 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
187 w.WriteHeader(http.StatusOK)
188 })
189
190 handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
191 srv := httptest.NewServer(handler)
192 defer srv.Close()
193
194 // Use a raw TCP connection because Go's HTTP client rejects the
195 // invalid port in the Location header before CheckRedirect fires.
196 conn, err := net.Dial("tcp", srv.Listener.Addr().String())
197 require.NoError(t, err)
198 defer conn.Close()
199
200 _, err = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: o2.bnema.dev:abcd\r\nConnection: close\r\n\r\n")
201 require.NoError(t, err)
202
203 var buf [4096]byte
204 n, _ := conn.Read(buf[:])
205 raw := string(buf[:n])
206
207 assert.Contains(t, raw, "308")
208 assert.Contains(t, raw, "Location: https://o2.bnema.dev:abcd/")
209}
210
211func TestProxyCIDRAllowlist(t *testing.T) {
212 log := testLogger()
213 redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
214 http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusPermanentRedirect)
215 })
216
217 tests := []struct {
218 name string
219 allowedNets []*net.IPNet
220 remoteAddr string
221 wantStatus int
222 }{
223 {
224 name: "no CIDRs configured passes through",
225 allowedNets: nil,
226 remoteAddr: "203.0.113.50:1234",
227 wantStatus: http.StatusPermanentRedirect,
228 },
229 {
230 name: "allowed IP gets redirect",
231 allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
232 remoteAddr: "173.245.48.1:1234",
233 wantStatus: http.StatusPermanentRedirect,
234 },
235 {
236 name: "blocked IP gets 403",
237 allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
238 remoteAddr: "203.0.113.50:1234",
239 wantStatus: http.StatusForbidden,
240 },
241 {
242 name: "localhost always allowed",
243 allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
244 remoteAddr: "127.0.0.1:1234",
245 wantStatus: http.StatusPermanentRedirect,
246 },
247 {
248 name: "ignores XFF (uses RemoteAddr only)",
249 allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
250 remoteAddr: "203.0.113.50:1234",
251 wantStatus: http.StatusForbidden,
252 },
253 }
254
255 for _, tt := range tests {
256 t.Run(tt.name, func(t *testing.T) {
257 handler := ProxyCIDRAllowlist(tt.allowedNets, log)(redirect)
258
259 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
260 req.RemoteAddr = tt.remoteAddr
261 // Even with XFF from an "allowed" range, the proxy middleware
262 // must check RemoteAddr, not forwarded headers.
263 req.Header.Set("X-Forwarded-For", "173.245.48.1")
264 rr := httptest.NewRecorder()
265
266 handler.ServeHTTP(rr, req)
267
268 assert.Equal(t, tt.wantStatus, rr.Code)
269
270 if tt.wantStatus == http.StatusForbidden {
271 var errResp dto.ErrorResponse
272 require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp))
273 assert.Equal(t, "Forbidden", errResp.Error)
274 }
275 })
276 }
277}