Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to specify inline style policies #48

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Expand Up @@ -198,6 +198,27 @@ p := bluemonday.UGCPolicy()
p.AllowElements("fieldset", "select", "option")
```

### Inline CSS

Although it's possible to handle inline CSS using `AllowAttrs` with a `Matching` rule, writing a single monolithic regular expression to safely process all inline CSS which you wish to allow is not a trivial task. Instead of attempting to do so, you can whitelist the `style` attribute on whichever element(s) you desire and use style policies to control and sanitize inline styles.

As noted above for HTML attributes, it's **always** recommended that you use either `Matching` (with a suitable regular expression) or `MatchingEnum` to ensure that the value is safe.

Similar to attributes, you can allow specific CSS properties to be set inline:
```go
p.AllowAttrs("style").OnElements("span", "p")
// Allow the 'color' property with valid RGB(A) hex values only (on any element allowed a 'style' attribute)
p.AllowStyles("color").Matching(regexp.MustCompile("(?i)^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$")).Globally()
```

Additionally, you can allow a CSS property to be set only to an allowed value:
```go
p.AllowAttrs("style").OnElements("span", "p")
// Allow the 'text-decoration' property to be set to 'underline', 'line-through' or 'none'
// on 'span' elements only
p.AllowStyles("text-decoration").MatchingEnum("underline", "line-through", "none").OnElements("span")
```

### Links

Links are difficult beasts to sanitise safely and also one of the biggest attack vectors for malicious content.
Expand Down
36 changes: 36 additions & 0 deletions example_test.go
Expand Up @@ -191,6 +191,42 @@ func ExamplePolicy_AllowAttrs() {
).OnElements("td", "th")
}

func ExamplePolicy_AllowStyles() {
p := bluemonday.NewPolicy()

// Allow only 'span' and 'p' elements
p.AllowElements("span", "p", "strong")

// Only allow 'style' attributes on 'span' and 'p' elements
p.AllowAttrs("style").OnElements("span", "p")

// Allow the 'text-decoration' property to be set to 'underline', 'line-through' or 'none'
// on 'span' elements only
p.AllowStyles("text-decoration").MatchingEnum("underline", "line-through", "none").OnElements("span")

// Allow the 'color' property with valid RGB(A) hex values only
// on every HTML element that has been whitelisted
p.AllowStyles("color").Matching(regexp.MustCompile("(?i)^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$")).Globally()

// The span has an invalid 'color' which will be stripped along with other disallowed properties
html := p.Sanitize(
`<p style="color:#f00;">
<span style="text-decoration: underline; background-image: url(javascript:alert('XSS')); color: #f00ba">
Red underlined <strong style="text-decoration:none;">text</strong>
</span>
</p>`,
)

fmt.Println(html)

// Output:
//<p style="color: #f00">
// <span style="text-decoration: underline">
// Red underlined <strong>text</strong>
// </span>
//</p>
}

func ExamplePolicy_AllowElements() {
p := bluemonday.NewPolicy()

Expand Down
120 changes: 120 additions & 0 deletions policy.go
Expand Up @@ -79,6 +79,12 @@ type Policy struct {
// map[htmlAttributeName]attrPolicy
globalAttrs map[string]attrPolicy

// map[htmlElementName]map[cssPropertyName]stylePolicy
elsAndStyles map[string]map[string]stylePolicy

// map[cssPropertyName]stylePolicy
globalStyles map[string]stylePolicy

// If urlPolicy is nil, all URLs with matching schema are allowed.
// Otherwise, only the URLs with matching schema and urlPolicy(url)
// returning true are allowed.
Expand All @@ -103,6 +109,18 @@ type attrPolicy struct {
regexp *regexp.Regexp
}

type stylePolicy struct {

// optional pattern to match, when not nil the regexp needs to match
// otherwise the property is removed
regexp *regexp.Regexp

// optional list of allowed property values, for properties which
// have a defined list of allowed values; property will be removed
// if the value is not allowed
enum []string
}

type attrPolicyBuilder struct {
p *Policy

Expand All @@ -111,13 +129,23 @@ type attrPolicyBuilder struct {
allowEmpty bool
}

type stylePolicyBuilder struct {
p *Policy

propertyNames []string
regexp *regexp.Regexp
enum []string
}

type urlPolicy func(url *url.URL) (allowUrl bool)

// init initializes the maps if this has not been done already
func (p *Policy) init() {
if !p.initialized {
p.elsAndAttrs = make(map[string]map[string]attrPolicy)
p.globalAttrs = make(map[string]attrPolicy)
p.elsAndStyles = make(map[string]map[string]stylePolicy)
p.globalStyles = make(map[string]stylePolicy)
p.allowURLSchemes = make(map[string]urlPolicy)
p.setOfElementsAllowedWithoutAttrs = make(map[string]struct{})
p.setOfElementsToSkipContent = make(map[string]struct{})
Expand Down Expand Up @@ -250,6 +278,98 @@ func (abp *attrPolicyBuilder) Globally() *Policy {
return abp.p
}

// AllowStyles takes a range of CSS property names and returns a
// style policy builder that allows you to specify the pattern and scope of
// the whitelisted property.
//
// The style policy is only added to the core policy when either Globally()
// or OnElements(...) are called.
func (p *Policy) AllowStyles(propertyNames ...string) *stylePolicyBuilder {

p.init()

abp := stylePolicyBuilder{
p: p,
}

for _, propertyName := range propertyNames {
abp.propertyNames = append(abp.propertyNames, strings.ToLower(propertyName))
}

return &abp
}

// Matching allows a regular expression to be applied to a nascent style
// policy, and returns the style policy. Calling this more than once will
// replace the existing regexp.
func (spb *stylePolicyBuilder) Matching(regex *regexp.Regexp) *stylePolicyBuilder {

spb.regexp = regex

return spb
}

// MatchingEnum allows a list of allowed values to be applied to a nascent style
// policy, and returns the style policy. Calling this more than once will
// replace the existing list of allowed values.
func (spb *stylePolicyBuilder) MatchingEnum(enum ...string) *stylePolicyBuilder {

spb.enum = enum

return spb
}

// OnElements will bind a style policy to a given range of HTML elements
// and return the updated policy
func (spb *stylePolicyBuilder) OnElements(elements ...string) *Policy {

for _, element := range elements {
element = strings.ToLower(element)

for _, attr := range spb.propertyNames {

if _, ok := spb.p.elsAndStyles[element]; !ok {
spb.p.elsAndStyles[element] = make(map[string]stylePolicy)
}

sp := stylePolicy{}
if spb.regexp != nil {
sp.regexp = spb.regexp
}
if len(spb.enum) > 0 {
sp.enum = spb.enum
}

spb.p.elsAndStyles[element][attr] = sp
}
}

return spb.p
}

// Globally will bind a style policy to all HTML elements and return the
// updated policy
func (spb *stylePolicyBuilder) Globally() *Policy {

for _, attr := range spb.propertyNames {
if _, ok := spb.p.globalStyles[attr]; !ok {
spb.p.globalStyles[attr] = stylePolicy{}
}

sp := stylePolicy{}
if spb.regexp != nil {
sp.regexp = spb.regexp
}
if len(spb.enum) > 0 {
sp.enum = spb.enum
}

spb.p.globalStyles[attr] = sp
}

return spb.p
}

// AllowElements will append HTML elements to the whitelist without applying an
// attribute policy to those elements (the elements are permitted
// sans-attributes)
Expand Down
73 changes: 73 additions & 0 deletions sanitize.go
Expand Up @@ -252,10 +252,26 @@ func (p *Policy) sanitizeAttrs(
return attrs
}

hasStylePolicies := false
sps, elementHasStylePolicies := p.elsAndStyles[elementName]
if len(p.globalStyles) > 0 || (elementHasStylePolicies && len(sps) > 0) {
hasStylePolicies = true
}

// Builds a new attribute slice based on the whether the attribute has been
// whitelisted explicitly or globally.
cleanAttrs := []html.Attribute{}
for _, htmlAttr := range attrs {
// Is this a "style" attribute, and if so, do we need to sanitize it?
if htmlAttr.Key == "style" && hasStylePolicies {
htmlAttr = p.sanitizeStyles(htmlAttr, sps)
if htmlAttr.Val == "" {
// We've sanitized away any and all styles; don't bother to
// output the style attribute (even if it's allowed)
continue
}
}

// Is there an element specific attribute policy that applies?
if ap, ok := aps[htmlAttr.Key]; ok {
if ap.regexp != nil {
Expand Down Expand Up @@ -483,6 +499,53 @@ func (p *Policy) sanitizeAttrs(
return cleanAttrs
}

func (p *Policy) sanitizeStyles(attr html.Attribute, sps map[string]stylePolicy) html.Attribute {
props := strings.Split(attr.Val, ";")
clean := []string{}

for _, prop := range props {
parts := strings.SplitN(prop, ":", 2)
if len(parts) != 2 {
continue
}
propName := strings.TrimSpace(parts[0])
propValue := strings.TrimSpace(parts[1])
if sp, ok := sps[propName]; ok {
if sp.regexp != nil {
if sp.regexp.MatchString(propValue) {
clean = append(clean, propName+": "+propValue)
}
continue
} else if len(sp.enum) > 0 && !stringInSlice(propValue, sp.enum) {
continue
}

clean = append(clean, propName+": "+propValue)
}

if sp, ok := p.globalStyles[propName]; ok {
if sp.regexp != nil {
if sp.regexp.MatchString(propValue) {
clean = append(clean, propName+": "+propValue)
}
continue
} else if len(sp.enum) > 0 && !stringInSlice(propValue, sp.enum) {
continue
}

clean = append(clean, propName+": "+propValue)
}
}

if len(clean) > 0 {
attr.Val = strings.Join(clean, "; ")
} else {
attr.Val = ""
}

return attr
}

func (p *Policy) allowNoAttrs(elementName string) bool {
_, ok := p.setOfElementsAllowedWithoutAttrs[elementName]
return ok
Expand Down Expand Up @@ -537,3 +600,13 @@ func linkable(elementName string) bool {
return false
}
}

// stringInSlice returns true if needle exists in haystack
func stringInSlice(needle string, haystack []string) bool {
for _, straw := range haystack {
if strings.ToLower(straw) == strings.ToLower(needle) {
return true
}
}
return false
}