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