Show a mail as escaped text and serve its attachments as downloads (issue 1266)

This commit is contained in:
jochen
2026-10-19 22:26:47 +02:00
parent ff1886ac62
commit 58b337770d
3 changed files with 239 additions and 0 deletions
@@ -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(`<!DOCTYPE html>
<html><head><title>{{.Subject}}</title></head><body>
<h1>{{.Subject}}</h1>
<p>{{.Date}} — from {{range .From}}{{.Name}} &lt;{{.Address}}&gt; {{end}}to {{range .To}}{{.Name}} &lt;{{.Address}}&gt; {{end}}</p>
<pre>{{.Text}}</pre>
{{if .Attachments}}<h2>Attachments</h2><ul>
{{range .Attachments}}<li><a href="/attachment?account={{$.Account}}&amp;folder={{$.Folder}}&amp;uid={{$.UID}}&amp;key={{.Key}}">{{.Name}}</a> ({{.Size}} bytes)</li>{{end}}
</ul>{{end}}
</body></html>`))
// 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)
}
@@ -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 <script>alert(1)</script> and <img src="http://remote/x"> 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": `<script>alert(1)</script> and <img src="http://remote.example/x">`,
})
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, "<script>") || strings.Contains(body, "<img") {
t.Fatalf("the page carries a script or img element:\n%s", body)
}
if !strings.Contains(body, "&lt;script&gt;") || !strings.Contains(body, "&lt;img") {
t.Fatalf("the body is not shown escaped as text:\n%s", body)
}
if strings.Contains(body, `src="http`) || strings.Contains(body, `href="http`) {
t.Fatalf("the page names another origin in a src or href:\n%s", body)
}
}
// Also 1266/AC2: a mail's attachment link names only this server's own path, never another origin.
func TestMailPageAttachmentLinksNameOnlyThisServersOwnPath(t *testing.T) {
raw, _ := json.Marshal(map[string]any{
"account": "mesh", "folder": "INBOX", "uid": 7, "subject": "hi", "date": "2026-01-01T00:00:00Z",
"attachments": []map[string]any{{"key": "abc123", "name": "invoice.pdf", "size": 10, "type": "application/pdf"}},
})
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, `href="/attachment?account=mesh&amp;folder=INBOX&amp;uid=7&amp;key=abc123"`) {
t.Fatalf("no same-origin attachment link:\n%s", body)
}
}
// 1266/AC3: an attachment asked carries its bytes with Content-Disposition attachment and its name escaped,
// Content-Type application/octet-stream, and X-Content-Type-Options nosniff.
func TestAttachmentDownloadCarriesTheRightHeadersAndBytes(t *testing.T) {
data := []byte("the attachment's own bytes")
raw, _ := json.Marshal(map[string]any{
"attachment": map[string]any{"key": "abc123", "name": `weird, name.pdf`, "size": len(data), "type": "application/pdf"},
"base64": base64.StdEncoding.EncodeToString(data),
})
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) { return raw, nil }}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/attachment?account=mesh&folder=INBOX&uid=7&key=abc123", nil)
b.attachmentPage(w, r)
if w.Header().Get("Content-Type") != "application/octet-stream" {
t.Fatalf("Content-Type: %q", w.Header().Get("Content-Type"))
}
if w.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("X-Content-Type-Options: %q", w.Header().Get("X-Content-Type-Options"))
}
cd := w.Header().Get("Content-Disposition")
if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, "weird, name.pdf") {
t.Fatalf("Content-Disposition: %q", cd)
}
if w.Body.String() != string(data) {
t.Fatalf("body: %q, want %q", w.Body.String(), data)
}
}
// 1266/AC4: a mail mail-store refuses (an account not granted) is answered 404, naming nothing of the mail.
func TestMailPageAndAttachmentAre404WhenMailStoreRefuses(t *testing.T) {
b := &Bundle{Read: func(context.Context, map[string]any) (json.RawMessage, error) {
return nil, errors.New("refused: mesh-web is not granted the mail account secret-project")
}}
w1 := httptest.NewRecorder()
b.mailPage(w1, httptest.NewRequest(http.MethodGet, "/mail?account=secret-project&folder=INBOX&uid=1", nil))
if w1.Code != http.StatusNotFound {
t.Fatalf("mail page status %d, want 404", w1.Code)
}
if strings.Contains(w1.Body.String(), "secret-project") {
t.Fatalf("the 404 names the account: %s", w1.Body.String())
}
w2 := httptest.NewRecorder()
b.attachmentPage(w2, httptest.NewRequest(http.MethodGet, "/attachment?account=secret-project&folder=INBOX&uid=1&key=k", nil))
if w2.Code != http.StatusNotFound {
t.Fatalf("attachment status %d, want 404", w2.Code)
}
if strings.Contains(w2.Body.String(), "secret-project") {
t.Fatalf("the 404 names the account: %s", w2.Body.String())
}
}
@@ -55,6 +55,8 @@ func NewServer(host string, auth *Auth, b *Bundle) *Server {
s.Mux.HandleFunc("/", protect(b.accountsPage))
s.Mux.HandleFunc("/folder", protect(b.folderPage))
s.Mux.HandleFunc("/search", protect(b.searchPage))
s.Mux.HandleFunc("/mail", protect(b.mailPage))
s.Mux.HandleFunc("/attachment", protect(b.attachmentPage))
} else {
s.Mux.HandleFunc("/", protect(s.rootPage))
}