Show a mail as escaped text and serve its attachments as downloads (issue 1266)
This commit is contained in:
@@ -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}} <{{.Address}}> {{end}}to {{range .To}}{{.Name}} <{{.Address}}> {{end}}</p>
|
||||
<pre>{{.Text}}</pre>
|
||||
{{if .Attachments}}<h2>Attachments</h2><ul>
|
||||
{{range .Attachments}}<li><a href="/attachment?account={{$.Account}}&folder={{$.Folder}}&uid={{$.UID}}&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, "<script>") || !strings.Contains(body, "<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&folder=INBOX&uid=7&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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user