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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
package main

// Composing a mail that answers nothing, addressing it, and attaching files to
// it.
//
// Three properties are worth a test rather than an inspection, because each is
// a rule that would be invisible if it broke:
//
//   - A hand-entered address lands in its own set and cannot reach a derived
//     one. The derived sets are what a person read before pressing send, and
//     the envelope is built from those same rows.
//   - What an attachment is called on the wire comes from its bytes, not from
//     what the upload said. A route that trusted the request would be
//     indistinguishable from this one until somebody uploaded a mislabelled
//     file.
//   - The MIME structure is derived from the contents: mixed appears when and
//     only when something is enclosed, and attaching a file does not turn a
//     plain-text body into HTML.

import (
	"bytes"
	"fmt"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
)

// postForm performs a form post against the routing table.
func postForm(t *testing.T, mux *http.ServeMux, path string, form url.Values) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	return rec
}

// draftTokenOf pulls the token out of the redirect a composing route answers.
func draftTokenOf(t *testing.T, rec *httptest.ResponseRecorder) string {
	t.Helper()
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("expected a redirect to the draft, got %d: %s", rec.Code, rec.Body)
	}
	loc := rec.Header().Get("Location")
	token := strings.TrimPrefix(loc, "/draft/")
	if token == loc || token == "" {
		t.Fatalf("no draft token in Location %q", loc)
	}
	return token
}

func testComposeServer(t *testing.T) (*server, *http.ServeMux) {
	t.Helper()
	accountName = "test"
	myAddress = "me@example.org"
	myAddresses = map[string]bool{myAddress: true}
	s := newTestServer(t)
	s.fromAddr = myAddress
	return s, s.routes()
}

// TestComposeCreatesAnAddressableDraft is the whole point of the feature: a
// mail to somebody who has never written here.
func TestComposeCreatesAnAddressableDraft(t *testing.T) {
	s, mux := testComposeServer(t)

	token := draftTokenOf(t, postForm(t, mux, "/compose", url.Values{
		"to":      {"strassenverkehr@example.org"},
		"cc":      {"kollege@example.org, zweite@example.org"},
		"subject": {"Antrag"},
		"body":    {"Sehr geehrte Damen und Herren,"},
	}))

	d, err := loadDraft(s.db.Read, token)
	if err != nil {
		t.Fatalf("load draft: %v", err)
	}
	if d.ParentID != 0 {
		t.Errorf("a composed draft answers nothing, got parent %d", d.ParentID)
	}
	if d.Subject != "Antrag" {
		t.Errorf("subject = %q", d.Subject)
	}
	if len(d.Blocks) != 1 || d.Blocks[0].Content != "Sehr geehrte Damen und Herren," {
		t.Errorf("unexpected blocks: %+v", d.Blocks)
	}

	// The addresses are in the entered set and nowhere else. A derived set
	// here would mean mailweb claiming it worked them out.
	set, ok := d.Recipients[setEntered]
	if !ok {
		t.Fatalf("no entered set, got %v", d.Recipients)
	}
	for _, derived := range []string{setSender, setAll, setList} {
		if _, ok := d.Recipients[derived]; ok {
			t.Errorf("a composed draft must have no %s set", derived)
		}
	}
	if len(set.To) != 1 || set.To[0].Address() != "strassenverkehr@example.org" {
		t.Errorf("To = %v", set.To)
	}
	// A comma-separated field is what a person types into a mail client, and
	// is split rather than refused.
	if len(set.Cc) != 2 {
		t.Fatalf("Cc = %v, want two addresses", set.Cc)
	}
}

// TestEnteredAddressIsNotItsOwnRemoveButton pins down how the draft page shows
// an address that can be taken back off.
//
// It used to be a button whose whole label was the address, so it read as a
// chip showing who the mail goes to, and a click meant to look at it removed it
// — without a word, from a draft that then said it was addressed to nobody. The
// address is shown as text now, beside a button that says what it does and asks
// first, the way an attachment's does.
func TestEnteredAddressIsNotItsOwnRemoveButton(t *testing.T) {
	_, mux := testComposeServer(t)
	token := draftTokenOf(t, postForm(t, mux, "/compose", url.Values{
		"to": {"amt@example.org"},
		"cc": {"kollege@example.org"},
	}))

	req := httptest.NewRequest("GET", "/draft/"+token, nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("draft page: %d", rec.Code)
	}
	page := rec.Body.String()

	for _, addr := range []string{"amt@example.org", "kollege@example.org"} {
		// No button may carry the address as its label.
		if strings.Contains(page, ": "+addr+"</button>") {
			t.Errorf("%s is still the label of a button", addr)
		}
		if !strings.Contains(page, "&lt;"+addr+"&gt;") {
			t.Errorf("%s is not shown as text", addr)
		}
	}
	if n := strings.Count(page, `type="submit" title="Remove `); n != 2 {
		t.Errorf("%d remove buttons, want one per address", n)
	}
	if n := strings.Count(page, `return confirm('Remove `); n != 2 {
		t.Errorf("%d removals ask first, want all 2", n)
	}
}

// TestComposeRefusesTheWholeRequestOnOneBadAddress checks that nothing is
// created when one address is not one.
//
// The failure this prevents is a draft addressed to four of the five people
// asked for: nothing afterwards says the fifth was dropped, so the mail goes to
// a list somebody believes is complete.
func TestComposeRefusesTheWholeRequestOnOneBadAddress(t *testing.T) {
	s, mux := testComposeServer(t)

	rec := postForm(t, mux, "/compose", url.Values{
		"to": {"gut@example.org", "~petname"},
	})
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("got %d, want 400: %s", rec.Code, rec.Body)
	}
	// The refusal says what to write instead, which is the whole value of
	// refusing a petname by name rather than as a syntax error.
	if !strings.Contains(rec.Body.String(), "petname") {
		t.Errorf("the refusal should name the problem: %s", rec.Body)
	}

	drafts, err := listDrafts(s.db.Read)
	if err != nil {
		t.Fatalf("list drafts: %v", err)
	}
	if len(drafts) != 0 {
		t.Errorf("nothing should have been created, got %d drafts", len(drafts))
	}
}

// TestEnteredRecipientsCannotReachADerivedSet is the safety of the whole
// recipient feature.
//
// A derived set records who the message being answered named, and the send
// button's label is built from those rows. If a request could write into one,
// the addresses could drift away from the words somebody read before pressing
// it — which is the failure "Choosing who a draft goes to" exists to prevent.
func TestEnteredRecipientsCannotReachADerivedSet(t *testing.T) {
	s, mux := testComposeServer(t)

	// A draft with a derived sender set, as a reply has.
	d := makeDraft(t, s, draftBlock{Kind: blockText, Content: "hi"})
	before := d.Recipients[setSender]

	// Every field the route reads, plus one it does not: a request that tries
	// to name the set it writes to.
	rec := postForm(t, mux, "/draft/"+d.Token+"/recipients", url.Values{
		"address":  {"neu@example.org"},
		"kind":     {"cc"},
		"set":      {setSender},
		"set_name": {setSender},
	})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("add recipient: %d %s", rec.Code, rec.Body)
	}

	after, err := loadDraft(s.db.Read, d.Token)
	if err != nil {
		t.Fatalf("reload: %v", err)
	}
	if got := after.Recipients[setSender]; got.Count() != before.Count() {
		t.Errorf("the sender set changed: %d recipients, was %d",
			got.Count(), before.Count())
	}
	entered := after.Recipients[setEntered]
	if len(entered.Cc) != 1 || entered.Cc[0].Address() != "neu@example.org" {
		t.Errorf("entered Cc = %v", entered.Cc)
	}

	// And removing only reaches the entered set as well.
	rec = postForm(t, mux, "/draft/"+d.Token+"/recipients/remove", url.Values{
		"address": {before.To[0].Address()},
		"kind":    {"to"},
	})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("remove recipient: %d %s", rec.Code, rec.Body)
	}
	after, err = loadDraft(s.db.Read, d.Token)
	if err != nil {
		t.Fatalf("reload: %v", err)
	}
	if after.Recipients[setSender].Count() != before.Count() {
		t.Error("removing an entered address must not touch a derived set")
	}
}

// TestEnteredRecipientIsRefusedUnparsed checks that the parser stands between a
// request and the database here as it does everywhere else.
func TestEnteredRecipientIsRefusedUnparsed(t *testing.T) {
	s, mux := testComposeServer(t)
	d := makeDraft(t, s, draftBlock{Kind: blockText})

	for _, bad := range []string{
		"not-an-address",
		"Klara Meyer <klara@example.org>", // a display name is not an address
		"a@b.com\nBcc: victim@example.org",
	} {
		rec := postForm(t, mux, "/draft/"+d.Token+"/recipients",
			url.Values{"address": {bad}})
		if rec.Code != http.StatusBadRequest {
			t.Errorf("%q: got %d, want 400", bad, rec.Code)
		}
	}

	after, err := loadDraft(s.db.Read, d.Token)
	if err != nil {
		t.Fatalf("reload: %v", err)
	}
	if _, ok := after.Recipients[setEntered]; ok {
		t.Error("nothing should have been stored")
	}
}

// uploadFile posts a file as a browser would.
func uploadFile(t *testing.T, mux *http.ServeMux, token, name string, data []byte) *httptest.ResponseRecorder {
	t.Helper()
	var body bytes.Buffer
	mw := multipart.NewWriter(&body)
	part, err := mw.CreateFormFile("file", name)
	if err != nil {
		t.Fatalf("create form file: %v", err)
	}
	if _, err := part.Write(data); err != nil {
		t.Fatalf("write form file: %v", err)
	}
	mw.Close()

	req := httptest.NewRequest("POST", "/draft/"+token+"/assets", &body)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	return rec
}

// pngBytes is a one-pixel PNG, which is enough for the sniffer to recognise.
func pngBytes() []byte {
	return append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0}, 64)...)
}

// TestUploadTypeComesFromTheBytes is the rule the inline PDF route already
// applies to mail being read, here applied to mail being sent.
//
// A file called invoice.pdf that is not one must not be labelled
// application/pdf: that would be mailweb asserting to a recipient something it
// never checked.
func TestUploadTypeComesFromTheBytes(t *testing.T) {
	s, mux := testComposeServer(t)
	d := makeDraft(t, s, draftBlock{Kind: blockText, Content: "see attached"})

	// Named as a PDF, and a PNG. The bytes win.
	if rec := uploadFile(t, mux, d.Token, "invoice.pdf", pngBytes()); rec.Code != http.StatusSeeOther {
		t.Fatalf("upload: %d %s", rec.Code, rec.Body)
	}
	after, err := loadDraft(s.db.Read, d.Token)
	if err != nil {
		t.Fatalf("reload: %v", err)
	}
	files := after.Attachments()
	if len(files) != 1 {
		t.Fatalf("want one attachment, got %d", len(files))
	}
	if got := files[0].Asset.MimeType; got != "image/png" {
		t.Errorf("mime type = %q, want image/png — the bytes decide, not the name", got)
	}
	// The name is kept as given: it is what the recipient will see, and only
	// its path and control characters are mailweb's business.
	if files[0].Asset.Filename != "invoice.pdf" {
		t.Errorf("filename = %q", files[0].Asset.Filename)
	}
}

// TestUploadFilenameIsANameNotAPath checks that a name cannot carry a path.
func TestUploadFilenameIsANameNotAPath(t *testing.T) {
	for _, c := range []struct{ in, want string }{
		{"../../etc/passwd", "passwd"},
		{`C:\Users\me\foto.jpg`, "foto.jpg"},
		{"", "attachment"},
		{"   ", "attachment"},
		{"..", "attachment"},
		{`quo"te.txt`, "quo_te.txt"},
	} {
		if got := assetFilename(c.in); got != c.want {
			t.Errorf("assetFilename(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// TestUploadRefusesOversizeAndAttachesNothing checks the cap is enforced and
// that a refusal leaves the draft as it was.
func TestUploadRefusesOversizeAndAttachesNothing(t *testing.T) {
	s, mux := testComposeServer(t)
	d := makeDraft(t, s, draftBlock{Kind: blockText})

	rec := uploadFile(t, mux, d.Token, "huge.bin", bytes.Repeat([]byte{7}, maxAssetSize+1))
	if rec.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("got %d, want 413: %s", rec.Code, rec.Body)
	}
	// The limit is named, so a client does not have to find it by bisection.
	if !strings.Contains(rec.Body.String(), "25.0MB") {
		t.Errorf("the refusal should name the limit: %s", rec.Body)
	}

	after, err := loadDraft(s.db.Read, d.Token)
	if err != nil {
		t.Fatalf("reload: %v", err)
	}
	if len(after.Attachments()) != 0 {
		t.Error("a refused upload must attach nothing")
	}
	size, err := draftAssetsSize(s.db.Read, d.ID)
	if err != nil {
		t.Fatalf("size: %v", err)
	}
	if size != 0 {
		t.Errorf("a refused upload must store no bytes, got %d", size)
	}
}

// TestRemovingAnAttachmentRemovesItsBytes checks that deleting a file block
// takes the asset with it.
//
// Nothing else references an asset, so a block deleted without one would leave
// megabytes that no page lists, no message sends and no deletion reaches.
func TestRemovingAnAttachmentRemovesItsBytes(t *testing.T) {
	s, mux := testComposeServer(t)
	d := makeDraft(t, s, draftBlock{Kind: blockText})

	if rec := uploadFile(t, mux, d.Token, "foto.png", pngBytes()); rec.Code != http.StatusSeeOther {
		t.Fatalf("upload: %d %s", rec.Code, rec.Body)
	}
	after, err := loadDraft(s.db.Read, d.Token)
	if err != nil {
		t.Fatalf("reload: %v", err)
	}
	assetID := after.Attachments()[0].Asset.ID

	// The bytes can be fetched while it is there.
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, httptest.NewRequest("GET",
		fmt.Sprintf("/draft/%s/assets/%d", d.Token, assetID), nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("fetch asset: %d", rec.Code)
	}
	if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
		t.Error("an asset must be served nosniff")
	}
	// An uploaded SVG or HTML file is script if anything lets it be, and this
	// is served from mailweb's own origin.
	if rec.Header().Get("Content-Security-Policy") != "sandbox" {
		t.Error("an asset must be served in an opaque origin")
	}

	rec = postForm(t, mux,
		fmt.Sprintf("/draft/%s/assets/%d/remove", d.Token, assetID), url.Values{})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("remove: %d %s", rec.Code, rec.Body)
	}

	after, err = loadDraft(s.db.Read, d.Token)
	if err != nil {
		t.Fatalf("reload: %v", err)
	}
	if len(after.Attachments()) != 0 {
		t.Error("the block should be gone")
	}
	size, err := draftAssetsSize(s.db.Read, d.ID)
	if err != nil {
		t.Fatalf("size: %v", err)
	}
	if size != 0 {
		t.Errorf("the bytes should be gone too, %d left", size)
	}
}

// TestSentDraftRefusesRecipientsAndUploads checks that a sent draft is
// read-only on these routes as it is on the block routes.
//
// A sent draft is the record of what went out; changing it afterwards would
// make that record disagree with the mail people are holding.
func TestSentDraftRefusesRecipientsAndUploads(t *testing.T) {
	s, mux := testComposeServer(t)
	d := makeDraft(t, s, draftBlock{Kind: blockText})
	if err := markDraftSent(s.db.Write, d.ID, "<x@example.org>"); err != nil {
		t.Fatalf("mark sent: %v", err)
	}

	if rec := postForm(t, mux, "/draft/"+d.Token+"/recipients",
		url.Values{"address": {"neu@example.org"}}); rec.Code != http.StatusConflict {
		t.Errorf("adding a recipient to a sent draft: got %d, want 409", rec.Code)
	}
	if rec := uploadFile(t, mux, d.Token, "foto.png", pngBytes()); rec.Code != http.StatusConflict {
		t.Errorf("attaching to a sent draft: got %d, want 409", rec.Code)
	}
}