1package http
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "maps"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "os"
13 "path/filepath"
14 "strings"
15 "testing"
16
17 "golang.org/x/net/websocket"
18
19 "github.com/mjl-/mox/mox-"
20)
21
22func tcheck(t *testing.T, err error, msg string) {
23 t.Helper()
24 if err != nil {
25 t.Fatalf("%s: %s", msg, err)
26 }
27}
28
29func TestWebserver(t *testing.T) {
30 os.RemoveAll("../testdata/webserver/data")
31 mox.ConfigStaticPath = filepath.FromSlash("../testdata/webserver/mox.conf")
32 mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
33 mox.MustLoadConfig(true, false)
34
35 loadStaticGzipCache(mox.DataDirPath("tmp/httpstaticcompresscache"), 1024*1024)
36
37 srv := &serve{Webserver: true}
38
39 test := func(method, target string, reqhdrs map[string]string, expCode int, expContent string, expHeaders map[string]string) {
40 t.Helper()
41
42 req := httptest.NewRequest(method, target, nil)
43 for k, v := range reqhdrs {
44 req.Header.Add(k, v)
45 }
46 rw := httptest.NewRecorder()
47 rw.Body = &bytes.Buffer{}
48 srv.ServeHTTP(rw, req)
49 resp := rw.Result()
50 if resp.StatusCode != expCode {
51 t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expCode)
52 }
53 if expContent != "" {
54 s := rw.Body.String()
55 if s != expContent {
56 t.Fatalf("got response data %q, expected %q", s, expContent)
57 }
58 }
59 for k, v := range expHeaders {
60 if xv := resp.Header.Get(k); xv != v {
61 t.Fatalf("got %q for header %q, expected %q", xv, k, v)
62 }
63 }
64 }
65
66 test("GET", "http://redir.mox.example", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "https://mox.example/"})
67
68 // http to https redirect, and stay on https afterwards without redirect loop.
69 test("GET", "http://schemeredir.example", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "https://schemeredir.example/"})
70 test("GET", "https://schemeredir.example", nil, http.StatusNotFound, "", nil)
71
72 accgzip := map[string]string{"Accept-Encoding": "gzip"}
73 test("GET", "http://mox.example/static/", accgzip, http.StatusOK, "", map[string]string{"X-Test": "mox", "Content-Encoding": "gzip"}) // index.html
74 test("GET", "http://mox.example/static/dir/hi.txt", accgzip, http.StatusOK, "", map[string]string{"X-Test": "mox", "Content-Encoding": ""}) // too small to compress
75 test("GET", "http://mox.example/static/dir/", accgzip, http.StatusOK, "", map[string]string{"X-Test": "mox", "Content-Encoding": "gzip"}) // listing
76 test("GET", "http://mox.example/static/dir", accgzip, http.StatusTemporaryRedirect, "", map[string]string{"Location": "/static/dir/"}) // redirect to dir
77 test("GET", "http://mox.example/static/bogus", accgzip, http.StatusNotFound, "", map[string]string{"Content-Encoding": ""})
78
79 test("GET", "http://mox.example/nolist/", nil, http.StatusOK, "", nil) // index.html
80 test("GET", "http://mox.example/nolist/dir/", nil, http.StatusForbidden, "", nil) // no listing
81
82 test("GET", "http://mox.example/tls/", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "https://mox.example/tls/"}) // redirect to tls
83
84 test("GET", "http://mox.example/baseurl/x?y=2", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "https://tls.mox.example/baseurl/x?q=1&y=2#fragment"})
85 test("GET", "http://mox.example/pathonly/old/x?q=2", nil, http.StatusTemporaryRedirect, "", map[string]string{"Location": "http://mox.example/pathonly/new/x?q=2"})
86 test("GET", "http://mox.example/baseurlpath/old/x?y=2", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "//other.mox.example/baseurlpath/new/x?q=1&y=2#fragment"})
87
88 test("GET", "http://mox.example/strip/x", nil, http.StatusBadGateway, "", nil) // no server yet
89 test("GET", "http://mox.example/nostrip/x", nil, http.StatusBadGateway, "", nil) // no server yet
90
91 badForwarded := map[string]string{
92 "Forwarded": "bad",
93 "X-Forwarded-For": "bad",
94 "X-Forwarded-Proto": "bad",
95 "X-Forwarded-Host": "bad",
96 "X-Forwarded-Ext": "bad",
97 }
98
99 // Server that echoes path, and forwarded request headers.
100 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101 for k, v := range badForwarded {
102 if r.Header.Get(k) == v {
103 w.WriteHeader(http.StatusInternalServerError)
104 return
105 }
106 }
107
108 for k, vl := range r.Header {
109 if k == "Forwarded" || k == "X-Forwarded" || strings.HasPrefix(k, "X-Forwarded-") {
110 w.Header()[k] = vl
111 }
112 }
113 w.Write([]byte(r.URL.Path))
114 }))
115 defer server.Close()
116
117 serverURL, err := url.Parse(server.URL)
118 if err != nil {
119 t.Fatalf("parsing url: %v", err)
120 }
121 serverURL.Path = "/a"
122
123 // warning: it is not normally allowed to access the dynamic config without lock. don't propagate accesses like this!
124 mox.Conf.Dynamic.WebHandlers[len(mox.Conf.Dynamic.WebHandlers)-2].WebForward.TargetURL = serverURL
125 mox.Conf.Dynamic.WebHandlers[len(mox.Conf.Dynamic.WebHandlers)-1].WebForward.TargetURL = serverURL
126
127 test("GET", "http://mox.example/strip/x", badForwarded, http.StatusOK, "/a/x", map[string]string{
128 "X-Test": "mox",
129 "X-Forwarded-For": "192.0.2.1", // IP is hardcoded in Go's src/net/http/httptest/httptest.go
130 "X-Forwarded-Proto": "http",
131 "X-Forwarded-Host": "mox.example",
132 "X-Forwarded-Ext": "",
133 })
134 test("GET", "http://mox.example/nostrip/x", map[string]string{"X-OK": "ok"}, http.StatusOK, "/a/nostrip/x", map[string]string{"X-Test": "mox"})
135
136 test("GET", "http://mox.example/bogus", nil, http.StatusNotFound, "", nil) // path not registered.
137 test("GET", "http://bogus.mox.example/static/", nil, http.StatusNotFound, "", nil) // domain not registered.
138 test("GET", "http://mox.example/xadmin/", nil, http.StatusOK, "", nil) // internal admin service
139 test("GET", "http://mox.example/xaccount/", nil, http.StatusOK, "", nil) // internal account service
140 test("GET", "http://mox.example/xwebmail/", nil, http.StatusOK, "", nil) // internal webmail service
141 test("GET", "http://mox.example/xwebapi/v0/", nil, http.StatusOK, "", nil) // internal webapi service
142
143 npaths := len(staticgzcache.paths)
144 if npaths != 1 {
145 t.Fatalf("%d file(s) in staticgzcache, expected 1", npaths)
146 }
147 loadStaticGzipCache(mox.DataDirPath("tmp/httpstaticcompresscache"), 1024*1024)
148 npaths = len(staticgzcache.paths)
149 if npaths != 1 {
150 t.Fatalf("%d file(s) in staticgzcache after loading from disk, expected 1", npaths)
151 }
152 loadStaticGzipCache(mox.DataDirPath("tmp/httpstaticcompresscache"), 0)
153 npaths = len(staticgzcache.paths)
154 if npaths != 0 {
155 t.Fatalf("%d file(s) in staticgzcache after setting max size to 0, expected 0", npaths)
156 }
157 loadStaticGzipCache(mox.DataDirPath("tmp/httpstaticcompresscache"), 0)
158 npaths = len(staticgzcache.paths)
159 if npaths != 0 {
160 t.Fatalf("%d file(s) in staticgzcache after setting max size to 0 and reloading from disk, expected 0", npaths)
161 }
162}
163
164func TestWebsocket(t *testing.T) {
165 os.RemoveAll("../testdata/websocket/data")
166 mox.ConfigStaticPath = filepath.FromSlash("../testdata/websocket/mox.conf")
167 mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
168 mox.MustLoadConfig(true, false)
169
170 srv := &serve{Webserver: true}
171
172 var handler http.Handler // Active handler during test.
173 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174 handler.ServeHTTP(w, r)
175 }))
176
177 defer backend.Close()
178 backendURL, err := url.Parse(backend.URL)
179 if err != nil {
180 t.Fatalf("parsing backend url: %v", err)
181 }
182 backendURL.Path = "/"
183
184 // warning: it is not normally allowed to access the dynamic config without lock. don't propagate accesses like this!
185 mox.Conf.Dynamic.WebHandlers[len(mox.Conf.Dynamic.WebHandlers)-1].WebForward.TargetURL = backendURL
186
187 server := httptest.NewServer(srv)
188 defer server.Close()
189
190 serverURL, err := url.Parse(server.URL)
191 tcheck(t, err, "parsing server url")
192 _, port, err := net.SplitHostPort(serverURL.Host)
193 tcheck(t, err, "parsing host port in server url")
194 wsurl := fmt.Sprintf("ws://%s/ws/", net.JoinHostPort("localhost", port))
195
196 handler = websocket.Handler(func(c *websocket.Conn) {
197 io.Copy(c, c)
198 })
199
200 // Test a correct websocket connection.
201 wsconn, err := websocket.Dial(wsurl, "ignored", "http://ignored.example")
202 tcheck(t, err, "websocket dial")
203 _, err = fmt.Fprint(wsconn, "test")
204 tcheck(t, err, "write to websocket")
205 buf := make([]byte, 128)
206 n, err := wsconn.Read(buf)
207 tcheck(t, err, "read from websocket")
208 if string(buf[:n]) != "test" {
209 t.Fatalf(`got websocket data %q, expected "test"`, buf[:n])
210 }
211 err = wsconn.Close()
212 tcheck(t, err, "closing websocket connection")
213
214 // Test with server.ServeHTTP directly.
215 test := func(method string, reqhdrs map[string]string, expCode int, expHeaders map[string]string) {
216 t.Helper()
217
218 req := httptest.NewRequest(method, wsurl, nil)
219 for k, v := range reqhdrs {
220 req.Header.Add(k, v)
221 }
222 rw := httptest.NewRecorder()
223 rw.Body = &bytes.Buffer{}
224 srv.ServeHTTP(rw, req)
225 resp := rw.Result()
226 if resp.StatusCode != expCode {
227 t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expCode)
228 }
229 for k, v := range expHeaders {
230 if xv := resp.Header.Get(k); xv != v {
231 t.Fatalf("got %q for header %q, expected %q", xv, k, v)
232 }
233 }
234 }
235
236 wsreqhdrs := map[string]string{
237 "Upgrade": "keep-alive, websocket",
238 "Connection": "X, Upgrade",
239 "Sec-Websocket-Version": "13",
240 "Sec-Websocket-Key": "AAAAAAAAAAAAAAAAAAAAAA==",
241 }
242
243 test("POST", wsreqhdrs, http.StatusBadRequest, nil)
244
245 clone := func(m map[string]string) map[string]string {
246 r := map[string]string{}
247 maps.Copy(r, m)
248 return r
249 }
250
251 hdrs := clone(wsreqhdrs)
252 hdrs["Sec-Websocket-Version"] = "14"
253 test("GET", hdrs, http.StatusBadRequest, map[string]string{"Sec-Websocket-Version": "13"})
254
255 httpurl := fmt.Sprintf("http://%s/ws/", net.JoinHostPort("localhost", port))
256
257 // Must now do actual HTTP requests and read the HTTP response. Cannot call
258 // ServeHTTP because ResponseRecorder is not a http.Hijacker.
259 test = func(method string, reqhdrs map[string]string, expCode int, expHeaders map[string]string) {
260 t.Helper()
261
262 req, err := http.NewRequest(method, httpurl, nil)
263 tcheck(t, err, "http newrequest")
264 for k, v := range reqhdrs {
265 req.Header.Add(k, v)
266 }
267 resp, err := http.DefaultClient.Do(req)
268 tcheck(t, err, "http transaction")
269 if resp.StatusCode != expCode {
270 t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expCode)
271 }
272 for k, v := range expHeaders {
273 if xv := resp.Header.Get(k); xv != v {
274 t.Fatalf("got %q for header %q, expected %q", xv, k, v)
275 }
276 }
277 }
278
279 hdrs = clone(wsreqhdrs)
280 hdrs["Sec-Websocket-Key"] = "malformed"
281 test("GET", hdrs, http.StatusBadRequest, nil)
282
283 hdrs = clone(wsreqhdrs)
284 hdrs["Sec-Websocket-Key"] = "c2hvcnQK" // "short"
285 test("GET", hdrs, http.StatusBadRequest, nil)
286
287 // Not responding with a 101, but with regular 200 OK response.
288 handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
289 http.Error(w, "bad", http.StatusOK)
290 })
291 test("GET", wsreqhdrs, http.StatusBadRequest, nil)
292
293 // Respond with 101, but other websocket response headers missing.
294 handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
295 w.WriteHeader(http.StatusSwitchingProtocols)
296 })
297 test("GET", wsreqhdrs, http.StatusBadRequest, nil)
298
299 // With Upgrade: websocket, without Connection: Upgrade
300 handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
301 w.Header().Set("Upgrade", "websocket")
302 w.WriteHeader(http.StatusSwitchingProtocols)
303 })
304 test("GET", wsreqhdrs, http.StatusBadRequest, nil)
305
306 // With malformed Sec-WebSocket-Accept response header.
307 handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308 h := w.Header()
309 h.Set("Upgrade", "websocket")
310 h.Set("Connection", "Upgrade")
311 h.Set("Sec-WebSocket-Accept", "malformed")
312 w.WriteHeader(http.StatusSwitchingProtocols)
313 })
314 test("GET", wsreqhdrs, http.StatusBadRequest, nil)
315
316 // With malformed Sec-WebSocket-Accept response header.
317 handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
318 h := w.Header()
319 h.Set("Upgrade", "websocket")
320 h.Set("Connection", "Upgrade")
321 h.Set("Sec-WebSocket-Accept", "YmFk") // "bad"
322 w.WriteHeader(http.StatusSwitchingProtocols)
323 })
324 test("GET", wsreqhdrs, http.StatusBadRequest, nil)
325
326 // All good.
327 wsresphdrs := map[string]string{
328 "Connection": "Upgrade",
329 "Upgrade": "websocket",
330 "Sec-Websocket-Accept": "ICX+Yqv66kxgM0FcWaLWlFLwTAI=",
331 "X-Test": "mox",
332 }
333 handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
334 h := w.Header()
335 h.Set("Upgrade", "websocket")
336 h.Set("Connection", "Upgrade")
337 h.Set("Sec-WebSocket-Accept", "ICX+Yqv66kxgM0FcWaLWlFLwTAI=")
338 w.WriteHeader(http.StatusSwitchingProtocols)
339 })
340 test("GET", wsreqhdrs, http.StatusSwitchingProtocols, wsresphdrs)
341}
342