Skip to content

Commit acf3c58

Browse files
authored
Merge pull request #388 from wneessen/bug/filename-sanitization
Improve filename sanitization in MIME headers
2 parents fe53c86 + 06bee90 commit acf3c58

File tree

2 files changed

+123
-4
lines changed

2 files changed

+123
-4
lines changed

msgwriter.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) {
273273
mimeType = string(file.ContentType)
274274
}
275275
file.setHeader(HeaderContentType, fmt.Sprintf(`%s; name="%s"`, mimeType,
276-
mw.encoder.Encode(mw.charset.String(), file.Name)))
276+
mw.encoder.Encode(mw.charset.String(), sanitizeFilename(file.Name))))
277277
}
278278

279279
if _, ok := file.getHeader(HeaderContentTransferEnc); !ok {
@@ -285,7 +285,7 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) {
285285

286286
if file.Desc != "" {
287287
if _, ok := file.getHeader(HeaderContentDescription); !ok {
288-
file.setHeader(HeaderContentDescription, file.Desc)
288+
file.setHeader(HeaderContentDescription, mw.encoder.Encode(mw.charset.String(), file.Desc))
289289
}
290290
}
291291

@@ -295,12 +295,12 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) {
295295
disposition = "attachment"
296296
}
297297
file.setHeader(HeaderContentDisposition, fmt.Sprintf(`%s; filename="%s"`,
298-
disposition, mw.encoder.Encode(mw.charset.String(), file.Name)))
298+
disposition, mw.encoder.Encode(mw.charset.String(), sanitizeFilename(file.Name))))
299299
}
300300

301301
if !isAttachment {
302302
if _, ok := file.getHeader(HeaderContentID); !ok {
303-
file.setHeader(HeaderContentID, fmt.Sprintf("<%s>", file.Name))
303+
file.setHeader(HeaderContentID, fmt.Sprintf("<%s>", sanitizeFilename(file.Name)))
304304
}
305305
}
306306
if mw.depth == 0 {
@@ -498,3 +498,33 @@ func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encodin
498498
mw.bytesWritten += n
499499
}
500500
}
501+
502+
// sanitizeFilename sanitizes a given filename string by replacing specific unwanted characters with
503+
// an underscore ('_').
504+
//
505+
// This method replaces any control character and any special character that is problematic for
506+
// MIME headers and file systems with an underscore ('_') character.
507+
//
508+
// The following characters are replaced
509+
// - Any control character (US-ASCII < 32)
510+
// - ", /, :, <, >, ?, \, |, [DEL]
511+
//
512+
// Parameters:
513+
// - input: A string of a filename that is supposed to be sanitized
514+
//
515+
// Returns:
516+
// - A string representing the sanitized version of the filename
517+
func sanitizeFilename(input string) string {
518+
var sanitized strings.Builder
519+
for i := 0; i < len(input); i++ {
520+
// We do not allow control characters in file names.
521+
if input[i] < 32 || input[i] == 34 || input[i] == 47 || input[i] == 58 ||
522+
input[i] == 60 || input[i] == 62 || input[i] == 63 || input[i] == 92 ||
523+
input[i] == 124 || input[i] == 127 {
524+
sanitized.WriteRune('_')
525+
continue
526+
}
527+
sanitized.WriteByte(input[i])
528+
}
529+
return sanitized.String()
530+
}

msgwriter_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,65 @@ func TestMsgWriter_addFiles(t *testing.T) {
304304
charset: CharsetUTF8,
305305
encoder: getEncoder(EncodingQP),
306306
}
307+
tests := []struct {
308+
name string
309+
filename string
310+
expect string
311+
}{
312+
{"normal US-ASCII filename", "test.txt", "test.txt"},
313+
{"normal US-ASCII filename with space", "test file.txt", "test file.txt"},
314+
{"filename with new lines", "test\r\n.txt", "test__.txt"},
315+
{"filename with disallowed character:\x22", "test\x22.txt", "test_.txt"},
316+
{"filename with disallowed character:\x2f", "test\x2f.txt", "test_.txt"},
317+
{"filename with disallowed character:\x3a", "test\x3a.txt", "test_.txt"},
318+
{"filename with disallowed character:\x3c", "test\x3c.txt", "test_.txt"},
319+
{"filename with disallowed character:\x3e", "test\x3e.txt", "test_.txt"},
320+
{"filename with disallowed character:\x3f", "test\x3f.txt", "test_.txt"},
321+
{"filename with disallowed character:\x5c", "test\x5c.txt", "test_.txt"},
322+
{"filename with disallowed character:\x7c", "test\x7c.txt", "test_.txt"},
323+
{"filename with disallowed character:\x7f", "test\x7f.txt", "test_.txt"},
324+
{
325+
"japanese characters filename", "添付ファイル.txt",
326+
"=?UTF-8?q?=E6=B7=BB=E4=BB=98=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB.txt?=",
327+
},
328+
{
329+
"simplified chinese characters filename", "测试附件文件.txt",
330+
"=?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=99=84=E4=BB=B6=E6=96=87=E4=BB=B6.txt?=",
331+
},
332+
{
333+
"cyrillic characters filename", "Тестовый прикрепленный файл.txt",
334+
"=?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B9_=D0=BF=D1=80?= " +
335+
"=?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B?= " +
336+
"=?UTF-8?q?=D0=B9_=D1=84=D0=B0=D0=B9=D0=BB.txt?=",
337+
},
338+
}
339+
for _, tt := range tests {
340+
t.Run("addFile with filename sanitization: "+tt.name, func(t *testing.T) {
341+
buffer := bytes.NewBuffer(nil)
342+
msgwriter.writer = buffer
343+
message := testMessage(t)
344+
message.AttachFile("testdata/attachment.txt", WithFileName(tt.filename))
345+
msgwriter.writeMsg(message)
346+
if msgwriter.err != nil {
347+
t.Errorf("msgWriter failed to write: %s", msgwriter.err)
348+
}
349+
350+
var ctExpect string
351+
cdExpect := fmt.Sprintf(`Content-Disposition: attachment; filename="%s"`, tt.expect)
352+
switch runtime.GOOS {
353+
case "freebsd":
354+
ctExpect = fmt.Sprintf(`Content-Type: application/octet-stream; name="%s"`, tt.expect)
355+
default:
356+
ctExpect = fmt.Sprintf(`Content-Type: text/plain; charset=utf-8; name="%s"`, tt.expect)
357+
}
358+
if !strings.Contains(buffer.String(), ctExpect) {
359+
t.Errorf("expected content-type: %q, got: %q", ctExpect, buffer.String())
360+
}
361+
if !strings.Contains(buffer.String(), cdExpect) {
362+
t.Errorf("expected content-disposition: %q, got: %q", cdExpect, buffer.String())
363+
}
364+
})
365+
}
307366
t.Run("message with a single file attached", func(t *testing.T) {
308367
buffer := bytes.NewBuffer(nil)
309368
msgwriter.writer = buffer
@@ -676,3 +735,33 @@ func TestMsgWriter_writeBody(t *testing.T) {
676735
}
677736
})
678737
}
738+
739+
func TestMsgWriter_sanitizeFilename(t *testing.T) {
740+
tests := []struct {
741+
given string
742+
want string
743+
}{
744+
{"test.txt", "test.txt"},
745+
{"test file.txt", "test file.txt"},
746+
{"test\\ file.txt", "test_ file.txt"},
747+
{`"test" file.txt`, "_test_ file.txt"},
748+
{`test file .txt`, "test_file_.txt"},
749+
{"test\r\nfile.txt", "test__file.txt"},
750+
{"test\x22file.txt", "test_file.txt"},
751+
{"test\x2ffile.txt", "test_file.txt"},
752+
{"test\x3afile.txt", "test_file.txt"},
753+
{"test\x3cfile.txt", "test_file.txt"},
754+
{"test\x3efile.txt", "test_file.txt"},
755+
{"test\x3ffile.txt", "test_file.txt"},
756+
{"test\x5cfile.txt", "test_file.txt"},
757+
{"test\x7cfile.txt", "test_file.txt"},
758+
{"test\x7ffile.txt", "test_file.txt"},
759+
}
760+
for _, tt := range tests {
761+
t.Run(tt.given+"=>"+tt.want, func(t *testing.T) {
762+
if got := sanitizeFilename(tt.given); got != tt.want {
763+
t.Errorf("sanitizeFilename failed, expected: %q, got: %q", tt.want, got)
764+
}
765+
})
766+
}
767+
}

0 commit comments

Comments
 (0)