From 58b337770d5dac9215916144d0a1f1de44f23ae2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 22:26:47 +0200 Subject: [PATCH] Show a mail as escaped text and serve its attachments as downloads (issue 1266) --- .../mesh-mail-web/cmd/mesh-mail-web/mail.go | 128 ++++++++++++++++++ .../cmd/mesh-mail-web/mail_test.go | 109 +++++++++++++++ .../mesh-mail-web/cmd/mesh-mail-web/server.go | 2 + 3 files changed, 239 insertions(+) create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/mail.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/mail_test.go diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/mail.go b/modules/mesh-mail-web/cmd/mesh-mail-web/mail.go new file mode 100644 index 00000000..62844e59 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/mail.go @@ -0,0 +1,128 @@ +package main + +// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266): mail_read already turns a mail's +// HTML part into text (mesh-mail's own mime handling); this page renders that text through html/template +// alone, so whatever it holds — a script element, a remote image — is shown as escaped text, never parsed as +// markup, and nothing on the page names another origin. An attachment is answered as a download: its bytes, +// Content-Disposition: attachment with its name escaped, Content-Type application/octet-stream, and +// X-Content-Type-Options: nosniff, so a browser never renders it inline. A mail mail-store refuses (an +// account not granted) is answered 404, naming nothing of it (issue 1266/AC4) — the same shape as asking for a +// mail that was never there. + +import ( + "context" + "encoding/base64" + "encoding/json" + "html/template" + "mime" + "net/http" + "strconv" + "time" +) + +// readAnswer is mail-store.read's own answer (mesh-mail tools.go readTool / imap.go metadata), the fields the +// mail page reads. Attachment carries only the fields a plain read needs; a read with `attachment` given +// answers a different shape (attachmentAnswer, below), never both. +type readAnswer struct { + Account string `json:"account"` + Folder string `json:"folder"` + UID int `json:"uid"` + Date string `json:"date"` + From []personResult `json:"from"` + To []personResult `json:"to"` + Cc []personResult `json:"cc"` + Subject string `json:"subject"` + Text string `json:"text"` + Attachments []attachmentRefShown `json:"attachments"` +} + +// attachmentRefShown is one attachment as mail-store.read names it (mesh-mail store.go AttachmentRef): only +// the fields the mail page's link and the download both need. +type attachmentRefShown struct { + Key string `json:"key"` + Name string `json:"name"` + Size int64 `json:"size"` + Type string `json:"type"` +} + +// attachmentAnswer is mail-store.read's own answer when asked with `attachment` (mesh-mail tools.go readTool). +type attachmentAnswer struct { + Attachment attachmentRefShown `json:"attachment"` + Base64 string `json:"base64"` +} + +var mailTemplate = template.Must(template.New("mail").Parse(` +{{.Subject}} +

{{.Subject}}

+

{{.Date}} — from {{range .From}}{{.Name}} <{{.Address}}> {{end}}to {{range .To}}{{.Name}} <{{.Address}}> {{end}}

+
{{.Text}}
+{{if .Attachments}}

Attachments

{{end}} +`)) + +// mailPage is issue 1266/AC1, AC2, AC4: the mail's headers and its text, through html/template alone (its +// auto-escaping) — never the mail's own HTML, and nothing on the page names another origin. A read mail-store +// refuses is 404, naming nothing of the mail. +func (b *Bundle) mailPage(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + account, folder, uidStr := q.Get("account"), q.Get("folder"), q.Get("uid") + uid, err := strconv.Atoi(uidStr) + if account == "" || folder == "" || err != nil || uid <= 0 { + http.NotFound(w, r) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + raw, err := b.readAsk(ctx, map[string]any{"account": account, "folder": folder, "uid": float64(uid)}) + if err != nil { + http.NotFound(w, r) + return + } + var answer readAnswer + if err := json.Unmarshal(raw, &answer); err != nil { + http.NotFound(w, r) + return + } + renderPage(w, mailTemplate, answer) +} + +// attachmentPage is issue 1266/AC3, AC4: an attachment's bytes as a download — Content-Disposition: attachment +// (its name escaped by mime.FormatMediaType, novox/hq ADR 0440 §5), Content-Type application/octet-stream, and +// X-Content-Type-Options: nosniff, so a browser never renders it inline. A read mail-store refuses is 404. +func (b *Bundle) attachmentPage(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + account, folder, uidStr, key := q.Get("account"), q.Get("folder"), q.Get("uid"), q.Get("key") + uid, err := strconv.Atoi(uidStr) + if account == "" || folder == "" || key == "" || err != nil || uid <= 0 { + http.NotFound(w, r) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + raw, err := b.readAsk(ctx, map[string]any{ + "account": account, "folder": folder, "uid": float64(uid), "attachment": key, + }) + if err != nil { + http.NotFound(w, r) + return + } + var answer attachmentAnswer + if err := json.Unmarshal(raw, &answer); err != nil || answer.Base64 == "" { + http.NotFound(w, r) + return + } + data, err := base64.StdEncoding.DecodeString(answer.Base64) + if err != nil { + http.NotFound(w, r) + return + } + name := answer.Attachment.Name + if name == "" { + name = "attachment" + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name})) + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = w.Write(data) +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/mail_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/mail_test.go new file mode 100644 index 00000000..b6a5f6c9 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/mail_test.go @@ -0,0 +1,109 @@ +package main + +// The mail page and attachment download (novox/hq ADR 0440 §5, issue 1266). + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// 1266/AC1, AC2: a mail whose body holds and shows that +// text escaped, carries no script or img element, and nothing on the page names another origin. +func TestMailPageEscapesScriptAndRemoteImageInTheBody(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "account": "mesh", "folder": "INBOX", "uid": 7, "subject": "hi", "date": "2026-01-01T00:00:00Z", + "from": []map[string]any{{"address": "bob@example.org"}}, "to": []map[string]any{{"address": "me@example.org"}}, + "text": ` and `, + }) + b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/mail?account=mesh&folder=INBOX&uid=7", nil) + b.mailPage(w, r) + + body := w.Body.String() + if strings.Contains(body, "