1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
package main

// What the HTML views say about attachments.
//
// The text rendering listed attachments from the day the feature landed; the
// HTML views did not, for two releases. A message with an .ics attached
// rendered as its three-line cover note, and nothing in the browser said
// anything had come with it — the failure attachments.go was written to fix,
// left open in the interface most reading actually happens in.
//
// The cases below are the three that were wrong or could go wrong again: the
// list is shown at all, an unexamined message says so rather than nothing, and
// none of it renders inside the frame holding the sender's HTML.

import (
	"strings"
	"testing"

	"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"
)

func testAttachment() attachment {
	return attachment{
		Idx: 1, PartPath: "2", MimeType: "text/calendar",
		Filename: "nlnet_rfp.ics", Size: 4788, Disposition: "attachment",
	}
}

// renderWithAttachments renders both listings and the message page with one
// message carrying the given box, so a rule can be asserted against all three.
func renderWithAttachments(t *testing.T, box attachmentBox) map[string]string {
	t.Helper()
	msg := testMsgRow()
	msg.ID = box.MsgID
	boxes := map[int64]attachmentBox{msg.ID: box}
	return map[string]string{
		"index": renderTemplate(t, "index", indexPageData{
			Messages: []msgRow{msg}, Paging: mailtext.Paging{Total: 1},
			AttachmentsByMsg: boxes,
		}),
		"contact": renderTemplate(t, "contactdetail", contactDetailData{
			Contact: "klara@example.org", Name: msg.From,
			AddrEscaped: "klara%40example.org", Messages: []msgRow{msg},
			AttachmentsByMsg: boxes,
		}),
		"msgview": renderTemplate(t, "msgview", msgViewData{
			Msg: msg, Mailbox: "INBOX", Att: box,
		}),
	}
}

// TestViewsListAttachments is the regression: every HTML view that shows a
// message names what is attached to it and hands out the URL that fetches it.
func TestViewsListAttachments(t *testing.T) {
	accountName = "test"
	myAddress = "me@example.org"
	myAddresses = map[string]bool{myAddress: true}

	box := attachmentBox{MsgID: 11719, Attachments: []attachment{testAttachment()}, Known: true}
	for name, out := range renderWithAttachments(t, box) {
		if !strings.Contains(out, "nlnet_rfp.ics") {
			t.Errorf("%s: should name the attachment", name)
		}
		if !strings.Contains(out, "/msg/11719/attachment/1") {
			t.Errorf("%s: should link to the attachment", name)
		}
		if !strings.Contains(out, "text/calendar") {
			t.Errorf("%s: should give the attachment's type", name)
		}
		// The size is what separates a signature from an invoice.
		if !strings.Contains(out, "4.7K") {
			t.Errorf("%s: should give the attachment's size", name)
		}
	}
}

// TestUnexaminedMessageSaysSo is the distinction bodystructure_scanned_at
// exists for. mailweb stores headers and fetches one part on demand, so for a
// message nobody has opened it does not know whether anything is attached.
// Rendering nothing there would be an assertion of absence it cannot make — and
// would be indistinguishable from the bug this whole file is about.
func TestUnexaminedMessageSaysSo(t *testing.T) {
	for name, out := range renderWithAttachments(t,
		attachmentBox{MsgID: 11719, Known: false}) {
		if !strings.Contains(out, "not yet known") {
			t.Errorf("%s: an unexamined message must say its attachments are unknown", name)
		}
	}
	// A message that *has* been examined and carries nothing says nothing:
	// silence is correct only once the question has been asked.
	for name, out := range renderWithAttachments(t,
		attachmentBox{MsgID: 11719, Known: true}) {
		if strings.Contains(out, "not yet known") {
			t.Errorf("%s: an examined message with nothing attached should stay quiet", name)
		}
	}
}

// TestAttachmentsRenderOutsideTheFrame guards the placement.
//
// A message body is the sender's own HTML. An attachment list rendered inside
// that document would be markup the sender can also write, and every row of it
// is a link the reader is invited to click — the single most worthwhile thing
// on the page to forge. Outside the frame it lives in mailweb's document, where
// the sender has no markup at all.
func TestAttachmentsRenderOutsideTheFrame(t *testing.T) {
	body := renderTemplate(t, "msgbody", map[string]any{
		"Body": "<p>see attached</p>", "MimeType": "text/html",
	})
	if strings.Contains(body, "atts") || strings.Contains(body, "/attachment/") {
		t.Error("the framed body must not render an attachment list")
	}
	if !strings.Contains(body, "script-src 'none'") {
		t.Error("a message body must carry the script-src 'none' policy")
	}

	// And the page that frames it keeps the sandbox that makes the frame safe.
	out := renderWithAttachments(t,
		attachmentBox{MsgID: 11719, Attachments: []attachment{testAttachment()}, Known: true})["msgview"]
	if !strings.Contains(out, "sandbox=") {
		t.Error("the message page must sandbox the body it frames")
	}
	if strings.Contains(out, "allow-scripts") {
		t.Error("a message frame must never be granted allow-scripts")
	}
	// allow-same-origin is granted so the outer page can size the frame; that
	// is safe only while allow-scripts is absent, which the check above pins.
	if !strings.Contains(out, "allow-same-origin") {
		t.Error("the message page needs allow-same-origin to size its frame")
	}
}

// TestAttachmentFilenamesAreEscaped: a filename is written by the sender and
// arrives here unaltered. html/template escapes it, and this pins that no view
// interpolates one somewhere escaping does not apply.
func TestAttachmentFilenamesAreEscaped(t *testing.T) {
	evil := testAttachment()
	evil.Filename = `<img src=x onerror=alert(1)>.pdf`
	for name, out := range renderWithAttachments(t,
		attachmentBox{MsgID: 11719, Attachments: []attachment{evil}, Known: true}) {
		if strings.Contains(out, "<img src=x") {
			t.Errorf("%s: a sender's filename must not reach the page as markup", name)
		}
		if !strings.Contains(out, "onerror=alert(1)") {
			t.Errorf("%s: the filename should still be shown, escaped", name)
		}
	}
}

// TestListingsLinkToTheMessagePage checks the subject leads somewhere with a
// header block. It used to link to /msg/{id}, the fragment the listing was
// already framing, so following it landed on a body with no headers, no size
// and no attachment list.
func TestListingsLinkToTheMessagePage(t *testing.T) {
	msg := testMsgRow()
	for name, out := range map[string]string{
		"index": renderTemplate(t, "index", indexPageData{
			Messages: []msgRow{msg}, Paging: mailtext.Paging{Total: 1},
		}),
		"contact": renderTemplate(t, "contactdetail", contactDetailData{
			Contact: "klara@example.org", Name: msg.From,
			AddrEscaped: "klara%40example.org", Messages: []msgRow{msg},
		}),
	} {
		if !strings.Contains(out, "/msg/28755/view") {
			t.Errorf("%s: the subject should link to the message's own page", name)
		}
	}
}