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