-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdisposition_test.go
More file actions
484 lines (418 loc) · 16.3 KB
/
Copy pathdisposition_test.go
File metadata and controls
484 lines (418 loc) · 16.3 KB
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
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
package dispo_test
import (
"net/url"
"strings"
"testing"
"github.com/skarm/dispo"
)
const (
testFilenamePrefix = `; filename="`
testFilenameStarPrefix = `; filename*=UTF-8''`
)
func TestContentDisposition_FilenameCases(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "ascii simple", in: "file.txt", want: `attachment; filename="file.txt"`},
{name: "trim edges keep inner space", in: " report 2026.txt ", want: `attachment; filename="report 2026.txt"`},
{name: "unicode letters", in: "Тест.pdf", want: `attachment; filename="____.pdf"; filename*=UTF-8''%D0%A2%D0%B5%D1%81%D1%82.pdf`},
{name: "emoji", in: "🔥.txt", want: `attachment; filename="_.txt"; filename*=UTF-8''%F0%9F%94%A5.txt`},
{name: "path separators and quote", in: `a/b\c"d.txt`, want: `attachment; filename="a_b_c\"d.txt"; filename*=UTF-8''a_b_c%22d.txt`},
{name: "chrome unicode with backslash", in: `ТЕСТ ФАЙЛ\ДОК_2023-04-04_17-46-52.pdf`, want: `attachment; filename="____ _________2023-04-04_17-46-52.pdf"; filename*=UTF-8''%D0%A2%D0%95%D0%A1%D0%A2%20%D0%A4%D0%90%D0%99%D0%9B_%D0%94%D0%9E%D0%9A_2023-04-04_17-46-52.pdf`},
{name: "controls removed", in: "ab\x00\x01\r\n\tcd.txt", want: `attachment; filename="abcd.txt"`},
{name: "only spaces fallback", in: " \u00A0\u2002 ", want: `attachment`},
{name: "empty fallback", in: "", want: `attachment`},
{name: "all spaces", in: " ", want: `attachment`},
{name: "percent kept", in: "100%.txt", want: `attachment; filename="100%.txt"`},
{name: "percent and others", in: "100%;, ok.txt", want: `attachment; filename="100%;, ok.txt"`},
{name: "percent hex escaped in fallback", in: "file%20name.txt", want: `attachment; filename="file_20name.txt"; filename*=UTF-8''file%2520name.txt`},
{name: "percent lowercase hex escaped in fallback", in: "file%af.txt", want: `attachment; filename="file_af.txt"; filename*=UTF-8''file%25af.txt`},
{name: "percent hazard formed after control removal", in: "file%\r\n41.txt", want: `attachment; filename="file_41.txt"; filename*=UTF-8''file%2541.txt`},
{name: "percent hazard formed after nul removal", in: "file%\x0041.txt", want: `attachment; filename="file_41.txt"; filename*=UTF-8''file%2541.txt`},
{name: "crlf injection payload", in: "evil\r\nSet-Cookie: hack=1.txt", want: `attachment; filename="evilSet-Cookie: hack=1.txt"`},
{name: "invalid utf8 bytes", in: string([]byte{0xff, 0xfe, 'a'}), want: `attachment; filename="__a"; filename*=UTF-8''%EF%BF%BD%EF%BF%BDa`},
{name: "ascii prefix + non-ascii + ascii after", in: "abc🔥def.txt", want: `attachment; filename="abc_def.txt"; filename*=UTF-8''abc%F0%9F%94%A5def.txt`},
{name: "ascii + spaces + non-ascii", in: "a 🔥b.txt", want: `attachment; filename="a _b.txt"; filename*=UTF-8''a%20%20%F0%9F%94%A5b.txt`},
{name: "ascii word with space before first non-ascii", in: "a b🔥.txt", want: `attachment; filename="a b_.txt"; filename*=UTF-8''a%20b%F0%9F%94%A5.txt`},
{name: "only non-ascii", in: "🔥🔥🔥.txt", want: `attachment; filename="___.txt"; filename*=UTF-8''%F0%9F%94%A5%F0%9F%94%A5%F0%9F%94%A5.txt`},
{name: "ascii + non-ascii + ascii after", in: "a🔥b.txt", want: `attachment; filename="a_b.txt"; filename*=UTF-8''a%F0%9F%94%A5b.txt`},
{name: "chinese characters", in: "你好世界.txt", want: `attachment; filename="____.txt"; filename*=UTF-8''%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C.txt`},
{name: "japanese kanji + kana", in: "漢字かな混在.pdf", want: `attachment; filename="______.pdf"; filename*=UTF-8''%E6%BC%A2%E5%AD%97%E3%81%8B%E3%81%AA%E6%B7%B7%E5%9C%A8.pdf`},
{name: "arabic letters", in: "مرحبا.txt", want: `attachment; filename="_____.txt"; filename*=UTF-8''%D9%85%D8%B1%D8%AD%D8%A8%D8%A7.txt`},
{name: "hebrew letters", in: "שלום.doc", want: `attachment; filename="____.doc"; filename*=UTF-8''%D7%A9%D7%9C%D7%95%D7%9D.doc`},
{name: "symbols and math", in: "∑∆√≈.txt", want: `attachment; filename="____.txt"; filename*=UTF-8''%E2%88%91%E2%88%86%E2%88%9A%E2%89%88.txt`},
{name: "mixing emojis and CJK", in: "🔥你好.txt", want: `attachment; filename="___.txt"; filename*=UTF-8''%F0%9F%94%A5%E4%BD%A0%E5%A5%BD.txt`},
{name: "slash only", in: "a/b", want: `attachment; filename="a_b"; filename*=UTF-8''a_b`},
{name: "backslash only", in: `a\b`, want: `attachment; filename="a_b"; filename*=UTF-8''a_b`},
{name: "quote only", in: `a"b`, want: `attachment; filename="a\"b"`},
{name: "percent only", in: "a%b", want: `attachment; filename="a%b"`},
{name: "apostrophe", in: "it's.txt", want: `attachment; filename="it's.txt"`},
{name: "asterisk", in: "file*.txt", want: `attachment; filename="file*.txt"`},
{name: "percent + unicode", in: "100%🔥.txt", want: `attachment; filename="100%_.txt"; filename*=UTF-8''100%25%F0%9F%94%A5.txt`},
{name: "double extension", in: "archive.tar.gz", want: `attachment; filename="archive.tar.gz"`},
{name: "brackets", in: "file[1].txt", want: `attachment; filename="file[1].txt"`},
{name: "angle brackets", in: "file<1>.txt", want: `attachment; filename="file<1>.txt"`},
{name: "equals", in: "a=b.txt", want: `attachment; filename="a=b.txt"`},
{name: "controls only become empty", in: "\x00\x01\r\n\x7f", want: `attachment`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dispo.Attachment(tt.in)
if got != tt.want {
t.Fatalf("mismatch:\nwant=%q\ngot =%q", tt.want, got)
}
disposition, filename, filenameStar, hasFilenameStar := parseContentDisposition(t, got)
if disposition != "attachment" {
t.Fatalf("disposition mismatch: %q", disposition)
}
assertCommonOutputInvariants(t, got, disposition, filename, filenameStar, hasFilenameStar)
})
}
}
func TestContentDisposition_TypeNormalization(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"inline lowercase", "inline", `inline; filename="file.txt"`},
{"inline uppercase", "INLINE", `inline; filename="file.txt"`},
{"inline with extra spaces", " \tInLiNe\n", `inline; filename="file.txt"`},
{"attachment explicit", "attachment", `attachment; filename="file.txt"`},
{"attachment with extra spaces", " \tattachment\r\n", `attachment; filename="file.txt"`},
{"extension token", "form-data", `form-data; filename="file.txt"`},
{"extension token normalized to lowercase", "X-Download", `x-download; filename="file.txt"`},
{"invalid token fallback", "bad token", `attachment; filename="file.txt"`},
{"empty", "", `attachment; filename="file.txt"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dispo.ContentDisposition(tt.in, "file.txt")
if got != tt.want {
t.Fatalf("mismatch:\nwant=%q\ngot =%q", tt.want, got)
}
})
}
}
func TestNormalizeDispositionType(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "inline lowercase", in: "inline", want: "inline"},
{name: "inline mixed case", in: "InLiNe", want: "inline"},
{name: "inline with surrounding HTTP whitespace", in: " \tinline\r\n", want: "inline"},
{name: "attachment lowercase", in: "attachment", want: "attachment"},
{name: "attachment mixed case", in: "AtTaChMeNt", want: "attachment"},
{name: "extension token lowercased", in: "X-Download", want: "x-download"},
{name: "token with allowed punctuation", in: "foo_bar.baz+qux", want: "foo_bar.baz+qux"},
{name: "empty fallback", in: "", want: "attachment"},
{name: "whitespace only fallback", in: " \t\r\n", want: "attachment"},
{name: "space inside invalid", in: "bad token", want: "attachment"},
{name: "separator invalid", in: "bad/token", want: "attachment"},
{name: "unicode invalid", in: "вложение", want: "attachment"},
{name: "control invalid", in: "bad\x00token", want: "attachment"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dispo.NormalizeDispositionType(tt.in)
if got != tt.want {
t.Fatalf("NormalizeDispositionType(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestContentDisposition_Wrappers(t *testing.T) {
name := "my report.txt"
wantAttachment := `attachment; filename="my report.txt"`
wantInline := `inline; filename="my report.txt"`
if got := dispo.Attachment(name); got != wantAttachment {
t.Fatalf("attachment mismatch:\nwant=%q\ngot =%q", wantAttachment, got)
}
if got := dispo.Inline(name); got != wantInline {
t.Fatalf("inline mismatch:\nwant=%q\ngot =%q", wantInline, got)
}
}
func TestContentDisposition_NonASCIICtrl(t *testing.T) {
runes := []rune{'\u0085', '\u2028', '\u2029'}
for _, r := range runes {
in := "ab" + string(r) + "cd.txt"
got := dispo.Attachment(in)
if got == "" {
t.Fatal("empty output")
}
_, filename, filenameStar, hasStar := parseContentDisposition(t, got)
if !hasQuotedStringSafety(filename) {
t.Fatalf("unsafe chars in filename: %q", filename)
}
if hasStar {
t.Fatalf("unexpected filename*: %q", got)
}
if filenameStar != "" {
t.Fatalf("unexpected filename*: %q", filenameStar)
}
}
}
func TestContentDisposition_InvariantsOnManyInputs(t *testing.T) {
types := []string{"attachment", "inline", "INLINE", "", "unknown"}
inputs := []string{
"", "a", "file.txt", " file.txt ", "你好世界", "🔥🔥🔥",
"\r\nbad", `quo"te\slash`, "semi;colon,comma", "100%.txt",
"foo\u00A0bar", string([]byte{0x00, 0x01, 0xff, 0xfe}),
strings.Repeat("a", 1024), strings.Repeat("🔥", 128),
}
for _, dt := range types {
for _, in := range inputs {
out1 := dispo.ContentDisposition(dt, in)
out2 := dispo.ContentDisposition(dt, in)
if out1 == "" {
t.Fatalf("empty output for type=%q input=%q", dt, in)
}
if out1 != out2 {
t.Fatalf("non-deterministic:\n%q\n!=\n%q", out1, out2)
}
disposition, filename, filenameStar, hasFilenameStar := parseContentDisposition(t, out1)
if !isHTTPDispositionToken(disposition) {
t.Fatalf("bad disposition %q", disposition)
}
assertCommonOutputInvariants(t, out1, disposition, filename, filenameStar, hasFilenameStar)
}
}
}
func TestContentDisposition_UTF8RoundTrip(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{name: "unicode letters", in: "Тест.pdf", want: "Тест.pdf"},
{name: "emoji", in: "🔥.txt", want: "🔥.txt"},
{name: "mixed unicode", in: "abc🔥def.txt", want: "abc🔥def.txt"},
{name: "cjk", in: "你好世界.txt", want: "你好世界.txt"},
{name: "kanji kana", in: "漢字かな混在.pdf", want: "漢字かな混在.pdf"},
{name: "ascii with separators", in: `a/b\c"d.txt`, want: `a_b_c"d.txt`},
{name: "ascii percent hazard", in: "file%20name.txt", want: "file%20name.txt"},
{name: "ascii lowercase hex hazard", in: "file%af.txt", want: "file%af.txt"},
{name: "sanitized percent hazard", in: "file%\r\n41.txt", want: "file%41.txt"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
header := dispo.Attachment(tt.in)
start := strings.Index(header, testFilenameStarPrefix)
if start < 0 {
t.Fatalf("filename* not found in header: %q", header)
}
encoded := header[start+len(testFilenameStarPrefix):]
decoded, err := url.PathUnescape(encoded)
if err != nil {
t.Fatalf("failed to percent-decode filename*: %v", err)
}
if decoded != tt.want {
t.Fatalf("UTF-8 roundtrip failed: want=%q got=%q", tt.want, decoded)
}
})
}
}
func TestContentDisposition_LongFilename(t *testing.T) {
in := strings.Repeat("a", 4096) + ".tar.gz"
want := `attachment; filename="` + in + `"`
if got := dispo.Attachment(in); got != want {
t.Fatalf("mismatch:\nwant=%q\ngot =%q", want, got)
}
}
func FuzzContentDisposition(f *testing.F) {
seeds := []struct {
dt string
name string
}{
{dt: "inline", name: "file.txt"},
{dt: "attachment", name: "Тест.pdf"},
{dt: "INLINE", name: "🔥 report.txt"},
{dt: "inline", name: string([]byte{0xff, 0xfe, 0xfd})},
{dt: " \tinline\n", name: `a/b\c"d`},
{dt: "", name: ""},
}
for _, s := range seeds {
f.Add(s.dt, s.name)
}
f.Fuzz(func(t *testing.T, dispositionType, name string) {
out1 := dispo.ContentDisposition(dispositionType, name)
out2 := dispo.ContentDisposition(dispositionType, name)
if out1 == "" {
t.Fatal("empty output")
}
if out1 != out2 {
t.Fatalf("non-deterministic:\n%q\n!=\n%q", out1, out2)
}
disp, filename, filenameStar, hasFilenameStar := parseContentDisposition(t, out1)
if !isHTTPDispositionToken(disp) {
t.Fatalf("unexpected disposition: %q", disp)
}
assertCommonOutputInvariants(t, out1, disp, filename, filenameStar, hasFilenameStar)
})
}
func parseContentDisposition(t *testing.T, out string) (disposition, filename, filenameStar string, hasFilenameStar bool) {
t.Helper()
i := strings.Index(out, testFilenamePrefix)
if i < 0 {
return out, "", "", false
}
disposition = out[:i]
start := i + len(testFilenamePrefix)
closeIdx, ok := scanQuotedString(out, start)
if !ok {
t.Fatalf("invalid Content-Disposition format: %q", out)
}
filename = out[start:closeIdx]
afterFilename := closeIdx + 1
j := strings.Index(out[afterFilename:], testFilenameStarPrefix)
if j >= 0 {
starStart := afterFilename + j + len(testFilenameStarPrefix)
filenameStar = out[starStart:]
return disposition, filename, filenameStar, true
}
return disposition, filename, "", false
}
func scanQuotedString(s string, start int) (closeIdx int, ok bool) {
for i := start; i < len(s); i++ {
switch s[i] {
case '\\':
if i+1 >= len(s) {
return 0, false
}
i++
case '"':
return i, true
}
}
return 0, false
}
func assertCommonOutputInvariants(t *testing.T, out, disposition, filename, filenameStar string, hasFilenameStar bool) {
t.Helper()
if out == "" {
t.Fatal("empty output")
}
if strings.ContainsAny(out, "\r\n") {
t.Fatalf("CRLF injection detected: %q", out)
}
if filename == "" {
if out != disposition {
t.Fatalf("expected disposition-only output, got %q", out)
}
if hasFilenameStar {
t.Fatalf("unexpected filename* for disposition-only output: %q", out)
}
if filenameStar != "" {
t.Fatalf("unexpected filename* value for disposition-only output: %q", filenameStar)
}
return
}
if !hasQuotedStringSafety(filename) {
t.Fatalf("unsafe quoted filename content: %q", filename)
}
if strings.Contains(filename, "/") {
t.Fatalf("legacy-unsafe slash in filename fallback: %q", filename)
}
if hasRFC6266PercentHazard(filename) {
t.Fatalf("legacy-unsafe percent escape pattern in filename fallback: %q", filename)
}
if hasFilenameStar {
if filenameStar == "" {
t.Fatal("empty filename* parameter")
}
assertValidRFC5987Value(t, filenameStar)
return
}
if filenameStar != "" {
t.Fatalf("unexpected filename* value for ascii-only form: %q", filenameStar)
}
if strings.Contains(out, testFilenameStarPrefix) {
t.Fatalf("unexpected filename* token in ascii-only form: %q", out)
}
}
func hasQuotedStringSafety(v string) bool {
for i := 0; i < len(v); i++ {
b := v[i]
if b < 0x20 || b == 0x7f {
return false
}
if b == '\\' {
if i+1 >= len(v) {
return false
}
next := v[i+1]
if next != '\\' && next != '"' {
return false
}
i++
continue
}
if b == '"' {
return false
}
}
return true
}
func assertValidRFC5987Value(t *testing.T, v string) {
t.Helper()
for i := 0; i < len(v); i++ {
b := v[i]
if b == '%' {
if i+2 >= len(v) || !isHex(v[i+1]) || !isHex(v[i+2]) {
t.Fatalf("broken percent encoding in filename*: %q", v)
}
i += 2
continue
}
if b >= 0x80 || !isRFC5987AttrChar(b) {
t.Fatalf("invalid unescaped byte %q in filename*: %q", b, v)
}
}
}
func isRFC5987AttrChar(b byte) bool {
if b >= 'a' && b <= 'z' {
return true
}
if b >= 'A' && b <= 'Z' {
return true
}
if b >= '0' && b <= '9' {
return true
}
switch b {
case '!', '#', '$', '&', '+', '-', '.', '^', '_', '`', '|', '~':
return true
default:
return false
}
}
func isHex(b byte) bool {
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'F') || (b >= 'a' && b <= 'f')
}
func hasRFC6266PercentHazard(s string) bool {
for i := 0; i+2 < len(s); i++ {
if s[i] == '%' && isHex(s[i+1]) && isHex(s[i+2]) {
return true
}
}
return false
}
func isHTTPDispositionToken(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
b := s[i]
if b <= 0x20 || b >= 0x7f {
return false
}
switch b {
case '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}':
return false
}
}
return true
}