From 519ecd1eac55cc78f42b7261870becae6e088e96 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 6 Mar 2026 23:19:41 +0700 Subject: [PATCH] =?UTF-8?q?fix(zalo):=20address=20PR=20#62=20review=20find?= =?UTF-8?q?ings=20=E2=80=94=20security,=20reliability,=20modularity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix CI: use %s for json.Number in e2e test (was %d) - Fix path traversal in parseMediaPath: restrict to os.TempDir() - Add 25MB file size limit (checkFileSize) before upload reads - Drain stale uploadCallbacks on Listener.reset() to prevent leaks - Split send.go (688 LOC) into send.go, send_image.go, send_file.go, send_helpers.go - Add unit tests for parseMediaPath (12 cases incl. traversal attacks) --- .../personal/protocol/listener_handlers.go | 12 + .../channels/zalo/personal/protocol/send.go | 484 ------------------ .../zalo/personal/protocol/send_file.go | 232 +++++++++ .../zalo/personal/protocol/send_helpers.go | 69 +++ .../zalo/personal/protocol/send_image.go | 233 +++++++++ internal/tools/message.go | 9 + internal/tools/message_test.go | 43 ++ tests/zalo_e2e/main.go | 2 +- 8 files changed, 599 insertions(+), 485 deletions(-) create mode 100644 internal/channels/zalo/personal/protocol/send_file.go create mode 100644 internal/channels/zalo/personal/protocol/send_helpers.go create mode 100644 internal/channels/zalo/personal/protocol/send_image.go create mode 100644 internal/tools/message_test.go diff --git a/internal/channels/zalo/personal/protocol/listener_handlers.go b/internal/channels/zalo/personal/protocol/listener_handlers.go index d066779c..d170a0e4 100644 --- a/internal/channels/zalo/personal/protocol/listener_handlers.go +++ b/internal/channels/zalo/personal/protocol/listener_handlers.go @@ -312,6 +312,18 @@ func (ln *Listener) reset() { ln.client = nil ln.cipherKey = "" ln.connectedAt = time.Time{} + + // Drain pending upload callbacks — old connection callbacks won't arrive. + ln.uploadCallbacks.Range(func(key, val any) bool { + if ch, ok := val.(chan string); ok { + select { + case ch <- "": + default: + } + } + ln.uploadCallbacks.Delete(key) + return true + }) } func (ln *Listener) resetRetryCounters() { diff --git a/internal/channels/zalo/personal/protocol/send.go b/internal/channels/zalo/personal/protocol/send.go index 8c133bb9..0a507f2f 100644 --- a/internal/channels/zalo/personal/protocol/send.go +++ b/internal/channels/zalo/personal/protocol/send.go @@ -1,24 +1,11 @@ package protocol import ( - "bytes" "context" - "crypto/md5" - "crypto/rand" "encoding/base64" - "encoding/hex" "encoding/json" "fmt" - "image" - _ "image/jpeg" - _ "image/png" - "io" - "mime/multipart" "net/http" - "os" - "path/filepath" - "strconv" - "strings" "time" ) @@ -181,477 +168,6 @@ func getServiceURL(sess *Session, service string) string { return urls[0] } -// FlexBool handles JSON fields that may be bool (true/false) or number (0/1). -type FlexBool bool - -func (b *FlexBool) UnmarshalJSON(data []byte) error { - switch string(data) { - case "true", "1": - *b = true - default: - *b = false - } - return nil -} - -// ImageUploadResult holds the response from uploading an image to Zalo's file service. -type ImageUploadResult struct { - NormalURL string `json:"normalUrl"` - HDUrl string `json:"hdUrl"` - ThumbURL string `json:"thumbUrl"` - PhotoID json.Number `json:"photoId"` // Zalo may return string or number - ClientFileID json.Number `json:"clientFileId"` // Zalo may return string or number - ChunkID int `json:"chunkId"` - Finished FlexBool `json:"finished"` // Zalo returns bool or int depending on endpoint - - // Set by caller (not from API response). - Width int `json:"-"` - Height int `json:"-"` - TotalSize int `json:"-"` -} - -// UploadImage uploads an image file to Zalo's file service. -// Returns upload metadata needed for SendImage. -func UploadImage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, filePath string) (*ImageUploadResult, error) { - fileURL := getServiceURL(sess, "file") - if fileURL == "" { - return nil, fmt.Errorf("zalo_personal: no file service URL") - } - - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("zalo_personal: read image: %w", err) - } - - fileName := filepath.Base(filePath) - totalSize := len(data) - width, height := imageDimensions(data) - - uploadParams := map[string]any{ - "totalChunk": 1, - "fileName": fileName, - "clientId": time.Now().UnixMilli(), - "totalSize": totalSize, - "imei": sess.IMEI, - "isE2EE": 0, - "jxl": 0, - "chunkId": 1, - } - if threadType == ThreadTypeGroup { - uploadParams["grid"] = threadID - } else { - uploadParams["toid"] = threadID - } - - encParams, err := encryptPayload(sess, uploadParams) - if err != nil { - return nil, fmt.Errorf("zalo_personal: encrypt upload params: %w", err) - } - - // Build URL: file_service/api/{message|group}/photo_original/upload - pathPrefix := "/api/message/" - typeParam := "2" - if threadType == ThreadTypeGroup { - pathPrefix = "/api/group/" - typeParam = "11" - } - - uploadURL := makeURL(sess, fileURL+pathPrefix+"photo_original/upload", map[string]any{ - "type": typeParam, - "params": encParams, - }, true) - - // Build multipart body - body, contentType, err := buildMultipartBody("chunkContent", fileName, data) - if err != nil { - return nil, fmt.Errorf("zalo_personal: build multipart: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body) - if err != nil { - return nil, err - } - setDefaultHeaders(req, sess) - req.Header.Set("Content-Type", contentType) - - resp, err := sess.Client.Do(req) - if err != nil { - return nil, fmt.Errorf("zalo_personal: upload image: %w", err) - } - defer resp.Body.Close() - - // Response: {"error_code":0, "data":""} - var envelope Response[*string] - if err := readJSON(resp, &envelope); err != nil { - return nil, fmt.Errorf("zalo_personal: parse upload response: %w", err) - } - if envelope.ErrorCode != 0 { - return nil, fmt.Errorf("zalo_personal: upload error code %d", envelope.ErrorCode) - } - if envelope.Data == nil { - return nil, fmt.Errorf("zalo_personal: empty upload response") - } - - plain, err := decryptDataField(sess, *envelope.Data) - if err != nil { - return nil, fmt.Errorf("zalo_personal: decrypt upload response: %w", err) - } - - var result ImageUploadResult - if err := json.Unmarshal(plain, &result); err != nil { - return nil, fmt.Errorf("zalo_personal: parse upload result: %w", err) - } - - result.Width = width - result.Height = height - result.TotalSize = totalSize - return &result, nil -} - -// SendImage sends a previously uploaded image as a message. -func SendImage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, upload *ImageUploadResult, caption string) (string, error) { - fileURL := getServiceURL(sess, "file") - if fileURL == "" { - return "", fmt.Errorf("zalo_personal: no file service URL") - } - - params := map[string]any{ - "photoId": upload.PhotoID, - "clientId": strconv.FormatInt(time.Now().UnixMilli(), 10), - "desc": caption, - "width": upload.Width, - "height": upload.Height, - "rawUrl": upload.NormalURL, - "hdUrl": upload.HDUrl, - "thumbUrl": upload.ThumbURL, - "hdSize": strconv.Itoa(upload.TotalSize), - "zsource": -1, - "ttl": 0, - "jcp": `{"convertible":"jxl"}`, - } - if threadType == ThreadTypeGroup { - params["grid"] = threadID - params["oriUrl"] = upload.NormalURL - } else { - params["toid"] = threadID - params["normalUrl"] = upload.NormalURL - } - - encData, err := encryptPayload(sess, params) - if err != nil { - return "", fmt.Errorf("zalo_personal: encrypt image send params: %w", err) - } - - pathPrefix := "/api/message/" - if threadType == ThreadTypeGroup { - pathPrefix = "/api/group/" - } - - sendURL := makeURL(sess, fileURL+pathPrefix+"photo_original/send", map[string]any{"nretry": 0}, true) - form := buildFormBody(map[string]string{"params": encData}) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form) - if err != nil { - return "", err - } - setDefaultHeaders(req, sess) - - resp, err := sess.Client.Do(req) - if err != nil { - return "", fmt.Errorf("zalo_personal: send image: %w", err) - } - defer resp.Body.Close() - - var envelope Response[*string] - if err := readJSON(resp, &envelope); err != nil { - return "", fmt.Errorf("zalo_personal: parse image send response: %w", err) - } - if envelope.ErrorCode != 0 { - return "", fmt.Errorf("zalo_personal: image send error code %d", envelope.ErrorCode) - } - if envelope.Data == nil { - return "", nil - } - - plain, err := decryptDataField(sess, *envelope.Data) - if err != nil { - return "", fmt.Errorf("zalo_personal: decrypt image send response: %w", err) - } - - var result struct { - MsgID json.Number `json:"msgId"` - } - if err := json.Unmarshal(plain, &result); err != nil { - return "", fmt.Errorf("zalo_personal: parse image send result: %w", err) - } - return result.MsgID.String(), nil -} - -// buildMultipartBody creates a multipart/form-data body with a single file field. -func buildMultipartBody(fieldName, fileName string, data []byte) (io.Reader, string, error) { - var buf bytes.Buffer - w := multipart.NewWriter(&buf) - - boundary := "----GoClaw" + randomBoundary() - if err := w.SetBoundary(boundary); err != nil { - return nil, "", err - } - - part, err := w.CreateFormFile(fieldName, fileName) - if err != nil { - return nil, "", err - } - if _, err := part.Write(data); err != nil { - return nil, "", err - } - if err := w.Close(); err != nil { - return nil, "", err - } - - return &buf, w.FormDataContentType(), nil -} - -func randomBoundary() string { - b := make([]byte, 8) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -// imageDimensions extracts width and height from an image (PNG, JPEG, etc.). -// Returns (0, 0) if the format is unrecognized. -func imageDimensions(data []byte) (int, int) { - cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) - if err != nil { - return 0, 0 - } - return cfg.Width, cfg.Height -} - -// IsImageFile returns true if the file extension is a supported image type. -func IsImageFile(filePath string) bool { - ext := strings.ToLower(filepath.Ext(filePath)) - switch ext { - case ".jpg", ".jpeg", ".png", ".webp": - return true - } - return false -} - -// FileUploadResult holds the response from uploading a file to Zalo's file service. -type FileUploadResult struct { - FileID string `json:"fileId"` - FileURL string `json:"fileUrl"` // populated from WS callback - ClientFileID json.Number `json:"clientFileId"` // Zalo may return string or number - ChunkID int `json:"chunkId"` - Finished int `json:"finished"` - - // Set by caller. - TotalSize int `json:"-"` - FileName string `json:"-"` - Checksum string `json:"-"` // MD5 hex -} - -// UploadFile uploads a non-image file to Zalo's file service. -// The upload response only contains fileId; the fileUrl arrives via WebSocket callback. -// The caller must provide the Listener so we can register a callback for the fileUrl. -func UploadFile(ctx context.Context, sess *Session, ln *Listener, threadID string, threadType ThreadType, filePath string) (*FileUploadResult, error) { - fileURL := getServiceURL(sess, "file") - if fileURL == "" { - return nil, fmt.Errorf("zalo_personal: no file service URL") - } - - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("zalo_personal: read file: %w", err) - } - - fileName := filepath.Base(filePath) - totalSize := len(data) - clientID := time.Now().UnixMilli() - - uploadParams := map[string]any{ - "totalChunk": 1, - "fileName": fileName, - "clientId": clientID, - "totalSize": totalSize, - "imei": sess.IMEI, - "isE2EE": 0, - "jxl": 0, - "chunkId": 1, - } - if threadType == ThreadTypeGroup { - uploadParams["grid"] = threadID - } else { - uploadParams["toid"] = threadID - } - - encParams, err := encryptPayload(sess, uploadParams) - if err != nil { - return nil, fmt.Errorf("zalo_personal: encrypt file upload params: %w", err) - } - - pathPrefix := "/api/message/" - typeParam := "2" - if threadType == ThreadTypeGroup { - pathPrefix = "/api/group/" - typeParam = "11" - } - - uploadURL := makeURL(sess, fileURL+pathPrefix+"asyncfile/upload", map[string]any{ - "type": typeParam, - "params": encParams, - }, true) - - body, contentType, err := buildMultipartBody("chunkContent", fileName, data) - if err != nil { - return nil, fmt.Errorf("zalo_personal: build multipart: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body) - if err != nil { - return nil, err - } - setDefaultHeaders(req, sess) - req.Header.Set("Content-Type", contentType) - - resp, err := sess.Client.Do(req) - if err != nil { - return nil, fmt.Errorf("zalo_personal: upload file: %w", err) - } - defer resp.Body.Close() - - var envelope Response[*string] - if err := readJSON(resp, &envelope); err != nil { - return nil, fmt.Errorf("zalo_personal: parse file upload response: %w", err) - } - if envelope.ErrorCode != 0 { - return nil, fmt.Errorf("zalo_personal: file upload error code %d", envelope.ErrorCode) - } - if envelope.Data == nil { - return nil, fmt.Errorf("zalo_personal: empty file upload response") - } - - plain, err := decryptDataField(sess, *envelope.Data) - if err != nil { - return nil, fmt.Errorf("zalo_personal: decrypt file upload response: %w", err) - } - - var result FileUploadResult - if err := json.Unmarshal(plain, &result); err != nil { - return nil, fmt.Errorf("zalo_personal: parse file upload result: %w", err) - } - - result.TotalSize = totalSize - result.FileName = fileName - - // Compute MD5 checksum - h := md5Hash(data) - result.Checksum = h - - // Wait for fileUrl from WebSocket callback - if ln != nil && result.FileID != "" && result.FileID != "-1" { - urlCh := ln.RegisterUploadCallback(result.FileID) - select { - case fileURL := <-urlCh: - result.FileURL = fileURL - case <-time.After(30 * time.Second): - ln.CancelUploadCallback(result.FileID) - return nil, fmt.Errorf("zalo_personal: timeout waiting for file upload callback (fileId=%s)", result.FileID) - case <-ctx.Done(): - ln.CancelUploadCallback(result.FileID) - return nil, ctx.Err() - } - } - - return &result, nil -} - -// SendFile sends a previously uploaded file as a message. -func SendFile(ctx context.Context, sess *Session, threadID string, threadType ThreadType, upload *FileUploadResult) (string, error) { - fileURL := getServiceURL(sess, "file") - if fileURL == "" { - return "", fmt.Errorf("zalo_personal: no file service URL") - } - - ext := strings.TrimPrefix(filepath.Ext(upload.FileName), ".") - - params := map[string]any{ - "fileId": upload.FileID, - "checksum": upload.Checksum, - "checksumSha": "", - "extention": ext, // Zalo typo: "extention" not "extension" - "totalSize": upload.TotalSize, - "fileName": upload.FileName, - "clientId": upload.ClientFileID.String(), - "fType": 1, - "fileCount": 0, - "fdata": "{}", - "fileUrl": upload.FileURL, - "zsource": -1, - "ttl": 0, - } - if threadType == ThreadTypeGroup { - params["grid"] = threadID - } else { - params["toid"] = threadID - } - - encData, err := encryptPayload(sess, params) - if err != nil { - return "", fmt.Errorf("zalo_personal: encrypt file send params: %w", err) - } - - pathPrefix := "/api/message/" - if threadType == ThreadTypeGroup { - pathPrefix = "/api/group/" - } - - sendURL := makeURL(sess, fileURL+pathPrefix+"asyncfile/msg", map[string]any{"nretry": 0}, true) - form := buildFormBody(map[string]string{"params": encData}) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form) - if err != nil { - return "", err - } - setDefaultHeaders(req, sess) - - resp, err := sess.Client.Do(req) - if err != nil { - return "", fmt.Errorf("zalo_personal: send file: %w", err) - } - defer resp.Body.Close() - - var respEnvelope Response[*string] - if err := readJSON(resp, &respEnvelope); err != nil { - return "", fmt.Errorf("zalo_personal: parse file send response: %w", err) - } - if respEnvelope.ErrorCode != 0 { - return "", fmt.Errorf("zalo_personal: file send error code %d", respEnvelope.ErrorCode) - } - if respEnvelope.Data == nil { - return "", nil - } - - plain, err := decryptDataField(sess, *respEnvelope.Data) - if err != nil { - return "", fmt.Errorf("zalo_personal: decrypt file send response: %w", err) - } - - var result struct { - MsgID json.Number `json:"msgId"` - } - if err := json.Unmarshal(plain, &result); err != nil { - return "", fmt.Errorf("zalo_personal: parse file send result: %w", err) - } - return result.MsgID.String(), nil -} - -// md5Hash returns the MD5 hex digest. Required by Zalo's file upload API checksum field. -func md5Hash(data []byte) string { - h := md5.Sum(data) - return hex.EncodeToString(h[:]) -} - // encryptPayload encrypts a JSON payload with the session's secret key via AES-CBC. func encryptPayload(sess *Session, payload map[string]any) (string, error) { blob, err := json.Marshal(payload) diff --git a/internal/channels/zalo/personal/protocol/send_file.go b/internal/channels/zalo/personal/protocol/send_file.go new file mode 100644 index 00000000..9f83080d --- /dev/null +++ b/internal/channels/zalo/personal/protocol/send_file.go @@ -0,0 +1,232 @@ +package protocol + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// FileUploadResult holds the response from uploading a file to Zalo's file service. +type FileUploadResult struct { + FileID string `json:"fileId"` + FileURL string `json:"fileUrl"` // populated from WS callback + ClientFileID json.Number `json:"clientFileId"` // Zalo may return string or number + ChunkID int `json:"chunkId"` + Finished int `json:"finished"` + + // Set by caller. + TotalSize int `json:"-"` + FileName string `json:"-"` + Checksum string `json:"-"` // MD5 hex +} + +// UploadFile uploads a non-image file to Zalo's file service. +// The upload response only contains fileId; the fileUrl arrives via WebSocket callback. +// The caller must provide the Listener so we can register a callback for the fileUrl. +func UploadFile(ctx context.Context, sess *Session, ln *Listener, threadID string, threadType ThreadType, filePath string) (*FileUploadResult, error) { + fileURL := getServiceURL(sess, "file") + if fileURL == "" { + return nil, fmt.Errorf("zalo_personal: no file service URL") + } + + if err := checkFileSize(filePath); err != nil { + return nil, err + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("zalo_personal: read file: %w", err) + } + + fileName := filepath.Base(filePath) + totalSize := len(data) + clientID := time.Now().UnixMilli() + + uploadParams := map[string]any{ + "totalChunk": 1, + "fileName": fileName, + "clientId": clientID, + "totalSize": totalSize, + "imei": sess.IMEI, + "isE2EE": 0, + "jxl": 0, + "chunkId": 1, + } + if threadType == ThreadTypeGroup { + uploadParams["grid"] = threadID + } else { + uploadParams["toid"] = threadID + } + + encParams, err := encryptPayload(sess, uploadParams) + if err != nil { + return nil, fmt.Errorf("zalo_personal: encrypt file upload params: %w", err) + } + + pathPrefix := "/api/message/" + typeParam := "2" + if threadType == ThreadTypeGroup { + pathPrefix = "/api/group/" + typeParam = "11" + } + + uploadURL := makeURL(sess, fileURL+pathPrefix+"asyncfile/upload", map[string]any{ + "type": typeParam, + "params": encParams, + }, true) + + body, contentType, err := buildMultipartBody("chunkContent", fileName, data) + if err != nil { + return nil, fmt.Errorf("zalo_personal: build multipart: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body) + if err != nil { + return nil, err + } + setDefaultHeaders(req, sess) + req.Header.Set("Content-Type", contentType) + + resp, err := sess.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("zalo_personal: upload file: %w", err) + } + defer resp.Body.Close() + + var envelope Response[*string] + if err := readJSON(resp, &envelope); err != nil { + return nil, fmt.Errorf("zalo_personal: parse file upload response: %w", err) + } + if envelope.ErrorCode != 0 { + return nil, fmt.Errorf("zalo_personal: file upload error code %d", envelope.ErrorCode) + } + if envelope.Data == nil { + return nil, fmt.Errorf("zalo_personal: empty file upload response") + } + + plain, err := decryptDataField(sess, *envelope.Data) + if err != nil { + return nil, fmt.Errorf("zalo_personal: decrypt file upload response: %w", err) + } + + var result FileUploadResult + if err := json.Unmarshal(plain, &result); err != nil { + return nil, fmt.Errorf("zalo_personal: parse file upload result: %w", err) + } + + result.TotalSize = totalSize + result.FileName = fileName + + // Compute MD5 checksum + h := md5Hash(data) + result.Checksum = h + + // Wait for fileUrl from WebSocket callback + if ln != nil && result.FileID != "" && result.FileID != "-1" { + urlCh := ln.RegisterUploadCallback(result.FileID) + select { + case fileURL := <-urlCh: + result.FileURL = fileURL + case <-time.After(30 * time.Second): + ln.CancelUploadCallback(result.FileID) + return nil, fmt.Errorf("zalo_personal: timeout waiting for file upload callback (fileId=%s)", result.FileID) + case <-ctx.Done(): + ln.CancelUploadCallback(result.FileID) + return nil, ctx.Err() + } + } + + return &result, nil +} + +// SendFile sends a previously uploaded file as a message. +func SendFile(ctx context.Context, sess *Session, threadID string, threadType ThreadType, upload *FileUploadResult) (string, error) { + fileURL := getServiceURL(sess, "file") + if fileURL == "" { + return "", fmt.Errorf("zalo_personal: no file service URL") + } + + ext := strings.TrimPrefix(filepath.Ext(upload.FileName), ".") + + params := map[string]any{ + "fileId": upload.FileID, + "checksum": upload.Checksum, + "checksumSha": "", + "extention": ext, // Zalo typo: "extention" not "extension" + "totalSize": upload.TotalSize, + "fileName": upload.FileName, + "clientId": upload.ClientFileID.String(), + "fType": 1, + "fileCount": 0, + "fdata": "{}", + "fileUrl": upload.FileURL, + "zsource": -1, + "ttl": 0, + } + if threadType == ThreadTypeGroup { + params["grid"] = threadID + } else { + params["toid"] = threadID + } + + encData, err := encryptPayload(sess, params) + if err != nil { + return "", fmt.Errorf("zalo_personal: encrypt file send params: %w", err) + } + + pathPrefix := "/api/message/" + if threadType == ThreadTypeGroup { + pathPrefix = "/api/group/" + } + + sendURL := makeURL(sess, fileURL+pathPrefix+"asyncfile/msg", map[string]any{"nretry": 0}, true) + form := buildFormBody(map[string]string{"params": encData}) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form) + if err != nil { + return "", err + } + setDefaultHeaders(req, sess) + + resp, err := sess.Client.Do(req) + if err != nil { + return "", fmt.Errorf("zalo_personal: send file: %w", err) + } + defer resp.Body.Close() + + var respEnvelope Response[*string] + if err := readJSON(resp, &respEnvelope); err != nil { + return "", fmt.Errorf("zalo_personal: parse file send response: %w", err) + } + if respEnvelope.ErrorCode != 0 { + return "", fmt.Errorf("zalo_personal: file send error code %d", respEnvelope.ErrorCode) + } + if respEnvelope.Data == nil { + return "", nil + } + + plain, err := decryptDataField(sess, *respEnvelope.Data) + if err != nil { + return "", fmt.Errorf("zalo_personal: decrypt file send response: %w", err) + } + + var result struct { + MsgID json.Number `json:"msgId"` + } + if err := json.Unmarshal(plain, &result); err != nil { + return "", fmt.Errorf("zalo_personal: parse file send result: %w", err) + } + return result.MsgID.String(), nil +} + +// md5Hash returns the MD5 hex digest. Required by Zalo's file upload API checksum field. +func md5Hash(data []byte) string { + h := md5.Sum(data) + return hex.EncodeToString(h[:]) +} diff --git a/internal/channels/zalo/personal/protocol/send_helpers.go b/internal/channels/zalo/personal/protocol/send_helpers.go new file mode 100644 index 00000000..f50d1994 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/send_helpers.go @@ -0,0 +1,69 @@ +package protocol + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "os" +) + +// maxUploadSize is the maximum file size for Zalo uploads (25 MB). +const maxUploadSize = 25 * 1024 * 1024 + +// checkFileSize validates that the file exists and is within the upload size limit. +func checkFileSize(filePath string) error { + fi, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("zalo_personal: stat file: %w", err) + } + if fi.Size() > maxUploadSize { + return fmt.Errorf("zalo_personal: file too large: %d bytes (max %d)", fi.Size(), maxUploadSize) + } + return nil +} + +// FlexBool handles JSON fields that may be bool (true/false) or number (0/1). +type FlexBool bool + +func (b *FlexBool) UnmarshalJSON(data []byte) error { + switch string(data) { + case "true", "1": + *b = true + default: + *b = false + } + return nil +} + +// buildMultipartBody creates a multipart/form-data body with a single file field. +func buildMultipartBody(fieldName, fileName string, data []byte) (io.Reader, string, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + boundary := "----GoClaw" + randomBoundary() + if err := w.SetBoundary(boundary); err != nil { + return nil, "", err + } + + part, err := w.CreateFormFile(fieldName, fileName) + if err != nil { + return nil, "", err + } + if _, err := part.Write(data); err != nil { + return nil, "", err + } + if err := w.Close(); err != nil { + return nil, "", err + } + + return &buf, w.FormDataContentType(), nil +} + +func randomBoundary() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/channels/zalo/personal/protocol/send_image.go b/internal/channels/zalo/personal/protocol/send_image.go new file mode 100644 index 00000000..eaad8d65 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/send_image.go @@ -0,0 +1,233 @@ +package protocol + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// ImageUploadResult holds the response from uploading an image to Zalo's file service. +type ImageUploadResult struct { + NormalURL string `json:"normalUrl"` + HDUrl string `json:"hdUrl"` + ThumbURL string `json:"thumbUrl"` + PhotoID json.Number `json:"photoId"` // Zalo may return string or number + ClientFileID json.Number `json:"clientFileId"` // Zalo may return string or number + ChunkID int `json:"chunkId"` + Finished FlexBool `json:"finished"` // Zalo returns bool or int depending on endpoint + + // Set by caller (not from API response). + Width int `json:"-"` + Height int `json:"-"` + TotalSize int `json:"-"` +} + +// UploadImage uploads an image file to Zalo's file service. +// Returns upload metadata needed for SendImage. +func UploadImage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, filePath string) (*ImageUploadResult, error) { + fileURL := getServiceURL(sess, "file") + if fileURL == "" { + return nil, fmt.Errorf("zalo_personal: no file service URL") + } + + if err := checkFileSize(filePath); err != nil { + return nil, err + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("zalo_personal: read image: %w", err) + } + + fileName := filepath.Base(filePath) + totalSize := len(data) + width, height := imageDimensions(data) + + uploadParams := map[string]any{ + "totalChunk": 1, + "fileName": fileName, + "clientId": time.Now().UnixMilli(), + "totalSize": totalSize, + "imei": sess.IMEI, + "isE2EE": 0, + "jxl": 0, + "chunkId": 1, + } + if threadType == ThreadTypeGroup { + uploadParams["grid"] = threadID + } else { + uploadParams["toid"] = threadID + } + + encParams, err := encryptPayload(sess, uploadParams) + if err != nil { + return nil, fmt.Errorf("zalo_personal: encrypt upload params: %w", err) + } + + // Build URL: file_service/api/{message|group}/photo_original/upload + pathPrefix := "/api/message/" + typeParam := "2" + if threadType == ThreadTypeGroup { + pathPrefix = "/api/group/" + typeParam = "11" + } + + uploadURL := makeURL(sess, fileURL+pathPrefix+"photo_original/upload", map[string]any{ + "type": typeParam, + "params": encParams, + }, true) + + // Build multipart body + body, contentType, err := buildMultipartBody("chunkContent", fileName, data) + if err != nil { + return nil, fmt.Errorf("zalo_personal: build multipart: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body) + if err != nil { + return nil, err + } + setDefaultHeaders(req, sess) + req.Header.Set("Content-Type", contentType) + + resp, err := sess.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("zalo_personal: upload image: %w", err) + } + defer resp.Body.Close() + + // Response: {"error_code":0, "data":""} + var envelope Response[*string] + if err := readJSON(resp, &envelope); err != nil { + return nil, fmt.Errorf("zalo_personal: parse upload response: %w", err) + } + if envelope.ErrorCode != 0 { + return nil, fmt.Errorf("zalo_personal: upload error code %d", envelope.ErrorCode) + } + if envelope.Data == nil { + return nil, fmt.Errorf("zalo_personal: empty upload response") + } + + plain, err := decryptDataField(sess, *envelope.Data) + if err != nil { + return nil, fmt.Errorf("zalo_personal: decrypt upload response: %w", err) + } + + var result ImageUploadResult + if err := json.Unmarshal(plain, &result); err != nil { + return nil, fmt.Errorf("zalo_personal: parse upload result: %w", err) + } + + result.Width = width + result.Height = height + result.TotalSize = totalSize + return &result, nil +} + +// SendImage sends a previously uploaded image as a message. +func SendImage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, upload *ImageUploadResult, caption string) (string, error) { + fileURL := getServiceURL(sess, "file") + if fileURL == "" { + return "", fmt.Errorf("zalo_personal: no file service URL") + } + + params := map[string]any{ + "photoId": upload.PhotoID, + "clientId": strconv.FormatInt(time.Now().UnixMilli(), 10), + "desc": caption, + "width": upload.Width, + "height": upload.Height, + "rawUrl": upload.NormalURL, + "hdUrl": upload.HDUrl, + "thumbUrl": upload.ThumbURL, + "hdSize": strconv.Itoa(upload.TotalSize), + "zsource": -1, + "ttl": 0, + "jcp": `{"convertible":"jxl"}`, + } + if threadType == ThreadTypeGroup { + params["grid"] = threadID + params["oriUrl"] = upload.NormalURL + } else { + params["toid"] = threadID + params["normalUrl"] = upload.NormalURL + } + + encData, err := encryptPayload(sess, params) + if err != nil { + return "", fmt.Errorf("zalo_personal: encrypt image send params: %w", err) + } + + pathPrefix := "/api/message/" + if threadType == ThreadTypeGroup { + pathPrefix = "/api/group/" + } + + sendURL := makeURL(sess, fileURL+pathPrefix+"photo_original/send", map[string]any{"nretry": 0}, true) + form := buildFormBody(map[string]string{"params": encData}) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form) + if err != nil { + return "", err + } + setDefaultHeaders(req, sess) + + resp, err := sess.Client.Do(req) + if err != nil { + return "", fmt.Errorf("zalo_personal: send image: %w", err) + } + defer resp.Body.Close() + + var envelope Response[*string] + if err := readJSON(resp, &envelope); err != nil { + return "", fmt.Errorf("zalo_personal: parse image send response: %w", err) + } + if envelope.ErrorCode != 0 { + return "", fmt.Errorf("zalo_personal: image send error code %d", envelope.ErrorCode) + } + if envelope.Data == nil { + return "", nil + } + + plain, err := decryptDataField(sess, *envelope.Data) + if err != nil { + return "", fmt.Errorf("zalo_personal: decrypt image send response: %w", err) + } + + var result struct { + MsgID json.Number `json:"msgId"` + } + if err := json.Unmarshal(plain, &result); err != nil { + return "", fmt.Errorf("zalo_personal: parse image send result: %w", err) + } + return result.MsgID.String(), nil +} + +// imageDimensions extracts width and height from an image (PNG, JPEG, etc.). +// Returns (0, 0) if the format is unrecognized. +func imageDimensions(data []byte) (int, int) { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return 0, 0 + } + return cfg.Width, cfg.Height +} + +// IsImageFile returns true if the file extension is a supported image type. +func IsImageFile(filePath string) bool { + ext := strings.ToLower(filepath.Ext(filePath)) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + return true + } + return false +} diff --git a/internal/tools/message.go b/internal/tools/message.go index 8b4dc722..8cda910d 100644 --- a/internal/tools/message.go +++ b/internal/tools/message.go @@ -138,6 +138,7 @@ func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath s } // parseMediaPath extracts a file path from a "MEDIA:/path/to/file" string. +// Only allows absolute paths within os.TempDir() to prevent path traversal. func parseMediaPath(s string) (string, bool) { s = strings.TrimSpace(s) if !strings.HasPrefix(s, "MEDIA:") { @@ -147,5 +148,13 @@ func parseMediaPath(s string) (string, bool) { if path == "" || path == "." { return "", false } + if !filepath.IsAbs(path) { + return "", false + } + // Restrict to temp directory to prevent path traversal. + tmpDir := filepath.Clean(os.TempDir()) + if !strings.HasPrefix(path, tmpDir+string(filepath.Separator)) && path != tmpDir { + return "", false + } return path, true } diff --git a/internal/tools/message_test.go b/internal/tools/message_test.go new file mode 100644 index 00000000..91506eb7 --- /dev/null +++ b/internal/tools/message_test.go @@ -0,0 +1,43 @@ +package tools + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseMediaPath(t *testing.T) { + tmpDir := os.TempDir() + + tests := []struct { + name string + input string + want string + wantOK bool + }{ + {"valid temp file", "MEDIA:" + filepath.Join(tmpDir, "test.png"), filepath.Join(tmpDir, "test.png"), true}, + {"valid nested temp file", "MEDIA:" + filepath.Join(tmpDir, "sub", "file.txt"), filepath.Join(tmpDir, "sub", "file.txt"), true}, + {"with spaces around prefix", " MEDIA:" + filepath.Join(tmpDir, "test.png") + " ", filepath.Join(tmpDir, "test.png"), true}, + {"no prefix", filepath.Join(tmpDir, "test.png"), "", false}, + {"empty after prefix", "MEDIA:", "", false}, + {"relative path", "MEDIA:relative/path.txt", "", false}, + {"dot path", "MEDIA:.", "", false}, + {"outside temp dir", "MEDIA:/etc/passwd", "", false}, + {"traversal attack", "MEDIA:" + filepath.Join(tmpDir, "..", "etc", "passwd"), "", false}, + {"home directory", "MEDIA:" + filepath.Join(os.Getenv("HOME"), "secret.txt"), "", false}, + {"empty string", "", "", false}, + {"just MEDIA", "MEDIA", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseMediaPath(tt.input) + if ok != tt.wantOK { + t.Errorf("parseMediaPath(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + if got != tt.want { + t.Errorf("parseMediaPath(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/tests/zalo_e2e/main.go b/tests/zalo_e2e/main.go index 263c78fb..6d59d85f 100644 --- a/tests/zalo_e2e/main.go +++ b/tests/zalo_e2e/main.go @@ -89,7 +89,7 @@ func main() { if err != nil { log.Fatalf("upload image: %v", err) } - log.Printf("Upload OK: photoId=%d normalUrl=%s", upload.PhotoID, truncate(upload.NormalURL, 60)) + log.Printf("Upload OK: photoId=%s normalUrl=%s", upload.PhotoID.String(), truncate(upload.NormalURL, 60)) imgMsgID, err := protocol.SendImage(ctx, sess, groupID, protocol.ThreadTypeGroup, upload, "Go E2E test image") if err != nil {