From cf08f7ceeb25fc7066c2fcba33afdcc2e0e92a44 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 15 Sep 2026 13:31:45 +0800 Subject: [PATCH] fix: port mailbox validation from im server --- internal/httpapi/api.go | 24 ++++++++++++++- internal/httpapi/mailbox_test.go | 51 ++++++++++++++++++++++++++++++++ internal/httpapi/response.go | 3 ++ internal/store/errors.go | 1 + internal/store/mysql.go | 18 ++++++++++- 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 internal/httpapi/mailbox_test.go diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index cadc0e0..6c4b8eb 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -25,6 +25,11 @@ type API struct { const workbenchEventPublishTimeout = 15 * time.Second +const ( + maxMailboxSubjectLength = 200 + maxMailboxContentLength = 5000 +) + func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher) *API { if publisher == nil { publisher = events.NoopPublisher{} @@ -879,9 +884,14 @@ func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth if !decodeBody(w, r, &body) { return } + subject := strings.TrimSpace(body.Subject) + content := strings.TrimSpace(body.Content) + if ok := validateMailboxSubmission(w, r, subject, content); !ok { + return + } attachments, _ := json.Marshal(body.AttachmentFileIDs) submission, err := a.store.CreateMailboxSubmission(r.Context(), p.UserID, store.MailboxSubmission{ - SubmissionID: "mb_" + randomToken(12), Subject: strings.TrimSpace(body.Subject), Content: strings.TrimSpace(body.Content), + SubmissionID: "mb_" + randomToken(12), Subject: subject, Content: content, Anonymous: body.Anonymous, AttachmentFileIDs: string(attachments), NoticeVersion: strings.TrimSpace(body.NoticeVersion), }, idempotencyKey) if err != nil { @@ -896,6 +906,18 @@ func (a *API) mailboxSubmissions(w http.ResponseWriter, r *http.Request, p *auth writeSuccess(w, r, mailboxSubmissionPayload(*submission)) } +func validateMailboxSubmission(w http.ResponseWriter, r *http.Request, subject, content string) bool { + if len([]rune(subject)) > maxMailboxSubjectLength { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "subject", "max_length": maxMailboxSubjectLength}) + return false + } + if len([]rune(content)) > maxMailboxContentLength { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "content", "max_length": maxMailboxContentLength}) + return false + } + return true +} + func (a *API) publishWorkbenchEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal, module, action, resourceID string, targets []int64, title, summary string, data map[string]any) bool { if a.events == nil { return true diff --git a/internal/httpapi/mailbox_test.go b/internal/httpapi/mailbox_test.go new file mode 100644 index 0000000..3efac6f --- /dev/null +++ b/internal/httpapi/mailbox_test.go @@ -0,0 +1,51 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +func TestValidateMailboxSubmissionRejectsTooLongContent(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil) + rec := httptest.NewRecorder() + + if validateMailboxSubmission(rec, req, "建议", strings.Repeat("a", maxMailboxContentLength+1)) { + t.Fatal("validateMailboxSubmission returned true for too-long content") + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body apiError + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.Reason != reasonValidationFailed { + t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String()) + } + if body.Details["field"] != "content" { + t.Fatalf("details=%v", body.Details) + } +} + +func TestWriteStoreErrorMapsPolicyToValidationFailed(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil) + rec := httptest.NewRecorder() + + writeStoreError(rec, req, store.ErrPolicy) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body apiError + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.Reason != reasonValidationFailed { + t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String()) + } +} diff --git a/internal/httpapi/response.go b/internal/httpapi/response.go index e58b44f..c505105 100644 --- a/internal/httpapi/response.go +++ b/internal/httpapi/response.go @@ -17,6 +17,7 @@ const ( reasonForbidden = "FORBIDDEN" reasonNotFound = "RESOURCE_NOT_FOUND" reasonConflict = "CONFLICT" + reasonValidationFailed = "VALIDATION_FAILED" reasonInternal = "INTERNAL" ) @@ -57,6 +58,8 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil) case errors.Is(err, ErrConflict), errors.Is(err, store.ErrConflict): writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", nil) + case errors.Is(err, store.ErrPolicy): + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil) default: writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil) } diff --git a/internal/store/errors.go b/internal/store/errors.go index 4be978f..d71c2ed 100644 --- a/internal/store/errors.go +++ b/internal/store/errors.go @@ -6,4 +6,5 @@ var ( ErrNotFound = errors.New("resource not found") ErrForbidden = errors.New("forbidden") ErrConflict = errors.New("conflict") + ErrPolicy = errors.New("policy violation") ) diff --git a/internal/store/mysql.go b/internal/store/mysql.go index 2bfda05..34e148c 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -719,8 +719,24 @@ func (s *Store) MailboxConfig(ctx context.Context) (*MailboxConfig, error) { } func (s *Store) CreateMailboxSubmission(ctx context.Context, userID int64, submission MailboxSubmission, idempotencyKey string) (*MailboxSubmission, error) { + cfg, err := s.MailboxConfig(ctx) + if err != nil { + return nil, err + } + submission.Subject = strings.TrimSpace(submission.Subject) + submission.Content = strings.TrimSpace(submission.Content) + if !cfg.Enabled || (submission.Anonymous && !cfg.AllowAnonymous) { + return nil, ErrPolicy + } + maxLength := cfg.MaxLength + if maxLength <= 0 { + maxLength = 5000 + } + if len([]rune(submission.Subject)) > 200 || len([]rune(submission.Content)) > maxLength { + return nil, ErrPolicy + } now := time.Now().UTC().Round(time.Millisecond) - _, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) + _, err = s.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, submission.SubmissionID, userID, submission.Subject, submission.Content, submission.Anonymous, submission.AttachmentFileIDs, submission.NoticeVersion, idempotencyKey, "received", now) if isDuplicate(err) && idempotencyKey != "" { var existing MailboxSubmission