@@ -0,0 +1,466 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
const PackageMagic = "YMHUTFB1"
|
||||
|
||||
var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`)
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
type submissionPayload struct {
|
||||
FeedbackCode string `json:"feedbackCode"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Contact string `json:"contact"`
|
||||
BodyLength int `json:"bodyLength"`
|
||||
PackageEncrypted bool `json:"packageEncrypted"`
|
||||
Encryption string `json:"encryption"`
|
||||
PackageBytes int64 `json:"packageBytes"`
|
||||
PackageSha256 string `json:"packageSha256"`
|
||||
PlainPackageBytes int64 `json:"plainPackageBytes"`
|
||||
PlainPackageSha256 string `json:"plainPackageSha256"`
|
||||
CreatedAt json.RawMessage `json:"createdAt"`
|
||||
}
|
||||
|
||||
type packageInfo struct {
|
||||
Request map[string]any
|
||||
Summary string
|
||||
Files []string
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config, store *db.Store) *Service {
|
||||
return &Service{cfg: cfg, store: store}
|
||||
}
|
||||
|
||||
func (s *Service) Submit(r *http.Request) (db.Feedback, error) {
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
if item, err := s.submitMultipart(r); err == nil {
|
||||
return item, nil
|
||||
} else if hasSignedFields(r) {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
}
|
||||
return s.submitSimple(r)
|
||||
}
|
||||
|
||||
func (s *Service) submitSimple(r *http.Request) (db.Feedback, error) {
|
||||
if strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
item := db.Feedback{
|
||||
Code: db.NewFeedbackCode(),
|
||||
Title: value(payload, "title", "客户端反馈"),
|
||||
Type: value(payload, "type", "issue"),
|
||||
Severity: value(payload, "severity", "normal"),
|
||||
Contact: value(payload, "contact", ""),
|
||||
Body: value(payload, "body", value(payload, "message", "")),
|
||||
Status: "new",
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
}
|
||||
if strings.TrimSpace(item.Body) == "" {
|
||||
item.Body = "No feedback body provided."
|
||||
}
|
||||
return item, s.store.InsertFeedback(item)
|
||||
}
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
_ = r.ParseForm()
|
||||
}
|
||||
item := db.Feedback{
|
||||
Code: db.NewFeedbackCode(),
|
||||
Title: firstNonEmpty(r.FormValue("title"), r.FormValue("subject"), "客户端反馈"),
|
||||
Type: firstNonEmpty(r.FormValue("type"), r.FormValue("category"), "issue"),
|
||||
Severity: firstNonEmpty(r.FormValue("severity"), r.FormValue("priority"), "normal"),
|
||||
Contact: firstNonEmpty(r.FormValue("contact"), r.FormValue("email")),
|
||||
Body: firstNonEmpty(r.FormValue("body"), r.FormValue("message"), r.FormValue("description")),
|
||||
Status: "new",
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
}
|
||||
if strings.TrimSpace(item.Body) == "" {
|
||||
item.Body = "No feedback body provided."
|
||||
}
|
||||
return item, s.store.InsertFeedback(item)
|
||||
}
|
||||
|
||||
func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) {
|
||||
if s.cfg.MaxRequestBytes > 0 {
|
||||
if r.ContentLength > s.cfg.MaxRequestBytes {
|
||||
return db.Feedback{}, errors.New("request is too large")
|
||||
}
|
||||
r.Body = http.MaxBytesReader(nilResponseWriter{}, r.Body, s.cfg.MaxRequestBytes)
|
||||
}
|
||||
if err := r.ParseMultipartForm(s.cfg.MaxRequestBytes); err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
payloadText := strings.TrimSpace(r.FormValue("payload"))
|
||||
timestamp := strings.TrimSpace(r.FormValue("timestamp"))
|
||||
nonce := strings.TrimSpace(r.FormValue("nonce"))
|
||||
packageSha256 := strings.ToLower(strings.TrimSpace(r.FormValue("packageSha256")))
|
||||
signature := strings.ToLower(strings.TrimSpace(r.FormValue("signature")))
|
||||
if payloadText == "" || timestamp == "" || nonce == "" || packageSha256 == "" || signature == "" {
|
||||
return db.Feedback{}, errors.New("signed multipart fields are required")
|
||||
}
|
||||
if !validTimestamp(timestamp, s.cfg.TimestampWindowSeconds) {
|
||||
return db.Feedback{}, errors.New("timestamp outside accepted window")
|
||||
}
|
||||
if !isHexSHA256(packageSha256) {
|
||||
return db.Feedback{}, errors.New("invalid package hash")
|
||||
}
|
||||
expected := SignWithKey(s.cfg.ClientSignatureKey, timestamp, nonce, packageSha256, payloadText)
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return db.Feedback{}, errors.New("invalid request signature")
|
||||
}
|
||||
var payload submissionPayload
|
||||
if err := json.Unmarshal([]byte(payloadText), &payload); err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
if err := validatePayload(payload); err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
code := NormalizeCode(payload.FeedbackCode)
|
||||
if code == "" {
|
||||
code = db.NewFeedbackCode()
|
||||
}
|
||||
if existing, err := s.store.GetFeedback(code); err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
file, _, err := r.FormFile("package")
|
||||
if err != nil {
|
||||
return db.Feedback{}, errors.New("missing package file")
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := readUploadedPackage(file, s.cfg.MaxPackageBytes)
|
||||
if err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte(PackageMagic)) {
|
||||
return db.Feedback{}, errors.New("encrypted package format is invalid")
|
||||
}
|
||||
if !hmac.Equal([]byte(sha256Hex(data)), []byte(packageSha256)) {
|
||||
return db.Feedback{}, errors.New("package hash mismatch")
|
||||
}
|
||||
plain, err := DecryptPackage(data, s.cfg.PackageEncryptionKey)
|
||||
if err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
if !isZipBytes(plain) {
|
||||
return db.Feedback{}, errors.New("decrypted package is not a zip")
|
||||
}
|
||||
if payload.PlainPackageSha256 != "" && isHexSHA256(payload.PlainPackageSha256) {
|
||||
if !hmac.Equal([]byte(sha256Hex(plain)), []byte(strings.ToLower(payload.PlainPackageSha256))) {
|
||||
return db.Feedback{}, errors.New("decrypted package hash mismatch")
|
||||
}
|
||||
}
|
||||
info, err := ReadFeedbackPackageWithGuard(plain, s.cfg.UploadGuard)
|
||||
if err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
dir := filepath.Join(s.cfg.StorageDir, "feedback")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
encryptedPath := filepath.Join(dir, code+".ymfb")
|
||||
packagePath := filepath.Join(dir, code+".zip")
|
||||
if err := os.WriteFile(encryptedPath, data, 0o640); err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
if err := os.WriteFile(packagePath, plain, 0o640); err != nil {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
item := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), r.RemoteAddr)
|
||||
return item, s.store.InsertFeedback(item)
|
||||
}
|
||||
|
||||
func hasSignedFields(r *http.Request) bool {
|
||||
if r.MultipartForm == nil {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"payload", "timestamp", "nonce", "packageSha256", "signature"} {
|
||||
if strings.TrimSpace(r.FormValue(key)) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func NormalizeCode(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
if feedbackCodePattern.MatchString(code) {
|
||||
return code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func SignWithKey(key, timestamp, nonce, packageSha256, payload string) string {
|
||||
material := timestamp + "\n" + nonce + "\n" + packageSha256 + "\n" + payload
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
_, _ = mac.Write([]byte(material))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func validTimestamp(value string, windowSeconds int64) bool {
|
||||
if !regexp.MustCompile(`^[0-9]{10,}$`).MatchString(value) {
|
||||
return false
|
||||
}
|
||||
seconds, err := time.ParseDuration(value + "s")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delta := time.Now().Unix() - int64(seconds.Seconds())
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
return delta <= windowSeconds
|
||||
}
|
||||
|
||||
func validatePayload(payload submissionPayload) error {
|
||||
if payload.Title == "" || payload.Type == "" || payload.Severity == "" {
|
||||
return errors.New("payload title, type and severity are required")
|
||||
}
|
||||
if payload.PackageBytes <= 0 || payload.PackageSha256 == "" || payload.PlainPackageSha256 == "" {
|
||||
return errors.New("payload package hashes are required")
|
||||
}
|
||||
if !payload.PackageEncrypted || payload.Encryption != PackageMagic {
|
||||
return errors.New("encrypted package is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readUploadedPackage(file multipart.File, maxBytes int64) ([]byte, error) {
|
||||
limit := maxBytes + 1
|
||||
if limit <= 1 {
|
||||
limit = 10*1024*1024 + 1
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maxBytes > 0 && int64(len(data)) > maxBytes {
|
||||
return nil, errors.New("feedback package is too large")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func DecryptPackage(data []byte, keyMaterial string) ([]byte, error) {
|
||||
if len(data) < len(PackageMagic)+12+16 || !bytes.HasPrefix(data, []byte(PackageMagic)) {
|
||||
return nil, errors.New("encrypted package format is invalid")
|
||||
}
|
||||
if keyMaterial == "" {
|
||||
keyMaterial = "ymhut-box-feedback-package-v1"
|
||||
}
|
||||
key := sha256.Sum256([]byte(keyMaterial))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset := len(PackageMagic)
|
||||
nonce := data[offset : offset+12]
|
||||
offset += 12
|
||||
tag := data[offset : offset+16]
|
||||
offset += 16
|
||||
ciphertext := data[offset:]
|
||||
combined := append(append([]byte{}, ciphertext...), tag...)
|
||||
return gcm.Open(nil, nonce, combined, []byte(PackageMagic))
|
||||
}
|
||||
|
||||
func ReadFeedbackPackageWithGuard(plain []byte, guard config.UploadGuardConfig) (packageInfo, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(plain), int64(len(plain)))
|
||||
if err != nil {
|
||||
return packageInfo{}, err
|
||||
}
|
||||
files := []string{}
|
||||
texts := map[string]string{}
|
||||
var total uint64
|
||||
for _, entry := range reader.File {
|
||||
if entry.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
cleanName, err := safeZipName(entry.Name)
|
||||
if err != nil {
|
||||
return packageInfo{}, err
|
||||
}
|
||||
if len(files)+1 > guard.MaxZipFiles {
|
||||
return packageInfo{}, errors.New("zip contains too many files")
|
||||
}
|
||||
if entry.UncompressedSize64 > uint64(guard.MaxSingleFileBytes) {
|
||||
return packageInfo{}, errors.New("zip entry is too large")
|
||||
}
|
||||
total += entry.UncompressedSize64
|
||||
if total > uint64(guard.MaxDecompressedBytes) {
|
||||
return packageInfo{}, errors.New("zip decompressed size is too large")
|
||||
}
|
||||
if entry.CompressedSize64 == 0 && entry.UncompressedSize64 > 0 {
|
||||
return packageInfo{}, errors.New("zip entry has invalid compression metadata")
|
||||
}
|
||||
if entry.CompressedSize64 > 0 && float64(entry.UncompressedSize64)/float64(entry.CompressedSize64) > guard.MaxCompressionRatio {
|
||||
return packageInfo{}, errors.New("zip compression ratio is suspicious")
|
||||
}
|
||||
files = append(files, cleanName)
|
||||
if cleanName != "feedback.json" && cleanName != "summary.txt" {
|
||||
if !guard.AllowUnexpectedZipFiles && !strings.HasPrefix(cleanName, "attachments/") {
|
||||
return packageInfo{}, errors.New("zip contains unexpected file")
|
||||
}
|
||||
continue
|
||||
}
|
||||
text, err := readZipText(entry, guard.MaxReadableTextBytes)
|
||||
if err != nil {
|
||||
return packageInfo{}, err
|
||||
}
|
||||
texts[cleanName] = text
|
||||
}
|
||||
request := map[string]any{}
|
||||
if raw := texts["feedback.json"]; raw != "" {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
|
||||
if nested, ok := parsed["request"].(map[string]any); ok {
|
||||
request = nested
|
||||
} else {
|
||||
request = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(request) == 0 && texts["feedback.json"] == "" {
|
||||
return packageInfo{}, errors.New("feedback.json is missing")
|
||||
}
|
||||
return packageInfo{Request: request, Summary: texts["summary.txt"], Files: files}, nil
|
||||
}
|
||||
|
||||
func safeZipName(name string) (string, error) {
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.Contains(name, "\x00") || strings.HasPrefix(name, "/") {
|
||||
return "", errors.New("unsafe zip entry name")
|
||||
}
|
||||
clean := path.Clean(name)
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", errors.New("unsafe zip entry path")
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func readZipText(entry *zip.File, maxBytes int64) (string, error) {
|
||||
if int64(entry.UncompressedSize64) > maxBytes {
|
||||
return "", nil
|
||||
}
|
||||
reader, err := entry.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return "", nil
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func buildRecord(code string, payload submissionPayload, info packageInfo, encryptedPath, packagePath, packageSha256, plainPackageSha256, remoteAddr string) db.Feedback {
|
||||
title := firstNonEmpty(textFromMap(info.Request, "title"), payload.Title, "未命名反馈")
|
||||
typ := firstNonEmpty(textFromMap(info.Request, "type"), payload.Type, "issue")
|
||||
severity := firstNonEmpty(textFromMap(info.Request, "severity"), payload.Severity, "normal")
|
||||
contact := firstNonEmpty(textFromMap(info.Request, "contact"), payload.Contact)
|
||||
body := firstNonEmpty(textFromMap(info.Request, "body"), info.Summary)
|
||||
return db.Feedback{
|
||||
Code: code,
|
||||
Title: title,
|
||||
Type: typ,
|
||||
Severity: severity,
|
||||
Contact: contact,
|
||||
Body: body,
|
||||
Status: "new",
|
||||
StatusDetail: "反馈已接收,等待后台处理。",
|
||||
SourceChannel: "winui",
|
||||
PackagePath: packagePath,
|
||||
EncryptedPackagePath: encryptedPath,
|
||||
PackageSha256: packageSha256,
|
||||
PlainPackageSha256: plainPackageSha256,
|
||||
SummaryText: info.Summary,
|
||||
IncludedFiles: strings.Join(info.Files, ", "),
|
||||
RemoteAddr: remoteAddr,
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func value(payload map[string]any, key, fallback string) string {
|
||||
if raw, ok := payload[key].(string); ok && strings.TrimSpace(raw) != "" {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func textFromMap(values map[string]any, key string) string {
|
||||
if value, ok := values[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isHexSHA256(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func isZipBytes(data []byte) bool {
|
||||
return bytes.HasPrefix(data, []byte("PK\x03\x04")) ||
|
||||
bytes.HasPrefix(data, []byte("PK\x05\x06")) ||
|
||||
bytes.HasPrefix(data, []byte("PK\x07\x08"))
|
||||
}
|
||||
|
||||
type nilResponseWriter struct{}
|
||||
|
||||
func (nilResponseWriter) Header() http.Header { return http.Header{} }
|
||||
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
func (nilResponseWriter) WriteHeader(int) {}
|
||||
@@ -0,0 +1,158 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
func TestSignedMultipartSubmission(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := testConfig(root)
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
service := NewService(cfg, store)
|
||||
plain := zipBytes(t, map[string]string{
|
||||
"feedback.json": `{"request":{"title":"Crash on launch","type":"issue","severity":"major","contact":"user@example.com","body":"It crashes."}}`,
|
||||
"summary.txt": "launch failure",
|
||||
})
|
||||
encrypted := encryptPackageForTest(t, plain, cfg.PackageEncryptionKey)
|
||||
encryptedHash := sha256HexTest(encrypted)
|
||||
plainHash := sha256HexTest(plain)
|
||||
timestamp := itoa(int(time.Now().Unix()))
|
||||
payload := `{"feedbackCode":"FB-20260625-ABCDEF","title":"Crash on launch","type":"issue","severity":"major","contact":"user@example.com","bodyLength":11,"packageEncrypted":true,"encryption":"YMHUTFB1","packageBytes":` + itoa(len(encrypted)) + `,"packageSha256":"` + encryptedHash + `","plainPackageBytes":` + itoa(len(plain)) + `,"plainPackageSha256":"` + plainHash + `","createdAt":"2026-06-25T00:00:00Z"}`
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
_ = writer.WriteField("payload", payload)
|
||||
_ = writer.WriteField("timestamp", timestamp)
|
||||
_ = writer.WriteField("nonce", "abc123")
|
||||
_ = writer.WriteField("packageSha256", encryptedHash)
|
||||
_ = writer.WriteField("signature", SignWithKey(cfg.ClientSignatureKey, timestamp, "abc123", encryptedHash, payload))
|
||||
part, err := writer.CreateFormFile("package", "feedback.ymfb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(part, bytes.NewReader(encrypted))
|
||||
_ = writer.Close()
|
||||
req := httptest.NewRequest("POST", "/", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
item, err := service.Submit(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Code != "FB-20260625-ABCDEF" || !strings.Contains(item.IncludedFiles, "feedback.json") {
|
||||
t.Fatalf("unexpected item: %#v", item)
|
||||
}
|
||||
if _, err := store.GetFeedback(item.Code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipPathEscapeRejected(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := testConfig(root)
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
plain := zipBytes(t, map[string]string{"../escape.txt": "bad", "feedback.json": `{}`})
|
||||
if _, err := ReadFeedbackPackageWithGuard(plain, cfg.UploadGuard); err == nil {
|
||||
t.Fatal("expected unsafe zip entry error")
|
||||
}
|
||||
}
|
||||
|
||||
func testConfig(root string) *config.Config {
|
||||
return &config.Config{
|
||||
StorageDir: filepath.Join(root, "storage"),
|
||||
ClientSignatureKey: "ymhut-box-feedback-client-v1",
|
||||
PackageEncryptionKey: "ymhut-box-feedback-package-v1",
|
||||
TimestampWindowSeconds: 600,
|
||||
MaxRequestBytes: 12 << 20,
|
||||
MaxPackageBytes: 10 << 20,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true},
|
||||
}
|
||||
}
|
||||
|
||||
func zipBytes(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
writer := zip.NewWriter(&buf)
|
||||
for name, body := range files {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = entry.Write([]byte(body))
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func encryptPackageForTest(t *testing.T, plain []byte, keyMaterial string) []byte {
|
||||
t.Helper()
|
||||
key := sha256.Sum256([]byte(keyMaterial))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := []byte("123456789012")
|
||||
sealed := gcm.Seal(nil, nonce, plain, []byte(PackageMagic))
|
||||
ciphertext := sealed[:len(sealed)-gcm.Overhead()]
|
||||
tag := sealed[len(sealed)-gcm.Overhead():]
|
||||
out := []byte(PackageMagic)
|
||||
out = append(out, nonce...)
|
||||
out = append(out, tag...)
|
||||
out = append(out, ciphertext...)
|
||||
return out
|
||||
}
|
||||
|
||||
func sha256HexTest(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for value > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
Reference in New Issue
Block a user