1package webmail
2
3import (
4 "archive/zip"
5 "bytes"
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "mime/multipart"
11 "net/http"
12 "net/http/httptest"
13 "net/textproto"
14 "os"
15 "path/filepath"
16 "reflect"
17 "strings"
18 "testing"
19 "time"
20
21 "golang.org/x/net/html"
22
23 "github.com/mjl-/sherpa"
24
25 "github.com/mjl-/mox/message"
26 "github.com/mjl-/mox/mox-"
27 "github.com/mjl-/mox/moxio"
28 "github.com/mjl-/mox/store"
29 "github.com/mjl-/mox/webauth"
30)
31
32var ctxbg = context.Background()
33
34func init() {
35 webauth.BadAuthDelay = 0
36}
37
38func tcheck(t *testing.T, err error, msg string) {
39 t.Helper()
40 if err != nil {
41 t.Fatalf("%s: %s", msg, err)
42 }
43}
44
45func tcompare(t *testing.T, got, exp any) {
46 t.Helper()
47 if !reflect.DeepEqual(got, exp) {
48 t.Fatalf("got %v, expected %v", got, exp)
49 }
50}
51
52type Message struct {
53 From, To, Cc, Bcc, Subject, MessageID string
54 Headers [][2]string
55 Date time.Time
56 References string
57 Part Part
58}
59
60type Part struct {
61 Type string
62 ID string
63 Disposition string
64 TransferEncoding string
65
66 Content string
67 Parts []Part
68
69 boundary string
70}
71
72func (m Message) Marshal(t *testing.T) []byte {
73 if m.Date.IsZero() {
74 m.Date = time.Now()
75 }
76 if m.MessageID == "" {
77 m.MessageID = "<" + mox.MessageIDGen(false) + ">"
78 }
79
80 var b bytes.Buffer
81 header := func(k, v string) {
82 if v == "" {
83 return
84 }
85 _, err := fmt.Fprintf(&b, "%s: %s\r\n", k, v)
86 tcheck(t, err, "write header")
87 }
88
89 header("From", m.From)
90 header("To", m.To)
91 header("Cc", m.Cc)
92 header("Bcc", m.Bcc)
93 header("Subject", m.Subject)
94 header("Message-Id", m.MessageID)
95 header("Date", m.Date.Format(message.RFC5322Z))
96 header("References", m.References)
97 for _, t := range m.Headers {
98 header(t[0], t[1])
99 }
100 header("Mime-Version", "1.0")
101 if len(m.Part.Parts) > 0 {
102 m.Part.boundary = multipart.NewWriter(io.Discard).Boundary()
103 }
104 m.Part.WriteHeader(t, &b)
105 m.Part.WriteBody(t, &b)
106 return b.Bytes()
107}
108
109func (p Part) Header() textproto.MIMEHeader {
110 h := textproto.MIMEHeader{}
111 add := func(k, v string) {
112 if v != "" {
113 h.Add(k, v)
114 }
115 }
116 ct := p.Type
117 if p.boundary != "" {
118 ct += fmt.Sprintf(`; boundary="%s"`, p.boundary)
119 }
120 add("Content-Type", ct)
121 add("Content-Id", p.ID)
122 add("Content-Disposition", p.Disposition)
123 add("Content-Transfer-Encoding", p.TransferEncoding) // todo: ensure if not multipart? probably ensure before calling headre
124 return h
125}
126
127func (p Part) WriteHeader(t *testing.T, w io.Writer) {
128 for k, vl := range p.Header() {
129 for _, v := range vl {
130 _, err := fmt.Fprintf(w, "%s: %s\r\n", k, v)
131 tcheck(t, err, "write header")
132 }
133 }
134 _, err := fmt.Fprint(w, "\r\n")
135 tcheck(t, err, "write line")
136}
137
138func (p Part) WriteBody(t *testing.T, w io.Writer) {
139 if len(p.Parts) == 0 {
140 switch p.TransferEncoding {
141 case "base64":
142 bw := moxio.Base64Writer(w)
143 _, err := bw.Write([]byte(p.Content))
144 tcheck(t, err, "writing base64")
145 err = bw.Close()
146 tcheck(t, err, "closing base64 part")
147 case "":
148 if p.Content == "" {
149 t.Fatalf("cannot write empty part")
150 }
151 if !strings.HasSuffix(p.Content, "\n") {
152 p.Content += "\n"
153 }
154 p.Content = strings.ReplaceAll(p.Content, "\n", "\r\n")
155 _, err := w.Write([]byte(p.Content))
156 tcheck(t, err, "write content")
157 default:
158 t.Fatalf("unknown transfer-encoding %q", p.TransferEncoding)
159 }
160 return
161 }
162
163 mp := multipart.NewWriter(w)
164 mp.SetBoundary(p.boundary)
165 for _, sp := range p.Parts {
166 if len(sp.Parts) > 0 {
167 sp.boundary = multipart.NewWriter(io.Discard).Boundary()
168 }
169 pw, err := mp.CreatePart(sp.Header())
170 tcheck(t, err, "create part")
171 sp.WriteBody(t, pw)
172 }
173 err := mp.Close()
174 tcheck(t, err, "close multipart")
175}
176
177var (
178 msgMinimal = Message{
179 Part: Part{Type: "text/plain", Content: "the body"},
180 }
181 msgText = Message{
182 From: "mjl <mjl@mox.example>",
183 To: "mox <mox@other.example>",
184 Subject: "text message",
185 Part: Part{Type: "text/plain; charset=utf-8", Content: "the body"},
186 }
187 msgHTML = Message{
188 From: "mjl <mjl@mox.example>",
189 To: "mox <mox@other.example>",
190 Subject: "html message",
191 Part: Part{Type: "text/html", Content: `<html>the body <img src="cid:img1@mox.example" /></html>`},
192 }
193 msgAlt = Message{
194 From: "mjl <mjl@mox.example>",
195 To: "mox <mox@other.example>",
196 Subject: "test",
197 MessageID: "<alt@localhost>",
198 Headers: [][2]string{{"In-Reply-To", "<previous@host.example>"}},
199 Part: Part{
200 Type: "multipart/alternative",
201 Parts: []Part{
202 {Type: "text/plain", Content: "the body"},
203 {Type: "text/html; charset=utf-8", Content: `<html>the body <img src="cid:img1@mox.example" /></html>`},
204 },
205 },
206 }
207 msgAltReply = Message{
208 Subject: "Re: test",
209 References: "<alt@localhost>",
210 Part: Part{Type: "text/plain", Content: "reply to alt"},
211 }
212 msgAltRel = Message{
213 From: "mjl <mjl+altrel@mox.example>",
214 To: "mox <mox+altrel@other.example>",
215 Subject: "test with alt and rel",
216 Headers: [][2]string{{"X-Special", "testing"}},
217 Part: Part{
218 Type: "multipart/alternative",
219 Parts: []Part{
220 {Type: "text/plain", Content: "the text body"},
221 {
222 Type: "multipart/related",
223 Parts: []Part{
224 {
225 Type: "text/html; charset=utf-8",
226 Content: `<html>the body <img src="cid:img1@mox.example" /></html>`,
227 },
228 {Type: `image/png`, Disposition: `inline; filename="test1.png"`, ID: "<img1@mox.example>", Content: `PNG...`, TransferEncoding: "base64"},
229 },
230 },
231 },
232 },
233 }
234 msgAttachments = Message{
235 From: "mjl <mjl@mox.example>",
236 To: "mox <mox@other.example>",
237 Subject: "test",
238 Part: Part{
239 Type: "multipart/mixed",
240 Parts: []Part{
241 {Type: "text/plain", Content: "the body"},
242 {Type: "image/png", TransferEncoding: "base64", Content: `PNG...`},
243 {Type: "image/png", TransferEncoding: "base64", Content: `PNG...`},
244 {Type: `image/jpg; name="test.jpg"`, TransferEncoding: "base64", Content: `JPG...`},
245 {Type: `image/jpg`, Disposition: `attachment; filename="test.jpg"`, TransferEncoding: "base64", Content: `JPG...`},
246 },
247 },
248 }
249)
250
251// Import test messages messages.
252type testmsg struct {
253 Mailbox string
254 Flags store.Flags
255 Keywords []string
256 msg Message
257 m store.Message // As delivered.
258 ID int64 // Shortcut for m.ID
259}
260
261func tdeliver(t *testing.T, acc *store.Account, tm *testmsg) {
262 msgFile, err := store.CreateMessageTemp(pkglog, "webmail-test")
263 tcheck(t, err, "create message temp")
264 defer os.Remove(msgFile.Name())
265 defer msgFile.Close()
266 size, err := msgFile.Write(tm.msg.Marshal(t))
267 tcheck(t, err, "write message temp")
268 m := store.Message{Flags: tm.Flags, Keywords: tm.Keywords, Size: int64(size)}
269 err = acc.DeliverMailbox(pkglog, tm.Mailbox, &m, msgFile)
270 tcheck(t, err, "deliver test message")
271 err = msgFile.Close()
272 tcheck(t, err, "closing test message")
273 tm.m = m
274 tm.ID = m.ID
275}
276
277func readBody(r io.Reader) string {
278 buf, err := io.ReadAll(r)
279 if err != nil {
280 return fmt.Sprintf("read error: %s", err)
281 }
282 return fmt.Sprintf("data: %q", buf)
283}
284
285// Test scenario with an account with some mailboxes, messages, then make all
286// kinds of changes and we check if we get the right events.
287// todo: check more of the results, we currently mostly check http statuses,
288// not the returned content.
289func TestWebmail(t *testing.T) {
290 mox.LimitersInit()
291 os.RemoveAll("../testdata/webmail/data")
292 mox.Context = ctxbg
293 mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
294 mox.MustLoadConfig(true, false)
295 defer store.Switchboard()()
296
297 acc, err := store.OpenAccount(pkglog, "mjl")
298 tcheck(t, err, "open account")
299 err = acc.SetPassword(pkglog, "test1234")
300 tcheck(t, err, "set password")
301 defer func() {
302 err := acc.Close()
303 pkglog.Check(err, "closing account")
304 }()
305
306 api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
307 apiHandler, err := makeSherpaHandler(api.maxMessageSize, api.cookiePath, false)
308 tcheck(t, err, "sherpa handler")
309
310 respRec := httptest.NewRecorder()
311 reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
312 ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
313
314 // Prepare loginToken.
315 loginCookie := &http.Cookie{Name: "webmaillogin"}
316 loginCookie.Value = api.LoginPrep(ctx)
317 reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
318
319 csrfToken := api.Login(ctx, loginCookie.Value, "mjl@mox.example", "test1234")
320 var sessionCookie *http.Cookie
321 for _, c := range respRec.Result().Cookies() {
322 if c.Name == "webmailsession" {
323 sessionCookie = c
324 break
325 }
326 }
327 if sessionCookie == nil {
328 t.Fatalf("missing session cookie")
329 }
330
331 reqInfo = requestInfo{"mjl@mox.example", "mjl", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
332 ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
333
334 tneedError(t, func() { api.MailboxCreate(ctx, "Inbox") }) // Cannot create inbox.
335 tneedError(t, func() { api.MailboxCreate(ctx, "Archive") }) // Already exists.
336 api.MailboxCreate(ctx, "Testbox1")
337 api.MailboxCreate(ctx, "Lists/Go/Nuts") // Creates hierarchy.
338
339 var zerom store.Message
340 var (
341 inboxMinimal = &testmsg{"Inbox", store.Flags{}, nil, msgMinimal, zerom, 0}
342 inboxText = &testmsg{"Inbox", store.Flags{}, nil, msgText, zerom, 0}
343 inboxHTML = &testmsg{"Inbox", store.Flags{}, nil, msgHTML, zerom, 0}
344 inboxAlt = &testmsg{"Inbox", store.Flags{}, nil, msgAlt, zerom, 0}
345 inboxAltRel = &testmsg{"Inbox", store.Flags{}, nil, msgAltRel, zerom, 0}
346 inboxAttachments = &testmsg{"Inbox", store.Flags{}, nil, msgAttachments, zerom, 0}
347 testbox1Alt = &testmsg{"Testbox1", store.Flags{}, nil, msgAlt, zerom, 0}
348 rejectsMinimal = &testmsg{"Rejects", store.Flags{Junk: true}, nil, msgMinimal, zerom, 0}
349 )
350 var testmsgs = []*testmsg{inboxMinimal, inboxText, inboxHTML, inboxAlt, inboxAltRel, inboxAttachments, testbox1Alt, rejectsMinimal}
351
352 for _, tm := range testmsgs {
353 tdeliver(t, acc, tm)
354 }
355
356 type httpHeaders [][2]string
357 ctHTML := [2]string{"Content-Type", "text/html; charset=utf-8"}
358 ctText := [2]string{"Content-Type", "text/plain; charset=utf-8"}
359 ctTextNoCharset := [2]string{"Content-Type", "text/plain"}
360 ctJS := [2]string{"Content-Type", "application/javascript; charset=utf-8"}
361 ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
362
363 cookieOK := &http.Cookie{Name: "webmailsession", Value: sessionCookie.Value}
364 cookieBad := &http.Cookie{Name: "webmailsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
365 hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
366 hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
367 hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
368 hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
369
370 testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
371 t.Helper()
372
373 req := httptest.NewRequest(method, path, nil)
374 for _, kv := range headers {
375 req.Header.Add(kv[0], kv[1])
376 }
377 rr := httptest.NewRecorder()
378 rr.Body = &bytes.Buffer{}
379 handle(apiHandler, false, rr, req)
380 if rr.Code != expStatusCode {
381 t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
382 }
383
384 resp := rr.Result()
385 for _, h := range expHeaders {
386 if resp.Header.Get(h[0]) != h[1] {
387 t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
388 }
389 }
390
391 if check != nil {
392 check(resp)
393 }
394 }
395 testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
396 t.Helper()
397 testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
398 }
399 testHTTPAuthREST := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
400 t.Helper()
401 testHTTP(method, path, httpHeaders{hdrSessionOK}, expStatusCode, expHeaders, check)
402 }
403
404 userAuthError := func(resp *http.Response, expCode string) {
405 t.Helper()
406
407 var response struct {
408 Error *sherpa.Error `json:"error"`
409 }
410 err := json.NewDecoder(resp.Body).Decode(&response)
411 tcheck(t, err, "parsing response as json")
412 if response.Error == nil {
413 t.Fatalf("expected sherpa error with code %s, no error", expCode)
414 }
415 if response.Error.Code != expCode {
416 t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
417 }
418 }
419 badAuth := func(resp *http.Response) {
420 t.Helper()
421 userAuthError(resp, "user:badAuth")
422 }
423 noAuth := func(resp *http.Response) {
424 t.Helper()
425 userAuthError(resp, "user:noAuth")
426 }
427
428 // HTTP webmail
429 testHTTP("GET", "/", httpHeaders{}, http.StatusOK, nil, nil)
430 testHTTP("POST", "/", httpHeaders{}, http.StatusMethodNotAllowed, nil, nil)
431 testHTTP("GET", "/", httpHeaders{[2]string{"Accept-Encoding", "gzip"}}, http.StatusOK, httpHeaders{ctHTML, [2]string{"Content-Encoding", "gzip"}}, nil)
432 testHTTP("GET", "/msg.js", httpHeaders{}, http.StatusOK, httpHeaders{ctJS}, nil)
433 testHTTP("POST", "/msg.js", httpHeaders{}, http.StatusMethodNotAllowed, nil, nil)
434 testHTTP("GET", "/text.js", httpHeaders{}, http.StatusOK, httpHeaders{ctJS}, nil)
435 testHTTP("POST", "/text.js", httpHeaders{}, http.StatusMethodNotAllowed, nil, nil)
436
437 testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
438 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
439 testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
440 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
441 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
442 testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
443 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
444 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
445 testHTTPAuthAPI("GET", "/api/Bogus", http.StatusMethodNotAllowed, nil, nil)
446 testHTTPAuthAPI("POST", "/api/Bogus", http.StatusNotFound, nil, nil)
447 testHTTPAuthAPI("POST", "/api/SSETypes", http.StatusOK, httpHeaders{ctJSON}, nil)
448
449 // Unknown.
450 testHTTP("GET", "/other", httpHeaders{}, http.StatusForbidden, nil, nil)
451
452 // HTTP message, generic
453 testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), nil, http.StatusForbidden, nil, nil)
454 testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrCSRFBad}, http.StatusForbidden, nil, nil)
455 testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrCSRFOK}, http.StatusForbidden, nil, nil)
456 testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
457 testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/attachments.zip", 0), http.StatusNotFound, nil, nil)
458 testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/attachments.zip", testmsgs[len(testmsgs)-1].ID+1), http.StatusNotFound, nil, nil)
459 testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil)
460 testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/view/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil)
461 testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/bogus/0", inboxMinimal.ID), http.StatusNotFound, nil, nil)
462 testHTTPAuthREST("GET", "/msg/", http.StatusNotFound, nil, nil)
463 testHTTPAuthREST("POST", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), http.StatusMethodNotAllowed, nil, nil)
464
465 // HTTP message: attachments.zip
466 ctZip := [2]string{"Content-Type", "application/zip"}
467 checkZip := func(resp *http.Response, fileContents [][2]string) {
468 t.Helper()
469 zipbuf, err := io.ReadAll(resp.Body)
470 tcheck(t, err, "reading response")
471 zr, err := zip.NewReader(bytes.NewReader(zipbuf), int64(len(zipbuf)))
472 tcheck(t, err, "open zip")
473 if len(fileContents) != len(zr.File) {
474 t.Fatalf("zip file has %d files, expected %d", len(fileContents), len(zr.File))
475 }
476 for i, fc := range fileContents {
477 if zr.File[i].Name != fc[0] {
478 t.Fatalf("zip, file at index %d is named %q, expected %q", i, zr.File[i].Name, fc[0])
479 }
480 f, err := zr.File[i].Open()
481 tcheck(t, err, "open file in zip")
482 buf, err := io.ReadAll(f)
483 tcheck(t, err, "read file in zip")
484 tcompare(t, string(buf), fc[1])
485 err = f.Close()
486 tcheck(t, err, "closing file")
487 }
488 }
489
490 pathInboxMinimal := fmt.Sprintf("/msg/%d", inboxMinimal.ID)
491 testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{}, http.StatusForbidden, nil, nil)
492 testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
493
494 testHTTPAuthREST("GET", pathInboxMinimal+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
495 checkZip(resp, nil)
496 })
497 pathInboxRelAlt := fmt.Sprintf("/msg/%d", inboxAltRel.ID)
498 testHTTPAuthREST("GET", pathInboxRelAlt+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
499 checkZip(resp, [][2]string{{"test1.png", "PNG..."}})
500 })
501 pathInboxAttachments := fmt.Sprintf("/msg/%d", inboxAttachments.ID)
502 testHTTPAuthREST("GET", pathInboxAttachments+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
503 checkZip(resp, [][2]string{{"attachment-1.png", "PNG..."}, {"attachment-2.png", "PNG..."}, {"test.jpg", "JPG..."}, {"test-1.jpg", "JPG..."}})
504 })
505
506 // HTTP message: raw
507 pathInboxAltRel := fmt.Sprintf("/msg/%d", inboxAltRel.ID)
508 pathInboxText := fmt.Sprintf("/msg/%d", inboxText.ID)
509 testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{}, http.StatusForbidden, nil, nil)
510 testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
511 testHTTPAuthREST("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil)
512 testHTTPAuthREST("GET", pathInboxText+"/raw", http.StatusOK, httpHeaders{ctText}, nil)
513
514 // HTTP message: parsedmessage.js
515 testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusForbidden, nil, nil)
516 testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
517 testHTTPAuthREST("GET", pathInboxMinimal+"/parsedmessage.js", http.StatusOK, httpHeaders{ctJS}, nil)
518
519 mox.LimitersInit()
520 // HTTP message: text,html,htmlexternal and msgtext,msghtml,msghtmlexternal
521 for _, elem := range []string{"text", "html", "htmlexternal", "msgtext", "msghtml", "msghtmlexternal"} {
522 testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{}, http.StatusForbidden, nil, nil)
523 testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
524 mox.LimitersInit() // Reset, for too many failures.
525 }
526
527 // The text endpoint serves JS that we generated, so should be safe, but still doesn't hurt to have a CSP.
528 cspText := [2]string{
529 "Content-Security-Policy",
530 "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'",
531 }
532 // HTML as viewed in the regular viewer, not in a new tab.
533 cspHTML := [2]string{
534 "Content-Security-Policy",
535 "sandbox allow-popups allow-popups-to-escape-sandbox; frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'",
536 }
537 // HTML when in separate message tab, needs allow-same-origin for iframe inner height.
538 cspHTMLSameOrigin := [2]string{
539 "Content-Security-Policy",
540 "sandbox allow-popups allow-popups-to-escape-sandbox allow-same-origin; frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'",
541 }
542 // Like cspHTML, but allows http and https resources.
543 cspHTMLExternal := [2]string{
544 "Content-Security-Policy",
545 "sandbox allow-popups allow-popups-to-escape-sandbox; frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:",
546 }
547 // HTML with external resources when opened in separate tab, with allow-same-origin for iframe inner height.
548 cspHTMLExternalSameOrigin := [2]string{
549 "Content-Security-Policy",
550 "sandbox allow-popups allow-popups-to-escape-sandbox allow-same-origin; frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:",
551 }
552 // Msg page, our JS, that loads an html iframe, already blocks access for the iframe.
553 cspMsgHTML := [2]string{
554 "Content-Security-Policy",
555 "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'",
556 }
557 // Msg page that already allows external resources for the iframe.
558 cspMsgHTMLExternal := [2]string{
559 "Content-Security-Policy",
560 "frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'",
561 }
562 testHTTPAuthREST("GET", pathInboxAltRel+"/text", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)
563 testHTTPAuthREST("GET", pathInboxAltRel+"/html", http.StatusOK, httpHeaders{ctHTML, cspHTML}, nil)
564 testHTTPAuthREST("GET", pathInboxAltRel+"/htmlexternal", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternal}, nil)
565 testHTTPAuthREST("GET", pathInboxAltRel+"/msgtext", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)
566 testHTTPAuthREST("GET", pathInboxAltRel+"/msghtml", http.StatusOK, httpHeaders{ctHTML, cspMsgHTML}, nil)
567 testHTTPAuthREST("GET", pathInboxAltRel+"/msghtmlexternal", http.StatusOK, httpHeaders{ctHTML, cspMsgHTMLExternal}, nil)
568
569 testHTTPAuthREST("GET", pathInboxAltRel+"/html?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLSameOrigin}, nil)
570 testHTTPAuthREST("GET", pathInboxAltRel+"/htmlexternal?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternalSameOrigin}, nil)
571
572 // No HTML part.
573 for _, elem := range []string{"html", "htmlexternal", "msghtml", "msghtmlexternal"} {
574 testHTTPAuthREST("GET", pathInboxText+"/"+elem, http.StatusBadRequest, nil, nil)
575
576 }
577 // No text part.
578 pathInboxHTML := fmt.Sprintf("/msg/%d", inboxHTML.ID)
579 for _, elem := range []string{"text", "msgtext"} {
580 testHTTPAuthREST("GET", pathInboxHTML+"/"+elem, http.StatusBadRequest, nil, nil)
581 }
582
583 // HTTP message part: view,viewtext,download
584 for _, elem := range []string{"view", "viewtext", "download"} {
585 testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{}, http.StatusForbidden, nil, nil)
586 testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
587 testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0", http.StatusOK, nil, nil)
588 testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.0", http.StatusOK, nil, nil)
589 testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.1", http.StatusOK, nil, nil)
590 testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.2", http.StatusNotFound, nil, nil)
591 testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/1", http.StatusNotFound, nil, nil)
592 }
593
594 // Logout invalidates the session. Must work exactly once.
595 // Normally the generic /api/ auth check returns a user error. We bypass it and
596 // check for the server error.
597 sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
598 reqInfo = requestInfo{"mjl@mox.example", "mjl", sessionToken, httptest.NewRecorder(), &http.Request{RemoteAddr: "127.0.0.1:1234"}}
599 ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
600 api.Logout(ctx)
601 tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
602}
603
604func TestSanitize(t *testing.T) {
605 check := func(s string, exp string) {
606 t.Helper()
607 n, err := html.Parse(strings.NewReader(s))
608 tcheck(t, err, "parsing html")
609 sanitizeNode(n)
610 var sb strings.Builder
611 err = html.Render(&sb, n)
612 tcheck(t, err, "writing html")
613 if sb.String() != exp {
614 t.Fatalf("sanitizing html: %s\ngot: %s\nexpected: %s", s, sb.String(), exp)
615 }
616 }
617
618 check(``,
619 `<html><head><base target="_blank" rel="noopener noreferrer"/></head><body></body></html>`)
620 check(`<script>read localstorage</script>`,
621 `<html><head><base target="_blank" rel="noopener noreferrer"/></head><body></body></html>`)
622 check(`<a href="javascript:evil">click me</a>`,
623 `<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><a target="_blank" rel="noopener noreferrer">click me</a></body></html>`)
624 check(`<a href="https://badsite" target="top">click me</a>`,
625 `<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><a href="https://badsite" target="_blank" rel="noopener noreferrer">click me</a></body></html>`)
626 check(`<a xlink:href="https://badsite">click me</a>`,
627 `<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><a xlink:href="https://badsite" target="_blank" rel="noopener noreferrer">click me</a></body></html>`)
628 check(`<a onclick="evil">click me</a>`,
629 `<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><a target="_blank" rel="noopener noreferrer">click me</a></body></html>`)
630 check(`<iframe src="data:text/html;base64,evilhtml"></iframe>`,
631 `<html><head><base target="_blank" rel="noopener noreferrer"/></head><body><iframe></iframe></body></html>`)
632}
633