mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 11:53:41 +00:00
fix: HTTP filename parsing for mixed encodings and GBK-encoded (#1189)
This commit is contained in:
1 parent
49b4d99b5e
commit
2f5fbfb17c
4 files changed
+253
-30
No files matched your search
@@ -132,7 +132,7 @@ require (
|
|||||||
golang.org/x/mod v0.24.0 // indirect
|
golang.org/x/mod v0.24.0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/net v0.39.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0
|
||||||
golang.org/x/time v0.8.0 // indirect
|
golang.org/x/time v0.8.0 // indirect
|
||||||
golang.org/x/tools v0.32.0 // indirect
|
golang.org/x/tools v0.32.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
"github.com/xiaoqidun/setft"
|
"github.com/xiaoqidun/setft"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -164,24 +166,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
|
|||||||
}
|
}
|
||||||
contentDisposition := httpResp.Header.Get(base.HttpHeaderContentDisposition)
|
contentDisposition := httpResp.Header.Get(base.HttpHeaderContentDisposition)
|
||||||
if contentDisposition != "" {
|
if contentDisposition != "" {
|
||||||
_, params, _ := mime.ParseMediaType(contentDisposition)
|
file.Name = parseFilename(contentDisposition)
|
||||||
filename := params["filename"]
|
|
||||||
if filename != "" {
|
|
||||||
// Check if the filename is MIME encoded-word
|
|
||||||
if strings.HasPrefix(filename, "=?") {
|
|
||||||
decoder := new(mime.WordDecoder)
|
|
||||||
filename = strings.Replace(filename, "UTF8", "UTF-8", 1)
|
|
||||||
file.Name, _ = decoder.Decode(filename)
|
|
||||||
} else {
|
|
||||||
file.Name = util.TryUrlQueryUnescape(filename)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
substr := "attachment; filename="
|
|
||||||
index := strings.Index(contentDisposition, substr)
|
|
||||||
if index != -1 {
|
|
||||||
file.Name = util.TryUrlQueryUnescape(contentDisposition[index+len(substr):])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// get file filePath by URL
|
// get file filePath by URL
|
||||||
if file.Name == "" {
|
if file.Name == "" {
|
||||||
@@ -618,12 +603,158 @@ func (f *Fetcher) buildClient() *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeMangledString(mangled string) string {
|
// parseFilename extracts filename from Content-Disposition header
|
||||||
rawBytes := make([]byte, 0, len(mangled)*3)
|
// It handles multiple encoding scenarios:
|
||||||
for _, r := range mangled {
|
// 1. RFC 5987/RFC 2231 format: filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip (preferred, checked first)
|
||||||
rawBytes = append(rawBytes, byte(r))
|
// 2. MIME encoded-word: filename="=?UTF-8?B?5rWL6K+VLnppcA==?="
|
||||||
|
// 3. URL-encoded: filename="%E6%B5%8B%E8%AF%95.zip"
|
||||||
|
// 4. Plain ASCII filename
|
||||||
|
//
|
||||||
|
// The key fix is checking filename*= first before mime.ParseMediaType, because
|
||||||
|
// some servers send Content-Disposition headers with invalid characters that cause
|
||||||
|
// mime.ParseMediaType to fail, but the filename*= parameter is still valid.
|
||||||
|
func parseFilename(contentDisposition string) string {
|
||||||
|
// First, try to find filename*= (RFC 5987 format, most reliable for non-ASCII)
|
||||||
|
if filename := parseFilenameExtended(contentDisposition); filename != "" {
|
||||||
|
return filename
|
||||||
}
|
}
|
||||||
return string(rawBytes)
|
|
||||||
|
// Try standard MIME parsing for regular filename= parameter
|
||||||
|
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||||
|
if err == nil {
|
||||||
|
if filename := params["filename"]; filename != "" {
|
||||||
|
return decodeFilenameParam(filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: manual parsing if mime.ParseMediaType fails
|
||||||
|
return parseFilenameFallback(contentDisposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFilenameExtended parses RFC 5987/RFC 2231 extended parameter format
|
||||||
|
// Format: filename*=charset'language'value (e.g., UTF-8''%E6%B5%8B%E8%AF%95.zip)
|
||||||
|
func parseFilenameExtended(cd string) string {
|
||||||
|
// Look for filename*= (case-insensitive)
|
||||||
|
lower := strings.ToLower(cd)
|
||||||
|
idx := strings.Index(lower, "filename*=")
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the value after filename*=
|
||||||
|
value := cd[idx+len("filename*="):]
|
||||||
|
|
||||||
|
// Find the end of the value (next ; or end of string)
|
||||||
|
if endIdx := strings.Index(value, ";"); endIdx != -1 {
|
||||||
|
value = value[:endIdx]
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// Parse charset'language'encoded-value format
|
||||||
|
// Common format: UTF-8''%E6%B5%8B%E8%AF%95.zip
|
||||||
|
parts := strings.SplitN(value, "''", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// parts[0] is charset (e.g., "UTF-8")
|
||||||
|
// parts[1] is percent-encoded value
|
||||||
|
decoded, err := url.QueryUnescape(parts[1])
|
||||||
|
if err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with single quote delimiter as well (some servers use this)
|
||||||
|
parts = strings.SplitN(value, "'", 3)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
decoded, err := url.QueryUnescape(parts[2])
|
||||||
|
if err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFilenameParam decodes a filename parameter value
|
||||||
|
// Handles MIME encoded-word, URL encoding, and GBK encoding fallback
|
||||||
|
func decodeFilenameParam(filename string) string {
|
||||||
|
// Check if the filename is MIME encoded-word (e.g., =?UTF-8?B?...?=)
|
||||||
|
if strings.HasPrefix(filename, "=?") {
|
||||||
|
decoder := new(mime.WordDecoder)
|
||||||
|
// Some servers use "UTF8" instead of "UTF-8", create a normalized copy
|
||||||
|
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
|
||||||
|
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try URL decoding
|
||||||
|
decoded := util.TryUrlQueryUnescape(filename)
|
||||||
|
|
||||||
|
// Check if the result is valid UTF-8. If not, try GBK decoding.
|
||||||
|
// This handles the case where Chinese Windows servers send GBK-encoded filenames
|
||||||
|
// which appear as garbled characters (e.g., "下载地址.zip" -> "���ص�ַ.zip")
|
||||||
|
if !utf8.ValidString(decoded) {
|
||||||
|
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
|
||||||
|
return gbkDecoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// gbkDecoder is a reusable GBK decoder for better performance
|
||||||
|
var gbkDecoder = simplifiedchinese.GBK.NewDecoder()
|
||||||
|
|
||||||
|
// tryDecodeGBK attempts to decode a string as GBK/GB2312/GB18030 encoding
|
||||||
|
// Returns empty string if decoding fails or result is not valid UTF-8
|
||||||
|
func tryDecodeGBK(s string) string {
|
||||||
|
// GBK uses 1-2 bytes per character. Single-byte chars are 0x00-0x7F (ASCII compatible).
|
||||||
|
// Double-byte chars have first byte 0x81-0xFE and second byte 0x40-0xFE.
|
||||||
|
// Skip if string is empty or all ASCII (valid UTF-8)
|
||||||
|
if len(s) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a fresh decoder since the transform state may be corrupted
|
||||||
|
decoder := simplifiedchinese.GBK.NewDecoder()
|
||||||
|
decoded, err := decoder.Bytes([]byte(s))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := string(decoded)
|
||||||
|
if utf8.ValidString(result) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
|
||||||
|
func parseFilenameFallback(cd string) string {
|
||||||
|
// Look for filename= (case-insensitive)
|
||||||
|
lower := strings.ToLower(cd)
|
||||||
|
idx := strings.Index(lower, "filename=")
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip "filename=" prefix
|
||||||
|
value := cd[idx+len("filename="):]
|
||||||
|
|
||||||
|
// Find the end of the value
|
||||||
|
if endIdx := strings.Index(value, ";"); endIdx != -1 {
|
||||||
|
value = value[:endIdx]
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// Remove quotes if present
|
||||||
|
if len(value) >= 2 {
|
||||||
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||||
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||||
|
value = value[1 : len(value)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeFilenameParam(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
type fetcherData struct {
|
type fetcherData struct {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestFetcher_Resolve(t *testing.T) {
|
|||||||
Range: false,
|
Range: false,
|
||||||
Files: []*base.FileInfo{
|
Files: []*base.FileInfo{
|
||||||
{
|
{
|
||||||
Name: "测试.zip",
|
Name: test.TestChineseFileName,
|
||||||
Size: test.BuildSize,
|
Size: test.BuildSize,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -52,7 +52,7 @@ func TestFetcher_Resolve(t *testing.T) {
|
|||||||
Range: false,
|
Range: false,
|
||||||
Files: []*base.FileInfo{
|
Files: []*base.FileInfo{
|
||||||
{
|
{
|
||||||
Name: "测试.zip",
|
Name: test.TestChineseFileName,
|
||||||
Size: test.BuildSize,
|
Size: test.BuildSize,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -62,7 +62,7 @@ func TestFetcher_Resolve(t *testing.T) {
|
|||||||
Range: false,
|
Range: false,
|
||||||
Files: []*base.FileInfo{
|
Files: []*base.FileInfo{
|
||||||
{
|
{
|
||||||
Name: "测试.zip",
|
Name: test.TestChineseFileName,
|
||||||
Size: 0,
|
Size: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -77,6 +77,42 @@ func TestFetcher_Resolve(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, t)
|
}, t)
|
||||||
|
// Test mixed encoding Content-Disposition where mime.ParseMediaType fails
|
||||||
|
// due to invalid characters, but filename*= contains the correct UTF-8 encoded name
|
||||||
|
testResolve(test.StartTestCustomServer, "mixed-encoding", &base.Resource{
|
||||||
|
Size: test.BuildSize,
|
||||||
|
Range: false,
|
||||||
|
Files: []*base.FileInfo{
|
||||||
|
{
|
||||||
|
Name: test.TestChineseFileName,
|
||||||
|
Size: test.BuildSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, t)
|
||||||
|
// Test filename*= only (RFC 5987 format)
|
||||||
|
testResolve(test.StartTestCustomServer, "filename-star", &base.Resource{
|
||||||
|
Size: test.BuildSize,
|
||||||
|
Range: false,
|
||||||
|
Files: []*base.FileInfo{
|
||||||
|
{
|
||||||
|
Name: test.TestChineseFileName,
|
||||||
|
Size: test.BuildSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, t)
|
||||||
|
// Test GBK-encoded filename (common on Chinese Windows servers)
|
||||||
|
// Before fix: "测试.zip" sent as GBK bytes -> parsed as "²âÊÔ.zip" (garbled)
|
||||||
|
// After fix: correctly decoded back to "测试.zip"
|
||||||
|
testResolve(test.StartTestCustomServer, "gbk-encoded", &base.Resource{
|
||||||
|
Size: test.BuildSize,
|
||||||
|
Range: false,
|
||||||
|
Files: []*base.FileInfo{
|
||||||
|
{
|
||||||
|
Name: test.TestChineseFileName,
|
||||||
|
Size: test.BuildSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, t)
|
||||||
|
|
||||||
fetcher := buildFetcher()
|
fetcher := buildFetcher()
|
||||||
err := fetcher.Resolve(&base.Request{
|
err := fetcher.Resolve(&base.Request{
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
"github.com/armon/go-socks5"
|
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,6 +15,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/armon/go-socks5"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -34,6 +36,10 @@ const (
|
|||||||
DownloadRename = "download (1).data"
|
DownloadRename = "download (1).data"
|
||||||
DownloadFile = Dir + DownloadName
|
DownloadFile = Dir + DownloadName
|
||||||
DownloadRenameFile = Dir + DownloadRename
|
DownloadRenameFile = Dir + DownloadRename
|
||||||
|
|
||||||
|
// TestChineseFileName is a common test filename with Chinese characters
|
||||||
|
// Used to test Content-Disposition parsing with various encodings
|
||||||
|
TestChineseFileName = "测试.zip"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartTestFileServer() net.Listener {
|
func StartTestFileServer() net.Listener {
|
||||||
@@ -103,7 +109,57 @@ func StartTestCustomServer() net.Listener {
|
|||||||
io.Copy(writer, file)
|
io.Copy(writer, file)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
|
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
|
||||||
writer.Header().Set("Content-Disposition", "attachment; filename=测试.zip")
|
writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
|
||||||
|
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||||
|
file, err := os.Open(BuildFile)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
io.Copy(writer, file)
|
||||||
|
})
|
||||||
|
// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
|
||||||
|
// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
|
||||||
|
// due to invalid characters like <a> tags in the filename.
|
||||||
|
mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
// This simulates a server that sends a garbled filename= with special chars that cause
|
||||||
|
// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
|
||||||
|
// The filename*= should be preferred and correctly parsed.
|
||||||
|
writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
||||||
|
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||||
|
file, err := os.Open(BuildFile)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
io.Copy(writer, file)
|
||||||
|
})
|
||||||
|
// Test endpoint for filename*= only (RFC 5987 format)
|
||||||
|
mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
|
||||||
|
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
||||||
|
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||||
|
file, err := os.Open(BuildFile)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
io.Copy(writer, file)
|
||||||
|
})
|
||||||
|
// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
|
||||||
|
// This simulates the case where Chinese characters are sent as GBK bytes
|
||||||
|
// which appear as garbled characters when interpreted as UTF-8.
|
||||||
|
// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
|
||||||
|
// Our fix detects invalid UTF-8 and attempts GBK decoding.
|
||||||
|
mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
// Encode TestChineseFileName as GBK
|
||||||
|
gbkEncoder := simplifiedchinese.GBK.NewEncoder()
|
||||||
|
gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
|
||||||
|
// Send GBK bytes directly in filename (simulating broken server behavior)
|
||||||
|
writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
|
||||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||||
file, err := os.Open(BuildFile)
|
file, err := os.Open(BuildFile)
|
||||||
|
|||||||
Reference in new issue
Block a user