Skip to content

sec(email): stop broadcasting approval tokens via SNS (closes #1015) - #1036

Merged
cristim merged 4 commits into
feat/multicloud-web-frontendfrom
sec/email-approval-token-sns
Jun 7, 2026
Merged

sec(email): stop broadcasting approval tokens via SNS (closes #1015)#1036
cristim merged 4 commits into
feat/multicloud-web-frontendfrom
sec/email-approval-token-sns

Conversation

@cristim

@cristim cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member

Summary

  • SendScheduledPurchaseNotification and SendRIExchangePendingApproval on the SES/SNS Sender previously routed token-bearing bodies through SendNotification (SNS broadcast), leaking working approve/pause/cancel/reject links to every topic subscriber.
  • Both methods now require RecipientEmail and route through SendToEmailWithCC (targeted SES), exactly mirroring the already-hardened SendPurchaseApprovalRequest path.
  • A structural guard in SendNotification rejects any body containing token= with ErrTokenInBroadcast, preventing future regressions mechanically.
  • RIExchangeNotificationData gains RecipientEmail/CCEmails fields; call sites resolve GlobalConfig.NotificationEmail and pass it as the recipient.

Closes #1015

Changes

  • internal/email/sender.go: add ErrTokenInBroadcast sentinel; add guard in SendNotification; add RecipientEmail/CCEmails to RIExchangeNotificationData
  • internal/email/templates.go: fix SendScheduledPurchaseNotification and SendRIExchangePendingApproval to gate on RecipientEmail and route via SendToEmailWithCC
  • internal/purchase/notifications.go: fetch GlobalConfig.NotificationEmail in sendPlanNotification and pass it as RecipientEmail; log warning and skip when unconfigured
  • internal/server/handler_ri_exchange.go: pass cfg.NotificationEmail through sendExchangeNotification to buildExchangeNotificationData
  • 8 new regression tests: guard rejects token bodies, guard passes clean bodies, both senders use SES not SNS, both senders return ErrNoRecipient when recipient absent

Test plan

  • go build ./... clean
  • go vet ./internal/email/... clean
  • go test ./internal/email/... ./internal/purchase/... ./internal/server/... 848 passed
  • Pre-existing 2 auth failures confirmed identical on base branch (unrelated)
  • Regression tests fail on pre-fix code (verified by re-reading the old implementation paths)
  • New TestSendNotification_RejectsTokenBearingBody: guard blocks all token= patterns
  • New TestSendScheduledPurchaseNotification_UsesSESNotSNS: SNS mock has no expected calls, SES mock receives the send
  • New TestSendRIExchangePendingApproval_UsesSESNotSNS: same pattern for RI exchange
  • New TestSend*_ErrNoRecipientWhenEmpty: both methods return ErrNoRecipient with empty RecipientEmail

Summary by CodeRabbit

  • Bug Fixes

    • Added security protection to prevent approval tokens from being exposed via broadcast messaging.
    • Changed approval-required notifications (scheduled purchases, RI exchanges) to route to a specific recipient instead of broadcast, preventing unintended token exposure.
    • System now respects configured notification email address for routing approval messages.
  • Tests

    • Added comprehensive test coverage for notification routing and security validations.

Scope expanded

Additional findings from report 07 folded into this PR (issue #1077):

07-H1 (template_renderers.go): Plain-text email bodies were rendered with html/template, HTML-encoding &, ', <, > in data fields. O'Brien appeared as O&#39;Brien; a&b@x.com as a&amp;b@x.com. Added renderTextTemplate (backed by text/template) and routed all non-HTML renderers through it. HTML renderers retain html/template for XSS safety. Regression tests assert verbatim survival of special characters in plain-text bodies and continued escaping in HTML bodies.

07-H2 (smtp_sender.go): dispatchSMTP now refuses to send SMTP AUTH over a non-TLS connection (auth != nil && !useTLS && !allowInsecure). An AllowInsecure escape hatch (test-only) is added to SMTPConfig and SMTPSender; factory constructors never set it. Updated integration tests that used plaintext auth stubs to pass allowInsecure: true. Added TestSMTPSender_DispatchSMTP_RefusesAuthOverCleartext regression.

07-M3 (sender.go): SendNotification passes the SNS subject through sanitizeHeader (strips CR/LF) and truncates to 100 bytes (SNS hard limit) before Publish. A PlanName with a newline would previously cause a runtime InvalidParameter. Regression tests cover newline stripping and byte truncation.

07-M2 (sender.go): ensureSandboxRecipientVerified error messages retain the full address in the user-facing string (needed for the recipient to know which inbox to verify) but drop the gratuitous address duplication in the second error. Doc comments clarify the PII/usability tradeoff.

07-M1 (sender.go): Doc comments on buildSESSendEmailInput{Multipart} note that the SES v2 structured API is not CRLF-injectable; any future raw-MIME path must call sanitizeHeader.

07-L2 (factory.go): Replaced hand-rolled containsColon loop with strings.Contains. Deleted the helper.

07-L3 (factory.go): Replaced the weak length+colon/slash heuristic in warnIfPlaintext with an explicit isSecretManagerReference check (prefixes arn:, projects/, contains .vault.azure.net/, prefix /). A 25-char password with a colon no longer silently skips the warning.

07-N2 (smtp_sender.go): buildSMTPMessageMultipart now generates a per-message random MIME boundary via crypto/rand instead of the fixed literal, eliminating the theoretical body-collision risk.

07-N4 (smtp_sender.go): Expanded SMTPSender.SendNotification doc comment to explain that GCP/Azure deployments receive no broadcast notifications (SMTP has no pub/sub).

Also closes #1077

…through targeted SES, not SNS broadcast (closes #1015)

SendScheduledPurchaseNotification and SendRIExchangePendingApproval on the
SES/SNS Sender previously rendered bodies that embed live approve/pause/cancel
tokens and shipped them via SendNotification (SNS broadcast). Every topic
subscriber received working action links, matching the exact threat the
purchase-approval path was hardened against in sender.go:386-391 and
templates.go:698-704.

Fix:
- Both methods now require data.RecipientEmail and route through
  SendToEmailWithCC (targeted SES), mirroring SendPurchaseApprovalRequest.
  Return ErrNoRecipient when the field is empty rather than silently
  broadcasting.
- Add RecipientEmail/CCEmails fields to RIExchangeNotificationData.
- Update call sites (purchase/notifications.go, handler_ri_exchange.go) to
  resolve GlobalConfig.NotificationEmail and pass it as RecipientEmail.
  When the global email is unconfigured, log a warning and skip the
  notification rather than broadcast.
- Add structural guard in SendNotification: reject any message body that
  contains "token=" with ErrTokenInBroadcast, preventing future regressions
  regardless of how a token-bearing body reaches the broadcast path.
- Add 8 regression tests: guard rejects token bodies, guard allows clean
  bodies, both methods use SES not SNS when RecipientEmail is set, both
  methods return ErrNoRecipient when RecipientEmail is absent.
@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/s Hours type/security Security finding labels Jun 7, 2026
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 15 minutes and 58 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9c5626b-8a4f-4051-a0cc-8a416f3d2cdf

📥 Commits

Reviewing files that changed from the base of the PR and between 17c4d83 and 51796db.

📒 Files selected for processing (12)
  • internal/email/coverage_extra_test.go
  • internal/email/coverage_test.go
  • internal/email/factory.go
  • internal/email/sender.go
  • internal/email/sender_test.go
  • internal/email/smtp_sender.go
  • internal/email/smtp_server_test.go
  • internal/email/template_renderers.go
  • internal/email/template_renderers_test.go
  • internal/email/templates_test.go
  • internal/server/handler_ri_exchange.go
  • internal/server/handler_ri_exchange_test.go
📝 Walkthrough

Walkthrough

This PR hardens email notification security by preventing approval token broadcast via SNS. Scheduled purchase and RI exchange notifications with token-bearing approval links now route through targeted SES delivery to a configured recipient, with a broadcast guard rejecting token-containing messages and integration of global notification email configuration.

Changes

Approval Token Broadcast Hardening

Layer / File(s) Summary
Token broadcast safety guard
internal/email/sender.go, internal/email/sender_test.go
ErrTokenInBroadcast sentinel and SendNotification message guard reject SNS broadcasts containing token= to prevent token leakage; tests verify rejection and token-free broadcast success.
Recipient routing data contract
internal/email/sender.go
RIExchangeNotificationData gains RecipientEmail and CCEmails fields for targeted delivery instead of broadcast routing.
Scheduled purchase targeted delivery
internal/email/templates.go, internal/email/templates_test.go, internal/email/sender_test.go
SendScheduledPurchaseNotification routes via SendToEmailWithCC when recipient set, returns ErrNoRecipient when empty; test coverage replaces SNS assertions with SES mocks and targeted delivery validation.
RI exchange pending-approval targeted delivery
internal/email/templates.go, internal/email/sender_test.go, internal/email/coverage_extra_test.go, internal/email/coverage_test.go
SendRIExchangePendingApproval routes via SendToEmailWithCC when recipient set, returns ErrNoRecipient when empty; comprehensive test coverage for SES routing and error handling.
Scheduled purchase notification email configuration
internal/purchase/notifications.go, internal/purchase/notifications_test.go
sendPlanNotification resolves NotificationEmail from global config, skips with warning if unset; buildNotificationData accepts email parameter and populates RecipientEmail in notification data.
RI exchange notification email configuration
internal/server/handler_ri_exchange.go, internal/server/handler_ri_exchange_test.go
executeRIExchangeReshape derives notifyEmail from config, threads through sendExchangeNotification and buildExchangeNotificationData to populate recipient routing on notification data.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

priority/p1, severity/high, impact/many, effort/m

Poem

🐰 Tokens took a risky broadcast flight,
Through SNS subscribers' hands—oh what a sight!
Now guarded by SES, with care and precision,
Each approval link finds its rightful decision.
No more leakage in the cloudy night! 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main security fix: preventing approval tokens from being broadcast via SNS. It references the issue it closes.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from issue #1015: token-bearing messages now route through SES instead of SNS, ErrNoRecipient is returned when no recipient is configured, SendNotification rejects token= bodies, and call sites resolve and pass RecipientEmail.
Out of Scope Changes check ✅ Passed All changes are directly related to the stated objective of preventing approval token broadcast via SNS. No unrelated modifications were introduced.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/email-approval-token-sns

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
internal/email/templates_test.go (1)

49-49: 💤 Low value

Remove no-op cleanup function.

t.Cleanup(func() {}) does nothing and appears to be leftover from a refactor. The actual mock assertions cleanup is on line 55.

🧹 Suggested fix
-	t.Cleanup(func() {})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/email/templates_test.go` at line 49, Remove the no-op test cleanup
call by deleting the stray t.Cleanup(func() {}) invocation in the test; rely on
the existing mock assertion/cleanup logic (e.g., the mock expectations teardown
already present) instead so there is no empty cleanup function left in
templates_test.go.
internal/email/sender.go (1)

116-118: ⚡ Quick win

Consider case-insensitive token detection for defense-in-depth.

The guard currently uses strings.Contains(message, "token=") which is case-sensitive. While all templates use lowercase "token=", making this check case-insensitive would provide additional safety against future template changes or edge cases.

🔒 Proposed case-insensitive guard
-	if strings.Contains(message, "token=") {
+	if strings.Contains(strings.ToLower(message), "token=") {
 		return ErrTokenInBroadcast
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/email/sender.go` around lines 116 - 118, The current guard uses a
case-sensitive check strings.Contains(message, "token=") which can miss
mixed-case tokens; update the check in internal/email/sender.go so the message
is checked case-insensitively (e.g., normalize message to lowercase or use a
case-insensitive comparison) and still return ErrTokenInBroadcast when found;
ensure you update the same guard that references ErrTokenInBroadcast and any
associated unit tests that rely on this behavior.
internal/server/handler_ri_exchange_test.go (1)

102-102: ⚡ Quick win

Consider adding test coverage for configured NotificationEmail.

All tests pass an empty string "" for the notifyEmail parameter. While this verifies the "no recipient configured" path, there's no test coverage for the happy path where cfg.NotificationEmail is actually set to a valid email address.

Consider adding a test case (optional, not blocking) that verifies:

  • When cfg.NotificationEmail is set to a valid email (e.g., "admin@example.com")
  • executeRIExchangeReshape derives and passes that email through
  • For manual mode with pending exchanges, the email is successfully routed to SendRIExchangePendingApproval with RecipientEmail populated

This would provide end-to-end confidence that the notification email configuration flows correctly through the handler layer.

Also applies to: 124-124, 255-255, 270-270, 294-294, 318-318, 341-341

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/handler_ri_exchange_test.go` at line 102, Add a test that
sets cfg.NotificationEmail to a valid address (e.g., "admin@example.com")
instead of passing "" into buildExchangeNotificationData, invoke
executeRIExchangeReshape in the manual/pending-exchange scenario, and assert
that SendRIExchangePendingApproval is called with a Notification/RecipientEmail
field populated accordingly; specifically, update the test around
buildExchangeNotificationData usage to supply the configured email, exercise
executeRIExchangeReshape, and verify the stub/mock for
SendRIExchangePendingApproval received RecipientEmail matching
cfg.NotificationEmail.
internal/server/handler_ri_exchange.go (1)

143-167: Fix RI exchange completion: RecipientEmail is ignored; completion uses SNS broadcast

  • SendRIExchangeCompleted ignores RIExchangeNotificationData.RecipientEmail and always calls s.SendNotification(...) (SNS broadcast path). The completed email template contains no token= links, so it won’t trip the ErrTokenInBroadcast guard.
  • Delivery target is driven by the transport: SES/SNS goes to the SNS topic; SMTP goes to the SMTP sender’s configured notifyEmail (also independent of data.RecipientEmail).
  • The only path that must use RecipientEmail is SendRIExchangePendingApproval, which returns ErrNoRecipient when RecipientEmail is empty.
Suggested clean-up if you want `RecipientEmail` to match the struct contract
-	data := buildExchangeNotificationData(result, app.appConfig.DashboardURL, notifyEmail)
+	data := buildExchangeNotificationData(result, app.appConfig.DashboardURL, "")

	var err error
	if result.Mode == "manual" && len(result.Pending) > 0 {
+		data.RecipientEmail = notifyEmail
		err = app.Email.SendRIExchangePendingApproval(ctx, data)
	} else if len(result.Completed)+len(result.Failed) > 0 {
		err = app.Email.SendRIExchangeCompleted(ctx, data)
	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/handler_ri_exchange.go` around lines 143 - 167, The current
sendExchangeNotification sets RecipientEmail for all notifications but
SendRIExchangeCompleted ignores it and always broadcasts via SNS; to fix, only
populate RIExchangeNotificationData.RecipientEmail when calling
SendRIExchangePendingApproval and ensure for the completed path you clear or
omit RecipientEmail so SendRIExchangeCompleted behaves as intended (SNS
broadcast/transport-driven delivery). Update sendExchangeNotification (and
buildExchangeNotificationData usage) so RecipientEmail is only set for the
manual pending-approval branch and not for the completed/failed branch; if you
need per-recipient completed emails instead, modify SendRIExchangeCompleted to
accept/respect RecipientEmail in RIExchangeNotificationData.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/email/sender.go`:
- Around line 116-118: The current guard uses a case-sensitive check
strings.Contains(message, "token=") which can miss mixed-case tokens; update the
check in internal/email/sender.go so the message is checked case-insensitively
(e.g., normalize message to lowercase or use a case-insensitive comparison) and
still return ErrTokenInBroadcast when found; ensure you update the same guard
that references ErrTokenInBroadcast and any associated unit tests that rely on
this behavior.

In `@internal/email/templates_test.go`:
- Line 49: Remove the no-op test cleanup call by deleting the stray
t.Cleanup(func() {}) invocation in the test; rely on the existing mock
assertion/cleanup logic (e.g., the mock expectations teardown already present)
instead so there is no empty cleanup function left in templates_test.go.

In `@internal/server/handler_ri_exchange_test.go`:
- Line 102: Add a test that sets cfg.NotificationEmail to a valid address (e.g.,
"admin@example.com") instead of passing "" into buildExchangeNotificationData,
invoke executeRIExchangeReshape in the manual/pending-exchange scenario, and
assert that SendRIExchangePendingApproval is called with a
Notification/RecipientEmail field populated accordingly; specifically, update
the test around buildExchangeNotificationData usage to supply the configured
email, exercise executeRIExchangeReshape, and verify the stub/mock for
SendRIExchangePendingApproval received RecipientEmail matching
cfg.NotificationEmail.

In `@internal/server/handler_ri_exchange.go`:
- Around line 143-167: The current sendExchangeNotification sets RecipientEmail
for all notifications but SendRIExchangeCompleted ignores it and always
broadcasts via SNS; to fix, only populate
RIExchangeNotificationData.RecipientEmail when calling
SendRIExchangePendingApproval and ensure for the completed path you clear or
omit RecipientEmail so SendRIExchangeCompleted behaves as intended (SNS
broadcast/transport-driven delivery). Update sendExchangeNotification (and
buildExchangeNotificationData usage) so RecipientEmail is only set for the
manual pending-approval branch and not for the completed/failed branch; if you
need per-recipient completed emails instead, modify SendRIExchangeCompleted to
accept/respect RecipientEmail in RIExchangeNotificationData.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92d2e872-e638-4260-be2b-d1f4e162b494

📥 Commits

Reviewing files that changed from the base of the PR and between cbdc4be and 17c4d83.

📒 Files selected for processing (10)
  • internal/email/coverage_extra_test.go
  • internal/email/coverage_test.go
  • internal/email/sender.go
  • internal/email/sender_test.go
  • internal/email/templates.go
  • internal/email/templates_test.go
  • internal/purchase/notifications.go
  • internal/purchase/notifications_test.go
  • internal/server/handler_ri_exchange.go
  • internal/server/handler_ri_exchange_test.go

…pient contract

Address CodeRabbit pass-1 nitpicks on PR #1036:

- sender.go: make the SNS token-broadcast guard case-insensitive so a future
  template that capitalizes the query param (Token=, TOKEN=) cannot slip a
  token-bearing body past the guard. Add mixed-case regression cases to
  TestSendNotification_RejectsTokenBearingBody (fails pre-fix, passes post-fix).
- handler_ri_exchange.go: only populate RIExchangeNotificationData.RecipientEmail
  for the pending-approval branch. The completed/failed path is transport-driven
  (SNS broadcast / SMTP notifyEmail) and ignores RecipientEmail, so setting it
  there misrepresented the struct contract. Update the buildExchangeNotificationData
  doc comment accordingly.
- handler_ri_exchange_test.go: add coverage for a configured NotificationEmail,
  asserting the pending branch receives RecipientEmail populated and the completed
  branch leaves it empty.
- templates_test.go: drop the leftover no-op t.Cleanup(func() {}).
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Addressed CR pass-1 nitpicks in 4a9d9fdf3:

  • templates_test.go:49 no-op t.Cleanup(func() {}) -> removed.
  • sender.go:116 case-sensitive token guard -> now case-insensitive (strings.ToLower); added mixed-case (Token=, TOKEN=) regression cases to TestSendNotification_RejectsTokenBearingBody (stash-verified: fails pre-fix, passes post-fix).
  • handler_ri_exchange.go:143-167 RecipientEmail set for both branches -> now populated only for the pending-approval branch; completed/failed path leaves it empty (transport-driven). Doc comment for buildExchangeNotificationData updated to match.
  • handler_ri_exchange_test.go configured-NotificationEmail coverage -> added TestSendExchangeNotification_PendingSetsRecipientEmail (asserts pending gets the address) and TestSendExchangeNotification_CompletedOmitsRecipientEmail (asserts completed leaves it empty).

gofmt/vet/build clean; 659 tests pass across internal/email + internal/server.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

@cristim All four pass-1 items look well addressed — particularly the case-insensitive token guard with stash-verified regression cases and the precise scoping of RecipientEmail to the pending-approval branch only. Triggering a fresh incremental review now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 2 commits June 7, 2026 04:43
… over non-TLS (07-H1, 07-H2)

Plain-text email renderers were using html/template which HTML-encodes
special characters in data fields -- "O'Brien" rendered as "O&#39;Brien"
and "a&b@x.com" as "a&amp;b@x.com" in recipients' inboxes (07-H1).

Add renderTextTemplate backed by text/template and route all non-HTML
renderers through it. HTML renderers retain html/template for XSS safety.
Regression tests assert that &, ', and < survive verbatim in plain-text
bodies and that html/template still escapes <script> in HTML bodies.

Also guard dispatchSMTP against cleartext credential transmission (07-H2):
if SMTP auth is configured but UseTLS is false the call is refused unless
AllowInsecure is explicitly set. AllowInsecure is test-only (local SMTP
stubs); factory constructors all use UseTLS=true and never set it. Update
smtp_server_test.go tests that used plaintext auth stubs to set AllowInsecure,
and add TestSMTPSender_DispatchSMTP_RefusesAuthOverCleartext regression.

Also: per-message random MIME boundary via crypto/rand (07-N2); doc note on
SMTP SendNotification no-op for GCP/Azure broadcast (07-N4).

Closes #1077 (07-H1, 07-H2, 07-N2, 07-N4)
…xt heuristic (07-M1--M3, 07-L2--L3)

07-M3: SendNotification now passes the subject through sanitizeHeader
(strips CR/LF to prevent SNS InvalidParameter) and truncates to 100 bytes
(SNS hard limit) before Publish. A PlanName containing a newline would
previously cause a runtime InvalidParameter error. Regression tests cover
newline stripping and byte truncation.

07-M2: ensureSandboxRecipientVerified user-facing error retains the raw
recipient address (needed for the recipient to know which inbox to verify)
but the second fmt.Errorf no longer duplicates the address in a gratuitous
"sent to %s" suffix. Doc comments clarify the PII-vs-usability tradeoff.

07-M1: doc comments on buildSESSendEmailInput{Multipart} note that SES v2
structured API is not CRLF-injectable via Subject.Data; any future raw-MIME
path must call sanitizeHeader before header composition.

07-L2: replace hand-rolled containsColon loop with strings.Contains.
Delete containsColon helper.

07-L3: replace the weak length+colon/slash heuristic in warnIfPlaintext with
an explicit isSecretManagerReference check (HasPrefix "arn:", "projects/",
Contains ".vault.azure.net/", HasPrefix "/"). A 25-char password with a colon
no longer silently skips the warning. Update coverage_extra_test.go to test
isSecretManagerReference instead of the deleted containsColon.

Closes #1077 (07-M1, 07-M2, 07-M3, 07-L2, 07-L3)
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

FOLD-1036 additions (07-H1, 07-H2, 07-M1--M3, 07-L2--L3, 07-N2, 07-N4) -- issue #1077

Two commits added on top of the C1 fix (commits 439bb3b, 51796db):

commit 439bb3b -- fix(email): use text/template for plain-text bodies, refuse SMTP auth over non-TLS

  • 07-H1: Added renderTextTemplate (backed by text/template) in template_renderers.go; routed all 10 non-HTML renderers through it. HTML renderers (*EmailHTML) remain on html/template. Regression tests in template_renderers_test.go: TestPlainTextTemplates_NoHTMLEscaping asserts &, ', < survive verbatim in 4 plain-text email types and that html/template still escapes <script> in HTML bodies.
  • 07-H2: dispatchSMTP in smtp_sender.go now returns an error if auth != nil && !useTLS && !allowInsecure. AllowInsecure bool added to SMTPConfig/SMTPSender (test-only; factory constructors never set it). Updated 2 existing plaintext-stub tests to set allowInsecure: true. New: TestSMTPSender_DispatchSMTP_RefusesAuthOverCleartext and TestSMTPSender_DispatchSMTP_AllowsAuthWithAllowInsecure.
  • 07-N2: buildSMTPMessageMultipart now generates a per-message random MIME boundary via crypto/rand.Read + hex.EncodeToString, with a fixed-literal fallback on rand failure.
  • 07-N4: Expanded SMTPSender.SendNotification doc comment to document that GCP/Azure deployments receive no broadcast notifications.

commit 51796db -- fix(email): SNS subject sanitize+truncate, sandbox PII, warnIfPlaintext heuristic

  • 07-M3: SendNotification in sender.go applies sanitizeHeader + byte-truncation to 100 chars before SNS Publish. Regression tests: TestSendNotification_SubjectSanitizedAndTruncated (newline stripped; long subject capped to snsMaxSubjectLen).
  • 07-M2: ensureSandboxRecipientVerified user-facing error retains the full recipient address (needed for the user to identify the inbox) but drops the duplicated address in the second format string. Doc comments clarify the PII/usability tradeoff.
  • 07-M1: Doc comments on buildSESSendEmailInput{Multipart} note that SES v2 structured API is not CRLF-injectable; any future raw-MIME path must call sanitizeHeader.
  • 07-L2: Deleted containsColon; replaced call site with strings.Contains(value, ":").
  • 07-L3: Replaced weak heuristic in warnIfPlaintext with isSecretManagerReference (checks arn: prefix, projects/ prefix, .vault.azure.net/ substring, / prefix). Updated coverage_extra_test.go to test the new function.

Build/vet/test: 334 passed in ./internal/email/..., 527 passed in ./internal/server/... ./internal/purchase/.... go build ./... clean.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

@cristim Great summary — the defence-in-depth layering here (text/template XSS isolation, TLS-gated SMTP auth, random MIME boundary, SNS header sanitisation + truncation, tightened isSecretManagerReference heuristic) looks solid. Triggering a fresh incremental review over commits 439bb3b and 51796db now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit acb3a17 into feat/multicloud-web-frontend Jun 7, 2026
4 checks passed
cristim added a commit that referenced this pull request Jun 8, 2026
#296)

Mirror the purchaseApprovalRequestHTMLTemplate pattern for the
SendRIExchangePendingApproval flow:

- Add riExchangePendingApprovalHTMLTemplate: inline-styled table of
  exchanges with per-row Approve/Reject CTA buttons, summary block,
  optional requested-by line, configurable cancellation-window note,
  and skipped-exchanges section.
- Extend riExchangePendingApprovalTemplate (plaintext): labelled
  Approve/Reject URLs (replacing bracket notation), optional
  requested-by block, configurable cancellation-window note.
- Extend RIExchangeNotificationData with RequestedByName,
  RequestedByEmail, RequestedAt, CancellationWindowNote fields
  alongside the RecipientEmail/CCEmails targeting fields.
- Add RenderRIExchangePendingApprovalEmailHTML renderer.
- Add sendRIExchangePendingApprovalVia helper (shared by Sender and
  SMTPSender, non-fatal HTML degrade on render error), now CC-aware.
- Sender.SendRIExchangePendingApproval: requires RecipientEmail and
  routes through targeted SES multipart (SendToEmailWithCCMultipart),
  never broadcasting approval tokens via the SNS topic.
- SMTPSender.SendRIExchangePendingApproval: resolves RecipientEmail
  (falling back to the static notify address), fails loud with
  ErrNoRecipient when neither is configured, then sends multipart.
- 7 new test cases covering both halves, empty/custom cancellation
  notes, missing requested-by, and skipped-exchanges rendering.

Rebased onto feat/multicloud-web-frontend: reconciled with the #1015 /
#1036 hardening that stops broadcasting approval tokens via SNS. The
multipart send now preserves that fail-loud, targeted-SES contract
instead of falling back to the SNS broadcast topic. Updated the SMTP
no-recipient coverage test to assert ErrNoRecipient accordingly.
cristim added a commit that referenced this pull request Jun 19, 2026
#296)

Mirror the purchaseApprovalRequestHTMLTemplate pattern for the
SendRIExchangePendingApproval flow:

- Add riExchangePendingApprovalHTMLTemplate: inline-styled table of
  exchanges with per-row Approve/Reject CTA buttons, summary block,
  optional requested-by line, configurable cancellation-window note,
  and skipped-exchanges section.
- Extend riExchangePendingApprovalTemplate (plaintext): labelled
  Approve/Reject URLs (replacing bracket notation), optional
  requested-by block, configurable cancellation-window note.
- Extend RIExchangeNotificationData with RequestedByName,
  RequestedByEmail, RequestedAt, CancellationWindowNote fields
  alongside the RecipientEmail/CCEmails targeting fields.
- Add RenderRIExchangePendingApprovalEmailHTML renderer.
- Add sendRIExchangePendingApprovalVia helper (shared by Sender and
  SMTPSender, non-fatal HTML degrade on render error), now CC-aware.
- Sender.SendRIExchangePendingApproval: requires RecipientEmail and
  routes through targeted SES multipart (SendToEmailWithCCMultipart),
  never broadcasting approval tokens via the SNS topic.
- SMTPSender.SendRIExchangePendingApproval: resolves RecipientEmail
  (falling back to the static notify address), fails loud with
  ErrNoRecipient when neither is configured, then sends multipart.
- 7 new test cases covering both halves, empty/custom cancellation
  notes, missing requested-by, and skipped-exchanges rendering.

Rebased onto feat/multicloud-web-frontend: reconciled with the #1015 /
#1036 hardening that stops broadcasting approval tokens via SNS. The
multipart send now preserves that fail-loud, targeted-SES contract
instead of falling back to the SNS broadcast topic. Updated the SMTP
no-recipient coverage test to assert ErrNoRecipient accordingly.
cristim added a commit that referenced this pull request Jul 10, 2026
#296)

Mirror the purchaseApprovalRequestHTMLTemplate pattern for the
SendRIExchangePendingApproval flow:

- Add riExchangePendingApprovalHTMLTemplate: inline-styled table of
  exchanges with per-row Approve/Reject CTA buttons, summary block,
  optional requested-by line, configurable cancellation-window note,
  and skipped-exchanges section.
- Extend riExchangePendingApprovalTemplate (plaintext): labelled
  Approve/Reject URLs (replacing bracket notation), optional
  requested-by block, configurable cancellation-window note.
- Extend RIExchangeNotificationData with RequestedByName,
  RequestedByEmail, RequestedAt, CancellationWindowNote fields
  alongside the RecipientEmail/CCEmails targeting fields.
- Add RenderRIExchangePendingApprovalEmailHTML renderer.
- Add sendRIExchangePendingApprovalVia helper (shared by Sender and
  SMTPSender, non-fatal HTML degrade on render error), now CC-aware.
- Sender.SendRIExchangePendingApproval: requires RecipientEmail and
  routes through targeted SES multipart (SendToEmailWithCCMultipart),
  never broadcasting approval tokens via the SNS topic.
- SMTPSender.SendRIExchangePendingApproval: resolves RecipientEmail
  (falling back to the static notify address), fails loud with
  ErrNoRecipient when neither is configured, then sends multipart.
- 7 new test cases covering both halves, empty/custom cancellation
  notes, missing requested-by, and skipped-exchanges rendering.

Rebased onto feat/multicloud-web-frontend: reconciled with the #1015 /
#1036 hardening that stops broadcasting approval tokens via SNS. The
multipart send now preserves that fail-loud, targeted-SES contract
instead of falling back to the SNS broadcast topic. Updated the SMTP
no-recipient coverage test to assert ErrNoRecipient accordingly.
@cristim
cristim deleted the sec/email-approval-token-sns branch July 27, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant