Skip to content

Commit 8df8041

Browse files
authored
Merge pull request #5 from yangyin5127/yy/2025/feat-css-filter
feat: add css filter
2 parents e288331 + 1594d20 commit 8df8041

File tree

12 files changed

+931
-7
lines changed

12 files changed

+931
-7
lines changed

README.en.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,28 @@ options.SingleQuotedAttributeValue = true
200200
// <a href='#'>Hello</a>
201201
```
202202

203+
### Customize CSS filter
203204

205+
If you allow the attribute style, the value will be processed by [cssfilter](github.com/yangyin5127/go-xss/cssfilter) module. The cssfilter module includes a default css whitelist. You can specify the options for cssfilter module like this:
206+
207+
208+
```
209+
// When enabled, the content of style attribute will be filtered to prevent XSS attacks via CSS.
210+
options.EnableCssFilter = true
211+
// enable div tag's style attribute
212+
options.WhiteList["div"] = []string{"style"}
213+
214+
215+
options.CssFilterOption = xss.NewCssFilterOption()
216+
options.CssFilterOption.WhiteList = ....
217+
// OnAttr func(name, value string, options StyleAttrOption) *string
218+
options.CssFilterOption.OnAttr = ....
219+
// OnIgnoreAttr func(name, value string, options StyleAttrOption) *string
220+
options.CssFilterOption.OnIgnoreAttr = ....
221+
```
222+
223+
224+
more details see: github.com/yangyin5127/go-xss/cssfilter
204225

205226
### Quick Start
206227

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,28 @@ options.SingleQuotedAttributeValue = true
201201

202202
### 自定义 CSS 过滤器
203203

204-
TODO
204+
通过 `EnableCssFilter` 来启用 [CSS过滤器](github.com/yangyin5127/go-xss/cssfilter),启用后会对 style 属性中的内容进行过滤,防止通过 CSS 进行 XSS 攻击
205+
206+
207+
```golang
208+
209+
# 例子
210+
// 允许css filter
211+
options.EnableCssFilter = true
212+
// 开启标签style属性白名单,默认style禁止
213+
options.WhiteList["div"] = []string{"style"}
214+
215+
options.CssFilterOption = xss.NewCssFilterOption()
216+
options.CssFilterOption.WhiteList = ....
217+
// OnAttr func(name, value string, options StyleAttrOption) *string
218+
options.CssFilterOption.OnAttr = ....
219+
// OnIgnoreAttr func(name, value string, options StyleAttrOption) *string
220+
options.CssFilterOption.OnIgnoreAttr = ....
221+
222+
223+
```
224+
more details see: github.com/yangyin5127/go-xss/cssfilter/css_option.go
225+
205226

206227
## 快捷配置
207228

cssfilter/css_option.go

Lines changed: 377 additions & 0 deletions
Large diffs are not rendered by default.

cssfilter/css_option_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package cssfilter
2+
3+
import "testing"
4+
5+
func TestSafeAttrValue(t *testing.T) {
6+
sourceName := "href"
7+
sourceValue := "javascript:alert(1);"
8+
result := SafeAttrValue(sourceName, sourceValue)
9+
if result != "" {
10+
t.Errorf("TestSafeAttrValue err %v", result)
11+
}
12+
13+
sourceName = "src"
14+
sourceValue = "javascript:alert(1);"
15+
result = SafeAttrValue(sourceName, sourceValue)
16+
if result != "" {
17+
t.Errorf("TestSafeAttrValue err %v", result)
18+
}
19+
20+
sourceName = "style"
21+
sourceValue = "background-image: url(javascript:alert(1));"
22+
result = SafeAttrValue(sourceName, sourceValue)
23+
if result != "" {
24+
t.Errorf("TestSafeAttrValue err %v", result)
25+
}
26+
}

cssfilter/filter.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package cssfilter
2+
3+
// 等价 JS isNull:nil → true
4+
func isNull(v interface{}) bool {
5+
return v == nil
6+
}
7+
8+
// ---------- FilterCSS 核心 ----------
9+
10+
type FilterCSS struct {
11+
Options CssOption
12+
}
13+
14+
// 新建 FilterCSS(浅拷贝 semantics)
15+
func NewFilterCSS(opt *CssOption) *FilterCSS {
16+
var cfg CssOption
17+
if opt != nil {
18+
cfg = *opt
19+
}
20+
21+
// 白名单为空时,默认空 map
22+
if cfg.WhiteList == nil {
23+
cfg.WhiteList = DefaultCssWhiteList
24+
}
25+
26+
// 默认回调
27+
if cfg.OnAttr == nil {
28+
cfg.OnAttr = func(name, value string, opts StyleAttrOption) *string {
29+
return nil
30+
}
31+
}
32+
if cfg.OnIgnoreAttr == nil {
33+
cfg.OnIgnoreAttr = func(name, value string, opts StyleAttrOption) *string {
34+
return nil
35+
}
36+
}
37+
if cfg.SafeAttrValue == nil {
38+
cfg.SafeAttrValue = SafeAttrValue
39+
}
40+
41+
return &FilterCSS{Options: cfg}
42+
}
43+
44+
func (fc *FilterCSS) Process(css string) string {
45+
if css == "" {
46+
return ""
47+
}
48+
49+
opts := fc.Options
50+
white := opts.WhiteList
51+
onAttr := opts.OnAttr
52+
onIgnore := opts.OnIgnoreAttr
53+
safeAttrValue := opts.SafeAttrValue
54+
55+
result := ParseStyle(css, func(sourcePos, position int, name, value, source string) string {
56+
57+
// ---- whitelist check ----
58+
check, exists := white[name]
59+
isWhite := false
60+
if exists {
61+
if check.Allow {
62+
isWhite = true
63+
} else if check.Func != nil {
64+
isWhite = check.Func(value)
65+
} else if check.Reg != nil {
66+
isWhite = check.Reg.MatchString(value)
67+
}
68+
}
69+
70+
// ---- safeAttrValue ----
71+
value = safeAttrValue(name, value)
72+
if value == "" {
73+
return ""
74+
}
75+
76+
optsObj := StyleAttrOption{
77+
Position: position,
78+
SourcePosition: sourcePos,
79+
Source: source,
80+
IsWhite: isWhite,
81+
}
82+
83+
if isWhite && onAttr != nil {
84+
// onAttr 可返回 nil → 默认 name:value
85+
ret := onAttr(name, value, optsObj)
86+
87+
if ret == nil {
88+
return name + ":" + value
89+
}
90+
return *ret
91+
}
92+
93+
// not whitelisted
94+
ret := onIgnore(name, value, optsObj)
95+
if ret != nil {
96+
return *ret
97+
}
98+
99+
return ""
100+
})
101+
102+
return result
103+
}

cssfilter/filter_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package cssfilter
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func eq(t *testing.T, got, want string) {
8+
t.Helper()
9+
if got != want {
10+
t.Fatalf("expected `%s`, got `%s`", want, got)
11+
}
12+
}
13+
14+
func ok(t *testing.T, condition bool, msg string) {
15+
t.Helper()
16+
if !condition {
17+
t.Fatal(msg)
18+
}
19+
}
20+
21+
func TestFilterCSS_Normal(t *testing.T) {
22+
fc := NewFilterCSS(nil)
23+
24+
result := fc.Process("00xx; position: fixed; width:100px; height: 200px")
25+
eq(t, result, "width:100px; height:200px;")
26+
27+
fc = NewFilterCSS(&CssOption{
28+
OnAttr: func(name, value string, opts StyleAttrOption) *string {
29+
ok(t, opts.IsWhite, "expected IsWhite true")
30+
if name == "width" {
31+
eq(t, value, "100px")
32+
} else if name == "height" {
33+
eq(t, value, "200px")
34+
} else {
35+
t.Fatalf("bad attr name `%s`", name)
36+
}
37+
return nil
38+
},
39+
OnIgnoreAttr: func(name, value string, opts StyleAttrOption) *string {
40+
ok(t, !opts.IsWhite, "expected IsWhite false")
41+
if name == "position" {
42+
eq(t, value, "fixed")
43+
} else {
44+
t.Fatalf("bad attr name `%s`", name)
45+
}
46+
return nil
47+
},
48+
})
49+
50+
result = fc.Process("position: fixed; width:100px; height: 200px")
51+
eq(t, result, "width:100px; height:200px;")
52+
}
53+
54+
func TestFilterCSS_OnAttrReturnNewSource(t *testing.T) {
55+
fc := NewFilterCSS(&CssOption{
56+
OnAttr: func(name, value string, opts StyleAttrOption) *string {
57+
ok(t, opts.IsWhite, "expected IsWhite true")
58+
ret := name + ": " + value
59+
return &ret
60+
},
61+
})
62+
63+
result := fc.Process("position: fixed; width:100px; height: 200px")
64+
eq(t, result, "width: 100px; height: 200px;")
65+
}
66+
67+
func TestFilterCSS_OnIgnoreAttrReturnNewSource(t *testing.T) {
68+
fc := NewFilterCSS(&CssOption{
69+
OnIgnoreAttr: func(name, value string, opts StyleAttrOption) *string {
70+
ok(t, !opts.IsWhite, "expected IsWhite false")
71+
if name == "position" {
72+
ret := "x-" + name + ":" + value
73+
return &ret
74+
}
75+
return nil
76+
},
77+
})
78+
79+
result := fc.Process("position: fixed; width:100px; height: 200px")
80+
eq(t, result, "x-position:fixed; width:100px; height:200px;")
81+
}
82+
83+
func TestFilterCSS_SafeAttrValue(t *testing.T) {
84+
fc := NewFilterCSS(nil)
85+
86+
tests := []struct {
87+
input string
88+
expect string
89+
}{
90+
{"background: url(javascript:alert(/xss/)); height: 400px;", "height:400px;"},
91+
{"background: url( javascript : alert(/xss/)); height: 400px;", "height:400px;"},
92+
{"background: url ( javascript :alert(/xss/)); height: 400px;", "height:400px;"},
93+
{"background: url (\" javascript :alert(/xss/)\"); height: 400px;", "height:400px;"},
94+
{"background: url ( javascript : \"alert(/xss/) \"); height: 400px;", "height:400px;"},
95+
{"background: url ( java script : alert(/xss/)); height: 400px;", "background:url ( java script : alert(/xss/)); height:400px;"},
96+
}
97+
98+
for _, test := range tests {
99+
result := fc.Process(test.input)
100+
eq(t, result, test.expect)
101+
}
102+
}

cssfilter/parser.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package cssfilter
2+
3+
import (
4+
"strings"
5+
"unicode"
6+
)
7+
8+
type OnAttrFunc func(sourcePosition int, position int, name string, value string, source string) string
9+
10+
func trimRight(s string) string {
11+
return strings.TrimRightFunc(s, unicode.IsSpace)
12+
}
13+
14+
func trim(s string) string {
15+
return strings.TrimSpace(s)
16+
}
17+
18+
func ParseStyle(css string, onAttr OnAttrFunc) string {
19+
css = trimRight(css)
20+
if !strings.HasSuffix(css, ";") {
21+
css += ";"
22+
}
23+
24+
cssLen := len(css)
25+
isParenthesisOpen := false
26+
lastPos := 0
27+
i := 0
28+
retCSS := strings.Builder{}
29+
30+
addNewAttr := func() {
31+
if !isParenthesisOpen {
32+
source := trim(css[lastPos:i])
33+
j := strings.Index(source, ":")
34+
if j != -1 {
35+
name := trim(source[:j])
36+
value := trim(source[j+1:])
37+
if name != "" {
38+
ret := onAttr(lastPos, retCSS.Len(), name, value, source)
39+
if ret != "" {
40+
retCSS.WriteString(ret)
41+
retCSS.WriteString("; ")
42+
}
43+
}
44+
}
45+
}
46+
lastPos = i + 1
47+
}
48+
49+
for i < cssLen {
50+
c := css[i]
51+
52+
// 注释 /* ... */
53+
if c == '/' && i+1 < cssLen && css[i+1] == '*' {
54+
j := strings.Index(css[i+2:], "*/")
55+
if j == -1 {
56+
break
57+
}
58+
i = i + 2 + j + 1
59+
lastPos = i + 1
60+
isParenthesisOpen = false
61+
} else if c == '(' {
62+
isParenthesisOpen = true
63+
} else if c == ')' {
64+
isParenthesisOpen = false
65+
} else if c == ';' {
66+
if !isParenthesisOpen {
67+
addNewAttr()
68+
}
69+
} else if c == '\n' {
70+
addNewAttr()
71+
}
72+
73+
i++
74+
}
75+
76+
return trim(retCSS.String())
77+
}

0 commit comments

Comments
 (0)