Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ ADMIN_PASSWORD_SECRET=ADMIN_PASSWORD_DEV
ADMIN_PASSWORD_DEV=LocalDev!Pass123
API_KEY_SECRET_ARN=ADMIN_API_KEY_DEV
ADMIN_API_KEY_DEV=cudly-local-dev-api-key-not-for-prod
# Required for signed one-click notification unsubscribe links. Generate a
# unique value for every environment (for example: openssl rand -hex 32).
NOTIFICATION_MUTE_SECRET=
# Production examples (override SECRET_PROVIDER and these):
# ADMIN_PASSWORD_SECRET=arn:aws:secretsmanager:us-east-1:000000000000:secret:cudly-admin-password-PLACEHOLDER
# API_KEY_SECRET_ARN=arn:aws:secretsmanager:us-east-1:000000000000:secret:cudly-api-key-PLACEHOLDER
Expand Down
66 changes: 33 additions & 33 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,12 @@ func (m *mockConfigStore) GetRIUtilizationCache(_ context.Context, _ string, _ i
func (m *mockConfigStore) UpsertRIUtilizationCache(_ context.Context, _ string, _ int, _ []byte, _ time.Time) error {
return nil
}
func (m *mockConfigStore) UpsertNotificationMute(_ context.Context, _, _, _ string) error {
return nil
}
func (m *mockConfigStore) IsNotificationMuted(_ context.Context, _, _ string) (bool, error) {
return false, nil
}

func (m *mockConfigStore) UpdatePurchaseHistoryListing(_ context.Context, _, _, _ string) error {
return nil
Expand Down
143 changes: 143 additions & 0 deletions internal/api/handler_notifications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package api

import (
"context"
"fmt"
"html/template"
"net/url"
"strings"

"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/LeanerCloud/CUDly/pkg/logging"
"github.com/aws/aws-lambda-go/events"
)

// mutePageCSP is the Content-Security-Policy for the unsubscribe confirmation
// page. The page is intentionally minimal (no external scripts or styles), so
// we can lock it down tightly.
const mutePageCSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"

// unsubscribeConfirmTmpl is the HTML confirmation page rendered after a
// successful one-click unsubscribe. No user-supplied data is interpolated via
// {{.}} without html/template escaping, so there is no XSS vector.
var unsubscribeConfirmTmpl = template.Must(template.New("unsub").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unsubscribed</title>
<style>
body{font-family:sans-serif;max-width:480px;margin:80px auto;padding:0 1rem;color:#222}
h1{font-size:1.4rem}
p{line-height:1.6}
</style>
</head>
<body>
<h1>You have been unsubscribed.</h1>
<p>You will no longer receive <strong>{{.ScopeLabel}}</strong> emails at this address.</p>
<p>This preference is saved. You do not need to click again.</p>
</body>
</html>
`))

// scopeLabel returns a human-readable label for a notification scope.
func scopeLabel(scope string) string {
switch scope {
case string(common.ScopePurchaseApprovals):
return "purchase approval request"
case string(common.ScopeRIExchangeApprovals):
return "RI exchange approval request"
default:
return "notification"
}
}

func validateOneClickUnsubscribeBody(req *events.LambdaFunctionURLRequest) error {
if req.RequestContext.HTTP.Method != "POST" {
return nil
}
values, err := url.ParseQuery(req.Body)
if err != nil || len(values) != 1 || len(values["List-Unsubscribe"]) != 1 ||
values.Get("List-Unsubscribe") != "One-Click" {
return NewClientError(400, "invalid one-click unsubscribe body")
}
return nil
}

func unsubscribeRequestParams(req *events.LambdaFunctionURLRequest) (token, email, scope string, err error) {
token = req.QueryStringParameters["token"]
email = req.QueryStringParameters["email"]
scope = req.QueryStringParameters["scope"]
if token == "" || email == "" || scope == "" {
return "", "", "", NewClientError(400, "token, email and scope are required")
}
if scope != string(common.ScopePurchaseApprovals) && scope != string(common.ScopeRIExchangeApprovals) {
return "", "", "", NewClientError(400, fmt.Sprintf("unknown notification scope: %s", scope))
}
return token, email, scope, nil
}

// unsubscribeHandler handles GET and RFC 8058 POST requests to
// /api/notifications/unsubscribe.
// The URL carries a signed token that encodes (email, scope); the handler
// verifies the HMAC, upserts the mute row, and returns a confirmation page.
//
// Auth: AuthPublic (token-based, no login required — mirrors approve/cancel).
func (h *Handler) unsubscribeHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) {
if err := validateOneClickUnsubscribeBody(req); err != nil {
return nil, err
}
token, email, scope, err := unsubscribeRequestParams(req)
if err != nil {
return nil, err
}

// Resolve the HMAC key with the fail-closed policy: a missing
// NOTIFICATION_MUTE_SECRET is a server misconfiguration, not a client error,
// and must never silently verify against a well-known key.
key, err := common.ResolveMuteSecret()
if err != nil {
logging.Errorf("notifications/unsubscribe: %v", err)
return nil, fmt.Errorf("unsubscribe is not configured: %w", err)
}
if !common.VerifyMuteToken(key, email, scope, token) {
logging.Warnf("notifications/unsubscribe: invalid token for scope=%s", scope)
return nil, NewClientError(401, "invalid or expired unsubscribe token")
}

if err := h.config.UpsertNotificationMute(ctx, email, scope, token); err != nil {
logging.Errorf("notifications/unsubscribe: store error: %v", err)
return nil, fmt.Errorf("could not save unsubscribe preference: %w", err)
}

logging.Infof("notifications/unsubscribe: muted scope=%s for %s", scope, redactEmailLocal(email))

var buf strings.Builder
if err := unsubscribeConfirmTmpl.Execute(&buf, struct{ ScopeLabel string }{
ScopeLabel: scopeLabel(scope),
}); err != nil {
return nil, fmt.Errorf("render unsubscribe page: %w", err)
}

return &rawResponse{
contentType: "text/html; charset=utf-8",
body: buf.String(),
csp: mutePageCSP,
}, nil
}
Comment on lines +86 to +127

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

GET-triggers-mutation bug spans handler, routing, and its own test coverage. The root cause is in unsubscribeHandler, which mutates unconditionally regardless of HTTP method; router.go routes both verbs to it unchanged (no fix needed there), and the test suite currently asserts the buggy behavior as correct.

  • internal/api/handler_notifications.go#L86-L127: gate h.config.UpsertNotificationMute (and the "you have been unsubscribed" response) behind a validated POST; render a non-mutating confirmation/landing page for GET.
  • internal/api/router.go#L323-L327: no change required — once the handler branches on method internally, the existing GET+POST routing to the same handler is fine.
  • internal/api/handler_notifications_test.go#L28-L55: update TestUnsubscribeHandler_Success (and add a GET-specific test) to assert GET does not call UpsertNotificationMute and instead returns a confirmation form; keep the existing POST-based mutation assertions in TestRouter_UnsubscribePOSTRoute_MutesSignedRecipientAndScope.
📍 Affects 3 files
  • internal/api/handler_notifications.go#L86-L127 (this comment)
  • internal/api/router.go#L323-L327
  • internal/api/handler_notifications_test.go#L28-L55
🤖 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/api/handler_notifications.go` around lines 86 - 127, Update
internal/api/handler_notifications.go:86-127 in unsubscribeHandler to branch on
the validated HTTP method, allowing UpsertNotificationMute and the unsubscribed
response only for POST while rendering a non-mutating confirmation form for GET;
update internal/api/handler_notifications_test.go:28-55 to assert GET does not
mutate and add GET coverage while preserving POST mutation assertions; make no
change to internal/api/router.go:323-327, since its shared GET/POST routing
remains valid.


// redactEmailLocal returns just the domain part with the local masked, e.g.
// "us***@example.com". Reuses the same masking logic as email/sender.go but
// without importing that package into api (avoids a dependency cycle).
func redactEmailLocal(email string) string {
at := strings.LastIndex(email, "@")
if at < 0 {
return "***"
}
local := email[:at]
domain := email[at:] // includes '@'
if len(local) <= 2 {
return "***" + domain
}
return local[:2] + "***" + domain
}
Loading
Loading