Email notification templates rendered the comment HTML via text/template, so the store-level UGC sanitizer's permitted <a> and <img> tags reached the email body verbatim. An authenticated user could plant phishing links and remote tracking pixels in notification emails sent from the legitimate remark42 address. Switch notify to html/template (auto-escaping every non-HTML field) and add a stricter email-only bluemonday policy that drops <a> and <img> while keeping basic text formatting; the sanitized comment HTML is passed as template.HTML. Add regression tests asserting links and images are stripped while anchor text and formatting survive.
311 lines
9.6 KiB
Go
311 lines
9.6 KiB
Go
package notify
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"net/url"
|
|
"time"
|
|
|
|
log "github.com/go-pkgz/lgr"
|
|
ntf "github.com/go-pkgz/notify"
|
|
"github.com/go-pkgz/repeater/v2"
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/microcosm-cc/bluemonday"
|
|
|
|
"github.com/umputun/remark42/backend/app/templates"
|
|
)
|
|
|
|
// EmailParams contain settings for email notifications
|
|
type EmailParams struct {
|
|
From string // from email address
|
|
AdminEmails []string // administrator emails to send copy of comment notification to
|
|
MsgTemplatePath string // path to request message template
|
|
VerificationSubject string // verification message sub
|
|
VerificationTemplatePath string // path to verification template
|
|
SubscribeURL string // full subscribe handler URL
|
|
UnsubscribeURL string // full unsubscribe handler URL
|
|
|
|
TokenGenFn func(userID, email, site string) (string, error) // unsubscribe token generation function
|
|
}
|
|
|
|
// Email implements notify.Destination for email
|
|
type Email struct {
|
|
*ntf.Email
|
|
|
|
EmailParams
|
|
msgTmpl *template.Template // parsed request message template
|
|
verifyTmpl *template.Template // parsed verification message template
|
|
}
|
|
|
|
// msgTmplData store data for message from request template execution
|
|
type msgTmplData struct {
|
|
UserName string
|
|
UserPicture string
|
|
CommentText template.HTML
|
|
CommentLink string
|
|
CommentDate time.Time
|
|
ParentUserName string
|
|
ParentUserPicture string
|
|
ParentCommentText template.HTML
|
|
ParentCommentLink string
|
|
ParentCommentDate time.Time
|
|
PostTitle string
|
|
Email string
|
|
UnsubscribeLink string
|
|
ForAdmin bool
|
|
}
|
|
|
|
// emailCommentPolicy sanitizes comment HTML for inclusion in notification emails.
|
|
// It is intentionally stricter than the store-level UGC policy used for web rendering:
|
|
// links (<a>) and images (<img>) are dropped so a comment can't smuggle phishing links
|
|
// or remote tracking pixels into an email sent from the legitimate remark42 address,
|
|
// while basic inline and block text formatting is preserved.
|
|
var emailCommentPolicy = func() *bluemonday.Policy {
|
|
p := bluemonday.NewPolicy()
|
|
p.AllowElements(
|
|
"p", "br", "hr", "div", "span",
|
|
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "sub", "sup", "mark", "small",
|
|
"blockquote", "q", "cite",
|
|
"code", "pre", "kbd", "samp", "var",
|
|
"ul", "ol", "li", "dl", "dt", "dd",
|
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
)
|
|
return p
|
|
}()
|
|
|
|
// emailSafeHTML strips links and images from pre-rendered comment HTML and returns
|
|
// it as template.HTML so html/template renders the remaining safe formatting as-is.
|
|
func emailSafeHTML(commentHTML string) template.HTML {
|
|
return template.HTML(emailCommentPolicy.Sanitize(commentHTML)) //nolint:gosec // sanitized above: <a>/<img> dropped, only formatting tags survive
|
|
}
|
|
|
|
// verifyTmplData store data for verification message template execution
|
|
type verifyTmplData struct {
|
|
User string
|
|
Token string
|
|
Email string
|
|
Site string
|
|
SubscribeURL string
|
|
}
|
|
|
|
const (
|
|
defaultVerificationSubject = "Email verification"
|
|
defaultEmailTimeout = 10 * time.Second
|
|
defaultEmailTemplatePath = "email_reply.html.tmpl"
|
|
defaultEmailVerificationTemplatePath = "email_confirmation_subscription.html.tmpl"
|
|
)
|
|
|
|
// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error
|
|
func NewEmail(emailParams EmailParams, smtpParams ntf.SMTPParams) (*Email, error) {
|
|
// set up Email emailParams
|
|
if smtpParams.TimeOut <= 0 {
|
|
smtpParams.TimeOut = defaultEmailTimeout
|
|
}
|
|
|
|
res := Email{Email: ntf.NewEmail(smtpParams), EmailParams: emailParams}
|
|
|
|
if res.VerificationSubject == "" {
|
|
res.VerificationSubject = defaultVerificationSubject
|
|
}
|
|
|
|
// initialize templates
|
|
err := res.setTemplates()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("can't set templates: %w", err)
|
|
}
|
|
|
|
log.Printf("[DEBUG] Create new email notifier for server %s with user %s, timeout=%s",
|
|
res.Host, res.Username, res.TimeOut)
|
|
|
|
return &res, nil
|
|
}
|
|
|
|
func (e *Email) setTemplates() error {
|
|
var err error
|
|
var msgTmplFile, verifyTmplFile []byte
|
|
|
|
if e.VerificationTemplatePath == "" {
|
|
e.VerificationTemplatePath = defaultEmailVerificationTemplatePath
|
|
}
|
|
|
|
if e.MsgTemplatePath == "" {
|
|
e.MsgTemplatePath = defaultEmailTemplatePath
|
|
}
|
|
|
|
if msgTmplFile, err = templates.Read(e.MsgTemplatePath); err != nil {
|
|
return fmt.Errorf("can't read message template: %w", err)
|
|
}
|
|
if verifyTmplFile, err = templates.Read(e.VerificationTemplatePath); err != nil {
|
|
return fmt.Errorf("can't read verification template: %w", err)
|
|
}
|
|
if e.msgTmpl, err = template.New("msgTmpl").Parse(string(msgTmplFile)); err != nil {
|
|
return fmt.Errorf("can't parse message template: %w", err)
|
|
}
|
|
if e.verifyTmpl, err = template.New("verifyTmpl").Parse(string(verifyTmplFile)); err != nil {
|
|
return fmt.Errorf("can't parse verification template: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Send email about comment reply to Request.Emails and Email.AdminEmails
|
|
// if they're set.
|
|
// Thread safe
|
|
func (e *Email) Send(ctx context.Context, req Request) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("sending email messages about comment %q aborted due to canceled context", req.Comment.ID)
|
|
default:
|
|
}
|
|
|
|
result := new(multierror.Error)
|
|
|
|
for _, email := range req.Emails {
|
|
err := e.buildAndSendMessage(ctx, req, email, false)
|
|
if err != nil {
|
|
result = multierror.Append(fmt.Errorf("problem sending user email notification to %q: %w", email, err))
|
|
}
|
|
}
|
|
|
|
for _, email := range e.AdminEmails {
|
|
err := e.buildAndSendMessage(ctx, req, email, true)
|
|
if err != nil {
|
|
result = multierror.Append(fmt.Errorf("problem sending admin email notification to %q: %w", email, err))
|
|
}
|
|
}
|
|
|
|
return result.ErrorOrNil()
|
|
}
|
|
|
|
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
|
|
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
|
|
msg, err := e.buildMessageFromRequest(req, email, forAdmin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return repeater.NewFixed(5, time.Millisecond*250).Do(
|
|
ctx,
|
|
func() error {
|
|
return e.Email.Send(
|
|
ctx,
|
|
fmt.Sprintf("mailto:%s?from=%s&unsubscribeLink=%s&subject=%s",
|
|
email,
|
|
url.QueryEscape(e.From),
|
|
url.QueryEscape(msg.unsubscribeLink),
|
|
url.QueryEscape(msg.subject),
|
|
),
|
|
msg.body,
|
|
)
|
|
})
|
|
}
|
|
|
|
// SendVerification email verification VerificationRequest.Email if it's set.
|
|
// Thread safe
|
|
func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) error {
|
|
if req.Email == "" {
|
|
// this means we can't send this request via Email
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("sending message to %q aborted due to canceled context", req.User)
|
|
default:
|
|
}
|
|
|
|
log.Printf("[DEBUG] send verification via %s, user %s", e, req.User)
|
|
msg, err := e.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return repeater.NewFixed(5, time.Millisecond*250).Do(
|
|
ctx,
|
|
func() error {
|
|
return e.Email.Send(
|
|
ctx,
|
|
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
|
|
req.Email,
|
|
url.QueryEscape(e.From),
|
|
url.QueryEscape(e.VerificationSubject),
|
|
),
|
|
msg,
|
|
)
|
|
})
|
|
}
|
|
|
|
// buildVerificationMessage generates verification email message based on given input
|
|
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
|
|
msg := bytes.Buffer{}
|
|
err := e.verifyTmpl.Execute(&msg, verifyTmplData{
|
|
User: user,
|
|
Token: token,
|
|
Email: email,
|
|
Site: site,
|
|
SubscribeURL: e.SubscribeURL,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("error executing template to build verification message: %w", err)
|
|
}
|
|
return msg.String(), nil
|
|
}
|
|
|
|
type commentMessage struct {
|
|
subject string
|
|
body string
|
|
unsubscribeLink string
|
|
}
|
|
|
|
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
|
|
func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool) (commentMessage, error) {
|
|
subject := "New reply to your comment"
|
|
if forAdmin {
|
|
subject = "New comment to your site"
|
|
}
|
|
if req.Comment.PostTitle != "" {
|
|
subject += fmt.Sprintf(" for %q", req.Comment.PostTitle)
|
|
}
|
|
|
|
token, err := e.TokenGenFn(req.parent.User.ID, email, req.Comment.Locator.SiteID)
|
|
if err != nil {
|
|
return commentMessage{}, fmt.Errorf("error creating token for unsubscribe link: %w", err)
|
|
}
|
|
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
|
|
if forAdmin {
|
|
unsubscribeLink = ""
|
|
}
|
|
|
|
commentURLPrefix := req.Comment.Locator.URL + uiNav
|
|
msg := bytes.Buffer{}
|
|
tmplData := msgTmplData{
|
|
UserName: req.Comment.User.Name,
|
|
UserPicture: req.Comment.User.Picture,
|
|
CommentText: emailSafeHTML(req.Comment.Text),
|
|
CommentLink: commentURLPrefix + req.Comment.ID,
|
|
CommentDate: req.Comment.Timestamp,
|
|
PostTitle: req.Comment.PostTitle,
|
|
Email: email,
|
|
UnsubscribeLink: unsubscribeLink,
|
|
ForAdmin: forAdmin,
|
|
}
|
|
// in case of message to admin, parent message might be empty
|
|
if req.Comment.ParentID != "" {
|
|
tmplData.ParentUserName = req.parent.User.Name
|
|
tmplData.ParentUserPicture = req.parent.User.Picture
|
|
tmplData.ParentCommentText = emailSafeHTML(req.parent.Text)
|
|
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
|
|
tmplData.ParentCommentDate = req.parent.Timestamp
|
|
}
|
|
err = e.msgTmpl.Execute(&msg, tmplData)
|
|
if err != nil {
|
|
return commentMessage{}, fmt.Errorf("error executing template to build comment reply message: %w", err)
|
|
}
|
|
return commentMessage{
|
|
subject: subject,
|
|
body: msg.String(),
|
|
unsubscribeLink: unsubscribeLink,
|
|
}, err
|
|
}
|