@@ -415,3 +415,84 @@ func stringContains(s, substr string) bool {
415415 }
416416 return false
417417}
418+
419+ func TestAttachmentDownloadSanitizesFilename (t * testing.T ) {
420+ tests := []struct {
421+ name string
422+ maliciousFilename string
423+ expectedOutputPath string
424+ }{
425+ {
426+ name : "path traversal with ../" ,
427+ maliciousFilename : "../../../etc/passwd" ,
428+ expectedOutputPath : "passwd" ,
429+ },
430+ {
431+ name : "path traversal with subdirectory" ,
432+ maliciousFilename : "../../.bashrc" ,
433+ expectedOutputPath : ".bashrc" ,
434+ },
435+ {
436+ name : "absolute path attempt" ,
437+ maliciousFilename : "/etc/shadow" ,
438+ expectedOutputPath : "shadow" ,
439+ },
440+ {
441+ name : "normal filename unchanged" ,
442+ maliciousFilename : "safe-file.png" ,
443+ expectedOutputPath : "safe-file.png" ,
444+ },
445+ {
446+ name : "filename with spaces" ,
447+ maliciousFilename : "my document.pdf" ,
448+ expectedOutputPath : "my document.pdf" ,
449+ },
450+ {
451+ name : "deeply nested traversal" ,
452+ maliciousFilename : "../../../../../../../../tmp/malware.sh" ,
453+ expectedOutputPath : "malware.sh" ,
454+ },
455+ }
456+
457+ for _ , tt := range tests {
458+ t .Run (tt .name , func (t * testing.T ) {
459+ cardData := map [string ]interface {}{
460+ "id" : "card-id" ,
461+ "number" : 241 ,
462+ "description_html" : `<action-text-attachment sgid="test-sgid" content-type="image/png" filename="` + tt .maliciousFilename + `" filesize="1000">
463+ <a href="/blobs/blob/file?disposition=attachment">Download</a>
464+ </action-text-attachment>` ,
465+ }
466+
467+ mock := NewMockClient ().WithGetData (cardData )
468+ result := SetTestMode (mock )
469+ SetTestConfig ("test-token" , "test-account" , "https://api.test.com" )
470+ defer ResetTestMode ()
471+
472+ rootCmd .SetArgs ([]string {"card" , "attachments" , "download" , "241" , "1" })
473+
474+ RunTestCommand (func () {
475+ _ = rootCmd .Execute ()
476+ })
477+
478+ if result .Response == nil {
479+ t .Fatal ("expected response, got nil" )
480+ }
481+
482+ if ! result .Response .Success {
483+ t .Errorf ("expected success, got error: %v" , result .Response )
484+ return
485+ }
486+
487+ // Verify the output path was sanitized
488+ if len (mock .DownloadFileCalls ) != 1 {
489+ t .Fatalf ("expected 1 download call, got %d" , len (mock .DownloadFileCalls ))
490+ }
491+
492+ actualOutputPath := mock .DownloadFileCalls [0 ].DestPath
493+ if actualOutputPath != tt .expectedOutputPath {
494+ t .Errorf ("expected sanitized output path %q, got %q" , tt .expectedOutputPath , actualOutputPath )
495+ }
496+ })
497+ }
498+ }
0 commit comments