Previously it was sanitised using the HTML sanitiser, but it had proven troublesome and unnecessary. Remark42 rendered the markdown into proper HTML, but then some pieces of it (like cited HTML code inside the code block, marked by backticks) were cut out, which then showed the incorrect markdown to a user when they were editing the comment. For example, the comment "`foo<bar>`" became "foo" after sanitising, and despite the proper render user saw only "foo" when editing the comment. After this change, the initial comment markdown is preserved unaltered. It could contain dangerous HTML with JS, which I assume shouldn't be a problem as it's never rendered as HTML but instead supposed to be converted to HTML by the interpreter. In Remark42, it's stored in a comment.Text field and sanitised and thus safe. I've left information about the potential danger of rendering the original markdown as-is without an interpreter in all relevant places I could find.
184 lines
6.2 KiB
Go
184 lines
6.2 KiB
Go
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
)
|
|
|
|
// Comment represents a single comment with optional reference to its parent
|
|
type Comment struct {
|
|
ID string `json:"id" bson:"_id"`
|
|
ParentID string `json:"pid"`
|
|
Text string `json:"text"`
|
|
Orig string `json:"orig,omitempty"` // important: never render this as HTML! It's not sanitized.
|
|
User User `json:"user"`
|
|
Locator Locator `json:"locator"`
|
|
Score int `json:"score"`
|
|
Votes map[string]bool `json:"votes,omitempty"`
|
|
VotedIPs map[string]VotedIPInfo `json:"voted_ips,omitempty"` // voted ips (hashes) with TS
|
|
Vote int `json:"vote"` // vote for the current user, -1/1/0.
|
|
Controversy float64 `json:"controversy,omitempty"`
|
|
Timestamp time.Time `json:"time" bson:"time"`
|
|
Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response
|
|
Pin bool `json:"pin,omitempty" bson:"pin,omitempty"`
|
|
Deleted bool `json:"delete,omitempty" bson:"delete"`
|
|
Imported bool `json:"imported,omitempty" bson:"imported"`
|
|
PostTitle string `json:"title,omitempty" bson:"title"`
|
|
}
|
|
|
|
// Locator keeps site and url of the post
|
|
type Locator struct {
|
|
SiteID string `json:"site,omitempty" bson:"site"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// Edit indication
|
|
type Edit struct {
|
|
Timestamp time.Time `json:"time" bson:"time"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
// PostInfo holds summary for given post url
|
|
type PostInfo struct {
|
|
URL string `json:"url"`
|
|
Count int `json:"count"`
|
|
ReadOnly bool `json:"read_only,omitempty" bson:"read_only,omitempty"`
|
|
FirstTS time.Time `json:"first_time,omitempty" bson:"first_time,omitempty"`
|
|
LastTS time.Time `json:"last_time,omitempty" bson:"last_time,omitempty"`
|
|
}
|
|
|
|
// BlockedUser holds id and ts for blocked user
|
|
type BlockedUser struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Until time.Time `json:"time"`
|
|
}
|
|
|
|
// VotedIPInfo keeps timestamp and voting value (direction). Used as VotedIPs value
|
|
type VotedIPInfo struct {
|
|
Timestamp time.Time
|
|
Value bool
|
|
}
|
|
|
|
// DeleteMode defines how much comment info will be erased
|
|
type DeleteMode int
|
|
|
|
// DeleteMode enum
|
|
const (
|
|
SoftDelete DeleteMode = 0
|
|
HardDelete DeleteMode = 1
|
|
)
|
|
|
|
// Maximum length for URL text shortening.
|
|
const shortURLLen = 48
|
|
const snippetLen = 200
|
|
|
|
// PrepareUntrusted pre-processes a comment received from untrusted source by clearing all
|
|
// autogen fields and reset everything users not supposed to provide
|
|
func (c *Comment) PrepareUntrusted() {
|
|
c.ID = "" // don't allow user to define ID, force auto-gen
|
|
c.Timestamp = time.Time{} // reset time, force auto-gen
|
|
c.Votes = make(map[string]bool)
|
|
c.VotedIPs = make(map[string]VotedIPInfo)
|
|
c.Score = 0
|
|
c.Controversy = 0
|
|
c.Edit = nil
|
|
c.Pin = false
|
|
c.Deleted = false
|
|
c.Imported = false
|
|
}
|
|
|
|
// SetDeleted clears comment info, reset to deleted state. hard flag will clear all user info as well
|
|
func (c *Comment) SetDeleted(mode DeleteMode) {
|
|
c.Text = ""
|
|
c.Orig = ""
|
|
c.Score = 0
|
|
c.Votes = map[string]bool{}
|
|
c.VotedIPs = make(map[string]VotedIPInfo)
|
|
c.Edit = nil
|
|
c.Deleted = true
|
|
c.Pin = false
|
|
|
|
if mode == HardDelete {
|
|
c.User.Name = "deleted"
|
|
c.User.ID = "deleted"
|
|
c.User.Picture = ""
|
|
c.User.IP = ""
|
|
}
|
|
}
|
|
|
|
// Sanitize clean dangerous html/js from the comment.
|
|
// Comment.Orig which is used to store the original comment text is not sanitized
|
|
// as we expect to never render it as HTML and render Comment.Text instead
|
|
func (c *Comment) Sanitize() {
|
|
p := bluemonday.UGCPolicy()
|
|
p.AllowAttrs("class").Matching(regexp.MustCompile("^chroma$")).OnElements("pre")
|
|
// this is list of <span> tag classes which could be produced by chroma code renderer
|
|
// source: https://github.com/alecthomas/chroma/blob/cc2dd5b/types.go#L211-L307
|
|
const codeSpanClassRegex = "^(bg|chroma|line|ln|lnt|hl|lntable|lntd|cl|w|err|x|k|kc" +
|
|
"|kd|kn|kp|kr|kt|n|na|nb|bp|nc|no|nd|ni|ne|nf|fm|py|nl|nn|nx|nt|nv|vc|vg" +
|
|
"|vi|vm|l|ld|s|sa|sb|sc|dl|sd|s2|se|sh|si|sx|sr|s1|ss|m|mb|mf|mh|mi|il" +
|
|
"|mo|o|ow|p|c|ch|cm|cp|cpf|c1|cs|g|gd|ge|gr|gh|gi|go|gp|gs|gu|gt|gl)$"
|
|
p.AllowAttrs("class").Matching(regexp.MustCompile(codeSpanClassRegex)).OnElements("span")
|
|
p.AllowAttrs("loading").Matching(regexp.MustCompile("^(lazy|eager)$")).OnElements("img")
|
|
c.Text = p.Sanitize(c.Text)
|
|
c.User.ID = template.HTMLEscapeString(c.User.ID)
|
|
c.User.Name = c.SanitizeText(c.User.Name)
|
|
c.User.Picture = c.SanitizeAsURL(c.User.Picture)
|
|
c.Locator.URL = c.SanitizeAsURL(c.Locator.URL)
|
|
c.PostTitle = c.SanitizeText(c.PostTitle)
|
|
}
|
|
|
|
// Snippet from comment's text
|
|
func (c *Comment) Snippet(limit int) string {
|
|
if limit <= 0 {
|
|
limit = snippetLen
|
|
}
|
|
cleanText := strings.Replace(c.Text, "\n", " ", -1)
|
|
size := len([]rune(cleanText))
|
|
if size < limit {
|
|
return cleanText
|
|
}
|
|
snippet := []rune(cleanText)[:size]
|
|
// go back in snippet and found the first space
|
|
for i := len(snippet) - 1; i >= 0; i-- {
|
|
if snippet[i] == ' ' {
|
|
snippet = snippet[:i]
|
|
break
|
|
}
|
|
}
|
|
return string(snippet) + " ..."
|
|
}
|
|
|
|
var reHref = regexp.MustCompile(`<a\s+(?:[^>]*?\s+)?href="([^"]*)"`)
|
|
|
|
// SanitizeAsURL drops dangerous code from a url.
|
|
// It wraps input with href to trigger bluemonday sanitizer and cleans href after sanitizing done
|
|
func (c *Comment) SanitizeAsURL(inp string) string {
|
|
h := fmt.Sprintf(`<a href=%q>`, inp)
|
|
clean := bluemonday.UGCPolicy().Sanitize(h)
|
|
if match := reHref.FindStringSubmatch(clean); len(match) > 1 {
|
|
return match[1]
|
|
}
|
|
return "" // this shouldn't happen as we build the href
|
|
}
|
|
|
|
func (c *Comment) escapeHTMLWithSome(inp string) string {
|
|
res := template.HTMLEscapeString(inp)
|
|
res = strings.Replace(res, "&", "&", -1)
|
|
res = strings.Replace(res, """, "\"", -1)
|
|
res = strings.Replace(res, "'", "'", -1)
|
|
return res
|
|
}
|
|
|
|
// SanitizeText used to sanitize any input string
|
|
func (c *Comment) SanitizeText(inp string) string {
|
|
clean := bluemonday.UGCPolicy().Sanitize(inp)
|
|
return c.escapeHTMLWithSome(clean)
|
|
}
|