110 lines
4.8 KiB
Go
110 lines
4.8 KiB
Go
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())
|
|
}
|
|
}
|