Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
2cf34b019a fix(email): sanitize invitation Subject and lock behavior with tests
Follow-up to #1126 (which closed the HTML-injection vector in the Body).

The Subject line is not HTML-rendered, so html.EscapeString would leak
literal entities into recipient inboxes. Instead:

- Strip control characters from workspace/inviter names (defense in depth
  even though Resend also filters CR/LF).
- Cap each field at 60 runes so an attacker can't stuff a full phishing
  pitch into a workspace name that gets sent from noreply@multica.ai.

Also extracts buildInvitationParams to make the sanitization logic
testable without mocking the Resend SDK, and adds a test covering:
  - HTML escape behavior for script/attribute/anchor injection payloads
  - Subject stripping of \r\n\t and other unicode controls
  - Subject NOT being HTML-escaped (so "Acme & Co." stays literal)
  - Subject length bounds
  - Benign inputs pass through unchanged

Adds a note on SendVerificationCode that its body uses only
server-generated content, to prevent the same pitfall from creeping in.

Refs #1117
2026-04-16 13:00:22 +08:00
2 changed files with 229 additions and 6 deletions

View File

@@ -5,10 +5,17 @@ import (
"html"
"os"
"strings"
"unicode"
"unicode/utf8"
"github.com/resend/resend-go/v2"
)
// maxSubjectFieldRunes bounds how much user-controlled text (workspace name,
// inviter name) can land in an email Subject. Prevents attackers from stuffing
// a full phishing pitch into a workspace name that gets sent from our domain.
const maxSubjectFieldRunes = 60
type EmailService struct {
client *resend.Client
fromEmail string
@@ -32,6 +39,10 @@ func NewEmailService() *EmailService {
}
}
// SendVerificationCode sends a one-time login code. The code is server-generated
// (6-digit numeric) so no user-controlled text reaches the email body here.
// If that ever changes, escape the user-controlled fields the same way
// SendInvitationEmail does.
func (s *EmailService) SendVerificationCode(to, code string) error {
if s.client == nil {
fmt.Printf("[DEV] Verification code for %s: %s\n", to, code)
@@ -69,13 +80,24 @@ func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName, invit
return nil
}
params := buildInvitationParams(s.fromEmail, to, inviterName, workspaceName, inviteURL)
_, err := s.client.Emails.Send(params)
return err
}
// buildInvitationParams assembles the Resend request for an invitation email.
// Separated from SendInvitationEmail so the sanitization behavior is unit-testable
// without needing to mock the Resend SDK.
func buildInvitationParams(from, to, inviterName, workspaceName, inviteURL string) *resend.SendEmailRequest {
safeWorkspace := html.EscapeString(workspaceName)
safeInviter := html.EscapeString(inviterName)
subjectInviter := sanitizeSubjectField(inviterName)
subjectWorkspace := sanitizeSubjectField(workspaceName)
params := &resend.SendEmailRequest{
From: s.fromEmail,
return &resend.SendEmailRequest{
From: from,
To: []string{to},
Subject: fmt.Sprintf("%s invited you to %s on Multica", inviterName, workspaceName),
Subject: fmt.Sprintf("%s invited you to %s on Multica", subjectInviter, subjectWorkspace),
Html: fmt.Sprintf(
`<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
<h2>You're invited to join %s</h2>
@@ -86,7 +108,27 @@ func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName, invit
<p style="color: #666; font-size: 14px;">You'll need to log in to accept or decline the invitation.</p>
</div>`, safeWorkspace, safeInviter, safeWorkspace, inviteURL),
}
_, err := s.client.Emails.Send(params)
return err
}
// sanitizeSubjectField prepares user-controlled text for the email Subject line.
// Subject is not HTML-rendered, so HTML-escaping would leak literal entities
// (e.g. &lt;script&gt;) into the recipient's inbox. Instead strip control
// characters (defense in depth against header-injection-adjacent abuse even
// though Resend also filters CR/LF) and cap length so attackers can't stuff
// a full phishing subject into a workspace name.
func sanitizeSubjectField(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if unicode.IsControl(r) {
continue
}
b.WriteRune(r)
}
cleaned := b.String()
if utf8.RuneCountInString(cleaned) <= maxSubjectFieldRunes {
return cleaned
}
runes := []rune(cleaned)
return string(runes[:maxSubjectFieldRunes-1]) + "…"
}

View File

@@ -0,0 +1,181 @@
package service
import (
"strings"
"testing"
)
func TestSanitizeSubjectField(t *testing.T) {
long := strings.Repeat("a", 100)
longRunes := strings.Repeat("深", 100)
tests := []struct {
name string
in string
want string
}{
{"plain ascii", "Acme", "Acme"},
{"strips newline", "Acme\nEvil", "AcmeEvil"},
{"strips crlf header-style", "Acme\r\nBcc: evil@example.com", "AcmeBcc: evil@example.com"},
{"strips tab", "Acme\tTeam", "AcmeTeam"},
{"strips unicode control", "Acme\x07Beep", "AcmeBeep"},
{"preserves non-ascii", "深度学习工作区", "深度学习工作区"},
{"preserves emoji", "Team 🚀", "Team 🚀"},
{"truncates long ascii", long, strings.Repeat("a", maxSubjectFieldRunes-1) + "…"},
{"truncates rune-aware", longRunes, strings.Repeat("深", maxSubjectFieldRunes-1) + "…"},
{"empty stays empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeSubjectField(tt.in)
if got != tt.want {
t.Errorf("sanitizeSubjectField(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestBuildInvitationParams_EscapesHTMLInBody(t *testing.T) {
tests := []struct {
name string
inviter string
workspace string
wantInBody []string
wantNotInBody []string
}{
{
name: "escapes script tag in inviter",
inviter: "<script>alert(1)</script>",
workspace: "Acme",
wantInBody: []string{
"&lt;script&gt;alert(1)&lt;/script&gt;",
},
wantNotInBody: []string{
"<script>alert(1)</script>",
},
},
{
name: "escapes attribute-break payload in inviter",
inviter: `Alice" onclick="evil()`,
workspace: "Acme",
wantNotInBody: []string{
`Alice" onclick="evil()`,
},
},
{
name: "escapes anchor tag in workspace",
inviter: "Alice",
workspace: `<a href="https://evil.example">Click</a>`,
wantInBody: []string{
"&lt;a href=",
"&gt;Click&lt;/a&gt;",
},
wantNotInBody: []string{
`<a href="https://evil.example">Click</a>`,
},
},
{
name: "benign text unchanged",
inviter: "Alice",
workspace: "Acme",
wantInBody: []string{
"Alice",
"Acme",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
tt.inviter,
tt.workspace,
"https://app.multica.ai/invite/abc-123",
)
for _, needle := range tt.wantInBody {
if !strings.Contains(p.Html, needle) {
t.Errorf("body missing %q\nbody: %s", needle, p.Html)
}
}
for _, needle := range tt.wantNotInBody {
if strings.Contains(p.Html, needle) {
t.Errorf("body should not contain raw %q\nbody: %s", needle, p.Html)
}
}
})
}
}
func TestBuildInvitationParams_SubjectStripsControls(t *testing.T) {
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
"Alice\r\n",
"Acme\t",
"https://app.multica.ai/invite/abc",
)
if strings.ContainsAny(p.Subject, "\r\n\t") {
t.Errorf("subject still contains control characters: %q", p.Subject)
}
if p.Subject != "Alice invited you to Acme on Multica" {
t.Errorf("unexpected subject: %q", p.Subject)
}
}
func TestBuildInvitationParams_SubjectNotHTMLEscaped(t *testing.T) {
// Subject is not HTML-rendered; entities would render literally in inboxes.
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
"Alice",
"Acme & Co.",
"https://app.multica.ai/invite/abc",
)
if strings.Contains(p.Subject, "&amp;") {
t.Errorf("subject should not be HTML-escaped, got %q", p.Subject)
}
if !strings.Contains(p.Subject, "Acme & Co.") {
t.Errorf("subject missing literal ampersand: %q", p.Subject)
}
}
func TestBuildInvitationParams_SubjectTruncated(t *testing.T) {
longWorkspace := strings.Repeat("A", 200)
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
"Alice",
longWorkspace,
"https://app.multica.ai/invite/abc",
)
// Template: "Alice invited you to <ws> on Multica"
// ws is capped at maxSubjectFieldRunes; overall subject should also be bounded.
maxExpected := len("Alice invited you to on Multica") + maxSubjectFieldRunes
if runes := len([]rune(p.Subject)); runes > maxExpected {
t.Errorf("subject not bounded: %d runes, max %d: %q", runes, maxExpected, p.Subject)
}
if !strings.Contains(p.Subject, "…") {
t.Errorf("truncated subject should contain ellipsis marker: %q", p.Subject)
}
}
func TestBuildInvitationParams_ToAndFromPassedThrough(t *testing.T) {
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
"Alice",
"Acme",
"https://app.multica.ai/invite/abc",
)
if p.From != "noreply@multica.ai" {
t.Errorf("From = %q", p.From)
}
if len(p.To) != 1 || p.To[0] != "invitee@example.com" {
t.Errorf("To = %v", p.To)
}
if !strings.Contains(p.Html, "https://app.multica.ai/invite/abc") {
t.Errorf("body missing invite URL: %s", p.Html)
}
}