1// Package webmail implements a webmail client, serving html/js and providing an API for message actions and SSE endpoint for receiving real-time updates.
2package webmail
3
4// todo: should we be serving the messages/parts on a separate (sub)domain for user-content? to limit damage if the csp rules aren't enough.
5
6import (
7 "archive/zip"
8 "bytes"
9 "context"
10 "encoding/base64"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "io"
15 "io/fs"
16 "log/slog"
17 "mime"
18 "net/http"
19 "os"
20 "path/filepath"
21 "regexp"
22 "runtime/debug"
23 "strconv"
24 "strings"
25 "time"
26
27 _ "embed"
28
29 "golang.org/x/net/html"
30
31 "github.com/prometheus/client_golang/prometheus"
32 "github.com/prometheus/client_golang/prometheus/promauto"
33
34 "github.com/mjl-/bstore"
35 "github.com/mjl-/sherpa"
36
37 "github.com/mjl-/mox/message"
38 "github.com/mjl-/mox/metrics"
39 "github.com/mjl-/mox/mlog"
40 "github.com/mjl-/mox/mox-"
41 "github.com/mjl-/mox/moxio"
42 "github.com/mjl-/mox/store"
43 "github.com/mjl-/mox/webauth"
44 "github.com/mjl-/mox/webops"
45)
46
47var pkglog = mlog.New("webmail", nil)
48
49type ctxKey string
50
51// We pass the request to the sherpa handler so the TLS info can be used for
52// the Received header in submitted messages. Most API calls need just the
53// account name.
54var requestInfoCtxKey ctxKey = "requestInfo"
55
56type requestInfo struct {
57 Log mlog.Log
58 LoginAddress string
59 Account *store.Account // Nil only for methods Login and LoginPrep.
60 SessionToken store.SessionToken
61 Response http.ResponseWriter
62 Request *http.Request // For Proto and TLS connection state during message submit.
63}
64
65//go:embed webmail.html
66var webmailHTML []byte
67
68//go:embed webmail.js
69var webmailJS []byte
70
71//go:embed msg.html
72var webmailmsgHTML []byte
73
74//go:embed msg.js
75var webmailmsgJS []byte
76
77//go:embed text.html
78var webmailtextHTML []byte
79
80//go:embed text.js
81var webmailtextJS []byte
82
83var (
84 // Similar between ../webmail/webmail.go:/metricSubmission and ../smtpserver/server.go:/metricSubmission and ../webapisrv/server.go:/metricSubmission
85 metricSubmission = promauto.NewCounterVec(
86 prometheus.CounterOpts{
87 Name: "mox_webmail_submission_total",
88 Help: "Webmail message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror, domaindisabled.",
89 },
90 []string{
91 "result",
92 },
93 )
94 metricServerErrors = promauto.NewCounterVec(
95 prometheus.CounterOpts{
96 Name: "mox_webmail_errors_total",
97 Help: "Webmail server errors, known values: dkimsign, submit.",
98 },
99 []string{
100 "error",
101 },
102 )
103 metricSSEConnections = promauto.NewGauge(
104 prometheus.GaugeOpts{
105 Name: "mox_webmail_sse_connections",
106 Help: "Number of active webmail SSE connections.",
107 },
108 )
109)
110
111func xcheckf(ctx context.Context, err error, format string, args ...any) {
112 if err == nil {
113 return
114 }
115 msg := fmt.Sprintf(format, args...)
116 errmsg := fmt.Sprintf("%s: %s", msg, err)
117 pkglog.WithContext(ctx).Errorx(msg, err)
118 code := "server:error"
119 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
120 code = "user:error"
121 }
122 panic(&sherpa.Error{Code: code, Message: errmsg})
123}
124
125func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
126 if err == nil {
127 return
128 }
129 msg := fmt.Sprintf(format, args...)
130 errmsg := fmt.Sprintf("%s: %s", msg, err)
131 pkglog.WithContext(ctx).Errorx(msg, err)
132 panic(&sherpa.Error{Code: "user:error", Message: errmsg})
133}
134
135func xdbwrite(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
136 err := acc.DB.Write(ctx, func(tx *bstore.Tx) error {
137 fn(tx)
138 return nil
139 })
140 xcheckf(ctx, err, "transaction")
141}
142
143func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
144 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
145 fn(tx)
146 return nil
147 })
148 xcheckf(ctx, err, "transaction")
149}
150
151var webmailFile = &mox.WebappFile{
152 HTML: webmailHTML,
153 JS: webmailJS,
154 HTMLPath: filepath.FromSlash("webmail/webmail.html"),
155 JSPath: filepath.FromSlash("webmail/webmail.js"),
156 CustomStem: "webmail",
157}
158
159func customization() (css, js []byte, err error) {
160 if css, err = os.ReadFile(mox.ConfigDirPath("webmail.css")); err != nil && !errors.Is(err, fs.ErrNotExist) {
161 return nil, nil, err
162 }
163 if js, err = os.ReadFile(mox.ConfigDirPath("webmail.js")); err != nil && !errors.Is(err, fs.ErrNotExist) {
164 return nil, nil, err
165 }
166 css = append([]byte("/* Custom CSS by admin from $configdir/webmail.css: */\n"), css...)
167 js = append([]byte("// Custom JS by admin from $configdir/webmail.js:\n"), js...)
168 js = append(js, '\n')
169 return css, js, nil
170}
171
172// Serve HTML content, either from a file, or return the fallback data. If
173// customize is set, css/js is inserted if configured. Caller should already have
174// set the content-type. We use this to return a file from the local file system
175// (during development), or embedded in the binary (when deployed).
176func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte, customize bool) {
177 serve := func(mtime time.Time, rd io.ReadSeeker) {
178 if customize {
179 buf, err := io.ReadAll(rd)
180 if err != nil {
181 log.Errorx("reading content to customize", err)
182 http.Error(w, "500 - internal server error - reading content to customize", http.StatusInternalServerError)
183 return
184 }
185 customCSS, customJS, err := customization()
186 if err != nil {
187 log.Errorx("reading customizations", err)
188 http.Error(w, "500 - internal server error - reading customizations", http.StatusInternalServerError)
189 return
190 }
191 buf = bytes.Replace(buf, []byte("/* css placeholder */"), customCSS, 1)
192 buf = bytes.Replace(buf, []byte("/* js placeholder */"), customJS, 1)
193 rd = bytes.NewReader(buf)
194 }
195 http.ServeContent(w, r, "", mtime, rd)
196 }
197
198 f, err := os.Open(path)
199 if err == nil {
200 defer func() {
201 err := f.Close()
202 log.Check(err, "closing serve file")
203 }()
204 st, err := f.Stat()
205 if err == nil {
206 serve(st.ModTime(), f)
207 return
208 }
209 }
210 serve(mox.FallbackMtime(log), bytes.NewReader(fallback))
211}
212
213func init() {
214 mox.NewWebmailHandler = func(maxMsgSize int64, basePath string, isForwarded bool, accountPath string) http.Handler {
215 return http.HandlerFunc(Handler(maxMsgSize, basePath, isForwarded, accountPath))
216 }
217}
218
219// Handler returns a handler for the webmail endpoints, customized for the max
220// message size coming from the listener and cookiePath.
221func Handler(maxMessageSize int64, cookiePath string, isForwarded bool, accountPath string) func(w http.ResponseWriter, r *http.Request) {
222 sh, err := makeSherpaHandler(maxMessageSize, cookiePath, isForwarded)
223 return func(w http.ResponseWriter, r *http.Request) {
224 if err != nil {
225 http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
226 return
227 }
228 handle(sh, isForwarded, accountPath, w, r)
229 }
230}
231
232func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w http.ResponseWriter, r *http.Request) {
233 ctx := r.Context()
234 log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
235
236 // Server-sent event connection, for all initial data (list of mailboxes), list of
237 // messages, and all events afterwards. Authenticated through a single use token in
238 // the query string, which it got from a Token API call.
239 if r.URL.Path == "/events" {
240 serveEvents(ctx, log, accountPath, w, r)
241 return
242 }
243
244 defer func() {
245 x := recover()
246 if x == nil {
247 return
248 }
249 err, ok := x.(*sherpa.Error)
250 if !ok {
251 log.WithContext(ctx).Error("handle panic", slog.Any("err", x))
252 debug.PrintStack()
253 metrics.PanicInc(metrics.Webmailhandle)
254 panic(x)
255 }
256 if strings.HasPrefix(err.Code, "user:") {
257 log.Debugx("webmail user error", err)
258 http.Error(w, "400 - bad request - "+err.Message, http.StatusBadRequest)
259 } else {
260 log.Errorx("webmail server error", err)
261 http.Error(w, "500 - internal server error - "+err.Message, http.StatusInternalServerError)
262 }
263 }()
264
265 switch r.URL.Path {
266 case "/":
267 switch r.Method {
268 case "GET", "HEAD":
269 h := w.Header()
270 h.Set("X-Frame-Options", "deny")
271 h.Set("Referrer-Policy", "same-origin")
272 webmailFile.Serve(ctx, log, w, r)
273 default:
274 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
275 }
276 return
277
278 case "/licenses.txt":
279 switch r.Method {
280 case "GET", "HEAD":
281 h := w.Header()
282 h.Set("Content-Type", "text/plain; charset=utf-8")
283 mox.LicensesWrite(w)
284 default:
285 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
286 }
287 return
288
289 case "/msg.js", "/text.js":
290 switch r.Method {
291 default:
292 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
293 return
294 case "GET", "HEAD":
295 }
296
297 path := filepath.Join("webmail", r.URL.Path[1:])
298 var fallback = webmailmsgJS
299 if r.URL.Path == "/text.js" {
300 fallback = webmailtextJS
301 }
302
303 w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
304 serveContentFallback(log, w, r, path, fallback, false)
305 return
306 }
307
308 isAPI := strings.HasPrefix(r.URL.Path, "/api/")
309 // Only allow POST for calls, they will not work cross-domain without CORS.
310 if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
311 http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
312 return
313 }
314
315 var loginAddress, accName string
316 var sessionToken store.SessionToken
317 // All other URLs, except the login endpoint require some authentication.
318 if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
319 var ok bool
320 isExport := r.URL.Path == "/export"
321 requireCSRF := isAPI || isExport
322 accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webmail", isForwarded, w, r, isAPI, requireCSRF, isExport)
323 if !ok {
324 // Response has been written already.
325 return
326 }
327 }
328
329 if isAPI {
330 var acc *store.Account
331 if accName != "" {
332 log = log.With(slog.String("account", accName))
333 var err error
334 acc, err = store.OpenAccount(log, accName, true)
335 if err != nil {
336 log.Errorx("open account", err)
337 http.Error(w, "500 - internal server error - error opening account", http.StatusInternalServerError)
338 return
339 }
340 defer func() {
341 err := acc.Close()
342 log.Check(err, "closing account")
343 }()
344 }
345 reqInfo := requestInfo{log, loginAddress, acc, sessionToken, w, r}
346 ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
347 apiHandler.ServeHTTP(w, r.WithContext(ctx))
348 return
349 }
350
351 // We are now expecting the following URLs:
352 // .../export
353 // .../msg/<msgid>/{attachments.zip,parsedmessage.js,raw}
354 // .../msg/<msgid>/{,msg}{text,html,htmlexternal}
355 // .../msg/<msgid>/{view,viewtext,download}/<partid>
356
357 if r.URL.Path == "/export" {
358 webops.Export(log, accName, w, r)
359 return
360 }
361
362 if !strings.HasPrefix(r.URL.Path, "/msg/") {
363 http.NotFound(w, r)
364 return
365 }
366
367 t := strings.Split(r.URL.Path[len("/msg/"):], "/")
368 if len(t) < 2 {
369 http.NotFound(w, r)
370 return
371 }
372
373 id, err := strconv.ParseInt(t[0], 10, 64)
374 if err != nil || id == 0 {
375 http.NotFound(w, r)
376 return
377 }
378
379 // Many of the requests need either a message or a parsed part. Make it easy to
380 // fetch/prepare and cleanup. We only do all the work when the request seems legit
381 // (valid HTTP route and method).
382 xprepare := func() (acc *store.Account, moreHeaders []string, m store.Message, msgr *store.MsgReader, p message.Part, cleanup func(), ok bool) {
383 if r.Method != "GET" {
384 http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
385 return
386 }
387
388 defer func() {
389 if ok {
390 return
391 }
392 if msgr != nil {
393 err := msgr.Close()
394 log.Check(err, "closing message reader")
395 msgr = nil
396 }
397 if acc != nil {
398 err := acc.Close()
399 log.Check(err, "closing account")
400 acc = nil
401 }
402 }()
403
404 var err error
405
406 acc, err = store.OpenAccount(log, accName, false)
407 xcheckf(ctx, err, "open account")
408
409 m = store.Message{ID: id}
410 err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
411 if err := tx.Get(&m); err != nil {
412 return err
413 } else if m.Expunged {
414 return fmt.Errorf("message was removed")
415 }
416 s := store.Settings{ID: 1}
417 if err := tx.Get(&s); err != nil {
418 return fmt.Errorf("get settings for more headers: %v", err)
419 }
420 moreHeaders = s.ShowHeaders
421 return nil
422 })
423 if err == bstore.ErrAbsent || err == nil && m.Expunged {
424 http.NotFound(w, r)
425 return
426 }
427 xcheckf(ctx, err, "get message")
428
429 msgr = acc.MessageReader(m)
430
431 p, err = m.LoadPart(msgr)
432 xcheckf(ctx, err, "load parsed message")
433
434 cleanup = func() {
435 err := msgr.Close()
436 log.Check(err, "closing message reader")
437 err = acc.Close()
438 log.Check(err, "closing account")
439 }
440 ok = true
441 return
442 }
443
444 h := w.Header()
445
446 // We set a Content-Security-Policy header that is as strict as possible, depending
447 // on the type of message/part/html/js. We have to be careful because we are
448 // returning data that is coming in from external places. E.g. HTML could contain
449 // javascripts that we don't want to execute, especially not on our domain. We load
450 // resources in an iframe. The CSP policy starts out with default-src 'none' to
451 // disallow loading anything, then start allowing what is safe, such as inlined
452 // datauri images and inline styles. Data can only be loaded when the request is
453 // coming from the same origin (so other sites cannot include resources
454 // (messages/parts)).
455 //
456 // We want to load resources in sandbox-mode, causing the page to be loaded as from
457 // a different origin. If sameOrigin is set, we have a looser CSP policy:
458 // allow-same-origin is set so resources are loaded as coming from this same
459 // origin. This is needed for the msg* endpoints that render a message, where we
460 // load the message body in a separate iframe again (with stricter CSP again),
461 // which we need to access for its inner height. If allowSelfScript is also set
462 // (for "msgtext"), the CSP leaves out the sandbox entirely.
463 //
464 // If allowExternal is set, we allow loading image, media (audio/video), styles and
465 // fronts from external URLs as well as inline URI's. By default we don't allow any
466 // loading of content, except inlined images (we do that ourselves for images
467 // embedded in the email), and we allow inline styles (which are safely constrained
468 // to an iframe).
469 //
470 // If allowSelfScript is set, inline scripts and scripts from our origin are
471 // allowed. Used to display a message including header. The header is rendered with
472 // javascript, the content is rendered in a separate iframe with a CSP that doesn't
473 // have allowSelfScript.
474 headers := func(sameOrigin, allowExternal, allowSelfScript, allowSelfImg bool) {
475 // allow-popups is needed to make opening links in new tabs work.
476 sb := "sandbox allow-popups allow-popups-to-escape-sandbox; "
477 if sameOrigin && allowSelfScript {
478 // Sandbox with both allow-same-origin and allow-script would not provide security,
479 // and would give warning in console about that.
480 sb = ""
481 } else if sameOrigin {
482 sb = "sandbox allow-popups allow-popups-to-escape-sandbox allow-same-origin; "
483 }
484 script := ""
485 if allowSelfScript {
486 script = "; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'"
487 }
488 var csp string
489 if allowExternal {
490 csp = sb + "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
491 } else if allowSelfImg {
492 csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'" + script
493 } else {
494 csp = sb + "frame-ancestors 'self'; default-src 'none'; img-src data:; style-src 'unsafe-inline'" + script
495 }
496 h.Set("Content-Security-Policy", csp)
497 h.Set("X-Frame-Options", "sameorigin") // Duplicate with CSP, but better too much than too little.
498 h.Set("X-Content-Type-Options", "nosniff")
499 h.Set("Referrer-Policy", "no-referrer")
500 }
501
502 switch {
503 case len(t) == 2 && t[1] == "attachments.zip":
504 acc, _, m, msgr, p, cleanup, ok := xprepare()
505 if !ok {
506 return
507 }
508 defer cleanup()
509 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
510 // note: state is cleared by cleanup
511
512 mi, err := messageItem(log, m, &state, nil)
513 xcheckf(ctx, err, "parsing message")
514
515 headers(false, false, false, false)
516 h.Set("Content-Type", "application/zip")
517 h.Set("Cache-Control", "no-store, max-age=0")
518 var subjectSlug string
519 if p.Envelope != nil {
520 s := p.Envelope.Subject
521 s = strings.ToLower(s)
522 s = regexp.MustCompile("[^a-z0-9_.-]").ReplaceAllString(s, "-")
523 s = regexp.MustCompile("--*").ReplaceAllString(s, "-")
524 s = strings.TrimLeft(s, "-")
525 s = strings.TrimRight(s, "-")
526 if s != "" {
527 s = "-" + s
528 }
529 subjectSlug = s
530 }
531 filename := fmt.Sprintf("email-%d-attachments-%s%s.zip", m.ID, m.Received.Format("20060102-150405"), subjectSlug)
532 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
533 h.Set("Content-Disposition", cd)
534
535 zw := zip.NewWriter(w)
536 names := map[string]bool{}
537 for _, a := range mi.Attachments {
538 ap := a.Part
539 _, name, err := ap.DispositionFilename()
540 if err != nil && errors.Is(err, message.ErrParamEncoding) {
541 log.Debugx("parsing disposition header for filename", err)
542 } else {
543 xcheckf(ctx, err, "reading disposition header")
544 }
545 if name != "" {
546 name = filepath.Base(name)
547 }
548 mt := strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
549 if name == "" || names[name] {
550 ext := filepath.Ext(name)
551 if ext == "" {
552 // Handle just a few basic types.
553 extensions := map[string]string{
554 "text/plain": ".txt",
555 "text/html": ".html",
556 "image/jpeg": ".jpg",
557 "image/png": ".png",
558 "image/gif": ".gif",
559 "application/zip": ".zip",
560 }
561 ext = extensions[mt]
562 if ext == "" {
563 ext = ".bin"
564 }
565 }
566 var stem string
567 if name != "" && strings.HasSuffix(name, ext) {
568 stem = strings.TrimSuffix(name, ext)
569 } else {
570 stem = "attachment"
571 for _, index := range a.Path {
572 stem += fmt.Sprintf("-%d", index)
573 }
574 }
575 name = stem + ext
576 seq := 0
577 for names[name] {
578 seq++
579 name = stem + fmt.Sprintf("-%d", seq) + ext
580 }
581 }
582 names[name] = true
583
584 fh := zip.FileHeader{
585 Name: name,
586 Modified: m.Received,
587 }
588 nodeflate := map[string]bool{
589 "application/x-bzip2": true,
590 "application/zip": true,
591 "application/x-zip-compressed": true,
592 "application/gzip": true,
593 "application/x-gzip": true,
594 "application/vnd.rar": true,
595 "application/x-rar-compressed": true,
596 "application/x-7z-compressed": true,
597 }
598 // Sniff content-type as well for compressed data.
599 buf := make([]byte, 512)
600 n, _ := io.ReadFull(ap.Reader(), buf)
601 var sniffmt string
602 if n > 0 {
603 sniffmt = strings.ToLower(http.DetectContentType(buf[:n]))
604 }
605 deflate := ap.MediaType != "VIDEO" && ap.MediaType != "AUDIO" && (ap.MediaType != "IMAGE" || ap.MediaSubType == "BMP") && !nodeflate[mt] && !nodeflate[sniffmt]
606 if deflate {
607 fh.Method = zip.Deflate
608 }
609 // We cannot return errors anymore: we have already sent an application/zip header.
610 if zf, err := zw.CreateHeader(&fh); err != nil {
611 log.Check(err, "adding to zip file")
612 return
613 } else if _, err := io.Copy(zf, ap.Reader()); err != nil {
614 log.Check(err, "writing to zip file")
615 return
616 }
617 }
618 err = zw.Close()
619 log.Check(err, "final write to zip file")
620
621 // Raw display or download of a message, as text/plain.
622 case len(t) == 2 && (t[1] == "raw" || t[1] == "rawdl"):
623 _, _, m, msgr, p, cleanup, ok := xprepare()
624 if !ok {
625 return
626 }
627 defer cleanup()
628
629 headers(false, false, false, false)
630
631 // We intentially use text/plain. We certainly don't want to return a format that
632 // browsers or users would think of executing. We do set the charset if available
633 // on the outer part. If present, we assume it may be relevant for other parts. If
634 // not, there is not much we could do better...
635 ct := "text/plain"
636 params := map[string]string{}
637
638 if t[1] == "rawdl" {
639 ct = "message/rfc822"
640 if smtputf8, err := p.NeedsSMTPUTF8(); err != nil {
641 log.Errorx("checking for smtputf8 for content-type", err, slog.Int64("msgid", m.ID))
642 http.Error(w, "500 - server error - checking message for content-type: "+err.Error(), http.StatusInternalServerError)
643 return
644 } else if smtputf8 {
645 ct = "message/global"
646 params["charset"] = "utf-8"
647 }
648 } else if charset := p.ContentTypeParams["charset"]; charset != "" {
649 params["charset"] = charset
650 }
651 h.Set("Content-Type", mime.FormatMediaType(ct, params))
652 if t[1] == "rawdl" {
653 filename := fmt.Sprintf("email-%d-%s.eml", m.ID, m.Received.Format("20060102-150405"))
654 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
655 h.Set("Content-Disposition", cd)
656 }
657 h.Set("Cache-Control", "no-store, max-age=0")
658
659 _, err := io.Copy(w, &moxio.AtReader{R: msgr})
660 log.Check(err, "writing raw")
661
662 case len(t) == 2 && (t[1] == "msgtext" || t[1] == "msghtml" || t[1] == "msghtmlexternal"):
663 // msg.html has a javascript tag with message data, and javascript to render the
664 // message header like the regular webmail.html and to load the message body in a
665 // separate iframe with a separate request with stronger CSP.
666 acc, _, m, msgr, p, cleanup, ok := xprepare()
667 if !ok {
668 return
669 }
670 defer cleanup()
671
672 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
673 // note: state is cleared by cleanup
674
675 pm, err := parsedMessage(log, &m, &state, true, true, true)
676 xcheckf(ctx, err, "getting parsed message")
677 if t[1] == "msgtext" && len(pm.Texts) == 0 || t[1] != "msgtext" && !pm.HasHTML {
678 http.Error(w, "400 - bad request - no such part", http.StatusBadRequest)
679 return
680 }
681
682 sameorigin := true
683 loadExternal := t[1] == "msghtmlexternal"
684 allowSelfScript := true
685 headers(sameorigin, loadExternal, allowSelfScript, false)
686 h.Set("Content-Type", "text/html; charset=utf-8")
687 h.Set("Cache-Control", "no-store, max-age=0")
688
689 path := filepath.FromSlash("webmail/msg.html")
690 fallback := webmailmsgHTML
691 serveContentFallback(log, w, r, path, fallback, true)
692
693 case len(t) == 2 && t[1] == "parsedmessage.js":
694 // Used by msg.html, for the msg* endpoints, for the data needed to show all data
695 // except the message body.
696 // This is js with data inside instead so we can load it synchronously, which we do
697 // to get a "loaded" event after the page was actually loaded.
698
699 acc, moreHeaders, m, msgr, p, cleanup, ok := xprepare()
700 if !ok {
701 return
702 }
703 defer cleanup()
704 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
705 // note: state is cleared by cleanup
706
707 pm, err := parsedMessage(log, &m, &state, true, true, true)
708 xcheckf(ctx, err, "parsing parsedmessage")
709 pmjson, err := json.Marshal(pm)
710 xcheckf(ctx, err, "marshal parsedmessage")
711
712 m.MsgPrefix = nil
713 m.ParsedBuf = nil
714 hl := messageItemMoreHeaders(moreHeaders, pm)
715 mi := MessageItem{m, pm.envelope, pm.attachments, pm.isSigned, pm.isEncrypted, false, hl}
716 mijson, err := json.Marshal(mi)
717 xcheckf(ctx, err, "marshal messageitem")
718
719 headers(false, false, false, false)
720 h.Set("Content-Type", "application/javascript; charset=utf-8")
721 h.Set("Cache-Control", "no-store, max-age=0")
722
723 _, err = fmt.Fprintf(w, "window.messageItem = %s;\nwindow.parsedMessage = %s;\n", mijson, pmjson)
724 log.Check(err, "writing parsedmessage.js")
725
726 case len(t) == 2 && t[1] == "text":
727 // Returns text.html whichs loads the message data with a javascript tag and
728 // renders just the text content with the same code as webmail.html. Used by the
729 // iframe in the msgtext endpoint. Not used by the regular webmail viewer, it
730 // renders the text itself, with the same shared js code.
731 acc, _, m, msgr, p, cleanup, ok := xprepare()
732 if !ok {
733 return
734 }
735 defer cleanup()
736
737 state := msgState{acc: acc, m: m, msgr: msgr, part: &p}
738 // note: state is cleared by cleanup
739
740 pm, err := parsedMessage(log, &m, &state, true, true, true)
741 xcheckf(ctx, err, "parsing parsedmessage")
742
743 if len(pm.Texts) == 0 {
744 http.Error(w, "400 - bad request - no text part in message", http.StatusBadRequest)
745 return
746 }
747
748 // Needed for inner document height for outer iframe height in separate message view.
749 sameorigin := true
750 allowSelfScript := true
751 allowSelfImg := true
752 headers(sameorigin, false, allowSelfScript, allowSelfImg)
753 h.Set("Content-Type", "text/html; charset=utf-8")
754 h.Set("Cache-Control", "no-store, max-age=0")
755
756 // We typically return the embedded file, but during development it's handy to load
757 // from disk.
758 path := filepath.FromSlash("webmail/text.html")
759 fallback := webmailtextHTML
760 serveContentFallback(log, w, r, path, fallback, true)
761
762 case len(t) == 2 && (t[1] == "html" || t[1] == "htmlexternal"):
763 // Returns the first HTML part, with "cid:" URIs replaced with an inlined datauri
764 // if the referenced Content-ID attachment can be found.
765 _, _, _, _, p, cleanup, ok := xprepare()
766 if !ok {
767 return
768 }
769 defer cleanup()
770
771 setHeaders := func() {
772 // Needed for inner document height for outer iframe height in separate message
773 // view. We only need that when displaying as a separate message on the msghtml*
774 // endpoints. When displaying in the regular webmail, we don't need to know the
775 // inner height so we load it as different origin, which should be safer.
776 sameorigin := r.URL.Query().Get("sameorigin") == "true"
777 allowExternal := strings.HasSuffix(t[1], "external")
778 headers(sameorigin, allowExternal, false, false)
779
780 h.Set("Content-Type", "text/html; charset=utf-8")
781 h.Set("Cache-Control", "no-store, max-age=0")
782 }
783
784 // todo: skip certain html parts? e.g. with content-disposition: attachment?
785 var done bool
786 var usePart func(p *message.Part, parents []*message.Part)
787 usePart = func(p *message.Part, parents []*message.Part) {
788 if done {
789 return
790 }
791 mt := p.MediaType + "/" + p.MediaSubType
792 switch mt {
793 case "TEXT/HTML":
794 done = true
795 err := inlineSanitizeHTML(log, setHeaders, w, p, parents)
796 if err != nil {
797 http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
798 }
799 return
800 }
801 parents = append(parents, p)
802 for _, sp := range p.Parts {
803 usePart(&sp, parents)
804 }
805 }
806 usePart(&p, nil)
807
808 if !done {
809 http.Error(w, "400 - bad request - no html part in message", http.StatusBadRequest)
810 }
811
812 case len(t) == 3 && (t[1] == "view" || t[1] == "viewtext" || t[1] == "download"):
813 // View any part, as referenced in the last element path. "0" is the whole message,
814 // 0.0 is the first subpart, etc. "view" returns it with the content-type from the
815 // message (could be dangerous, but we set strict CSP headers), "viewtext" returns
816 // data with a text/plain content-type so the browser will attempt to display it,
817 // and "download" adds a content-disposition header causing the browser the
818 // download the file.
819 _, _, _, _, p, cleanup, ok := xprepare()
820 if !ok {
821 return
822 }
823 defer cleanup()
824
825 paths := strings.Split(t[2], ".")
826 if len(paths) == 0 || paths[0] != "0" {
827 http.NotFound(w, r)
828 return
829 }
830 ap := p
831 for _, e := range paths[1:] {
832 index, err := strconv.ParseInt(e, 10, 32)
833 if err != nil || index < 0 || int(index) >= len(ap.Parts) {
834 http.NotFound(w, r)
835 return
836 }
837 ap = ap.Parts[int(index)]
838 }
839
840 headers(false, false, false, false)
841 var ct string
842 if t[1] == "viewtext" {
843 ct = "text/plain"
844 } else {
845 ct = strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
846 }
847 h.Set("Content-Type", ct)
848 h.Set("Cache-Control", "no-store, max-age=0")
849 if t[1] == "download" {
850 _, name, err := ap.DispositionFilename()
851 if err != nil && errors.Is(err, message.ErrParamEncoding) {
852 log.Debugx("parsing disposition/filename", err)
853 } else {
854 xcheckf(ctx, err, "reading disposition/filename")
855 }
856 if name == "" {
857 name = "attachment.bin"
858 }
859 cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
860 h.Set("Content-Disposition", cd)
861 }
862
863 _, err := io.Copy(w, ap.Reader())
864 log.Check(err, "copying attachment")
865 default:
866 http.NotFound(w, r)
867 }
868}
869
870// inlineSanitizeHTML writes the part as HTML, with "cid:" URIs for html "src"
871// attributes inlined and with potentially dangerous tags removed (javascript). The
872// sanitizing is just a first layer of defense, CSP headers block execution of
873// scripts. If the HTML becomes too large, an error is returned. Before writing
874// HTML, setHeaders is called to write the required headers for content-type and
875// CSP. On error, setHeader is not called, no output is written and the caller
876// should write an error response.
877func inlineSanitizeHTML(log mlog.Log, setHeaders func(), w io.Writer, p *message.Part, parents []*message.Part) error {
878 // Prepare cids if there is a chance we will use them.
879 cids := map[string]*message.Part{}
880 for _, parent := range parents {
881 if parent.MediaType+"/"+parent.MediaSubType == "MULTIPART/RELATED" && p.DecodedSize < 2*1024*1024 {
882 for i, rp := range parent.Parts {
883 if rp.ContentID != "" {
884 cids[strings.ToLower(rp.ContentID)] = &parent.Parts[i]
885 }
886 }
887 }
888 }
889
890 node, err := html.Parse(p.ReaderUTF8OrBinary())
891 if err != nil {
892 return fmt.Errorf("parsing html: %v", err)
893 }
894
895 // We track size, if it becomes too much, we abort and still copy as regular html.
896 var totalSize int64
897 if err := inlineNode(node, cids, &totalSize); err != nil {
898 return fmt.Errorf("inline cid uris in html nodes: %w", err)
899 }
900 sanitizeNode(node)
901 setHeaders()
902 err = html.Render(w, node)
903 log.Check(err, "writing html")
904 return nil
905}
906
907// We inline cid: URIs into data: URIs. If a cid is missing in the
908// multipart/related, we ignore the error and continue with other HTML nodes. It
909// will probably just result in a "broken image". We limit the max size we
910// generate. We only replace "src" attributes that start with "cid:". A cid URI
911// could theoretically occur in many more places, like link href, and css url().
912// That's probably not common though. Let's wait for someone to need it.
913func inlineNode(node *html.Node, cids map[string]*message.Part, totalSize *int64) error {
914 for i, a := range node.Attr {
915 if a.Key != "src" || !caselessPrefix(a.Val, "cid:") || a.Namespace != "" {
916 continue
917 }
918 cid := a.Val[4:]
919 ap := cids["<"+strings.ToLower(cid)+">"]
920 if ap == nil {
921 // Missing cid, can happen with email, no need to stop returning data.
922 continue
923 }
924 *totalSize += ap.DecodedSize
925 if *totalSize >= 10*1024*1024 {
926 return fmt.Errorf("html too large")
927 }
928 var sb strings.Builder
929 if _, err := fmt.Fprintf(&sb, "data:%s;base64,", strings.ToLower(ap.MediaType+"/"+ap.MediaSubType)); err != nil {
930 return fmt.Errorf("writing datauri: %v", err)
931 }
932 w := base64.NewEncoder(base64.StdEncoding, &sb)
933 if _, err := io.Copy(w, ap.Reader()); err != nil {
934 return fmt.Errorf("writing base64 datauri: %v", err)
935 }
936 node.Attr[i].Val = sb.String()
937 }
938 for node = node.FirstChild; node != nil; node = node.NextSibling {
939 if err := inlineNode(node, cids, totalSize); err != nil {
940 return err
941 }
942 }
943 return nil
944}
945
946func caselessPrefix(k, pre string) bool {
947 return len(k) >= len(pre) && strings.EqualFold(k[:len(pre)], pre)
948}
949
950var targetable = map[string]bool{
951 "a": true,
952 "area": true,
953 "form": true,
954 "base": true,
955}
956
957// sanitizeNode removes script elements, on* attributes, javascript: href
958// attributes, adds target="_blank" to all links and to a base tag.
959func sanitizeNode(node *html.Node) {
960 i := 0
961 var haveTarget, haveRel bool
962 for i < len(node.Attr) {
963 a := node.Attr[i]
964 // Remove dangerous attributes.
965 if strings.HasPrefix(a.Key, "on") || a.Key == "href" && caselessPrefix(a.Val, "javascript:") || a.Key == "src" && caselessPrefix(a.Val, "data:text/html") {
966 copy(node.Attr[i:], node.Attr[i+1:])
967 node.Attr = node.Attr[:len(node.Attr)-1]
968 continue
969 }
970 if a.Key == "target" {
971 node.Attr[i].Val = "_blank"
972 haveTarget = true
973 }
974 if a.Key == "rel" && targetable[node.Data] {
975 node.Attr[i].Val = "noopener noreferrer"
976 haveRel = true
977 }
978 i++
979 }
980 // Ensure target attribute is set for elements that can have it.
981 if !haveTarget && node.Type == html.ElementNode && targetable[node.Data] {
982 node.Attr = append(node.Attr, html.Attribute{Key: "target", Val: "_blank"})
983 haveTarget = true
984 }
985 if haveTarget && !haveRel {
986 node.Attr = append(node.Attr, html.Attribute{Key: "rel", Val: "noopener noreferrer"})
987 }
988
989 parent := node
990 node = node.FirstChild
991 var haveBase bool
992 for node != nil {
993 // Set next now, we may remove cur, which clears its NextSibling.
994 cur := node
995 node = node.NextSibling
996
997 // Remove script elements.
998 if cur.Type == html.ElementNode && cur.Data == "script" {
999 parent.RemoveChild(cur)
1000 continue
1001 }
1002 sanitizeNode(cur)
1003 }
1004 if parent.Type == html.ElementNode && parent.Data == "head" && !haveBase {
1005 n := html.Node{Type: html.ElementNode, Data: "base", Attr: []html.Attribute{{Key: "target", Val: "_blank"}, {Key: "rel", Val: "noopener noreferrer"}}}
1006 parent.AppendChild(&n)
1007 }
1008}
1009