实现一个 Gin 的 XSS 中间件
前言
本着造轮子不如用轮子的心态, 当然是搜一搜网上有没有现成的轮子了, 找了好大一圈发现只有一个
轮子 XssMw1(17 stars). 然后大概扫了一眼这个项目的代码, 还是蛮简单的, 注释也比较丰富,
但是代码里面有很多用于调试的 fmt.Printf, 作为一个有代码洁癖的人, 肯定是受不了这个. 而
且这个项目好像很久没有更新了, 差不多有一年了, 所以就决定自己造轮子了.
XssMw 底层是使用的 bluemonday2 这个库来过滤字符串, 本身只是做了一个解析请求, 再
过滤的操作. 看了下 bluemonday 有 1.7k stars, 库质量应该还是不错的. 所以我的思路和
XssMw 一致, 但尽量简化代码.
基本思路
基本思路就是先将请求参数进行解析, 然后对解析后里的字符串进行过滤, 过滤之后, 再重新组装成请 求参数.
参数解析
参数解析算是整个中间件里面最麻烦的事情了, 对于一些纯文本的参数来说就比较简单, 直接过滤就
行了. 但是对于 JSON, MessagePack 这种序列化格式来说就比较麻烦, 就需要先反序列化一下.
Plain Text
直接使用 bluemonday 过滤就行了. 因为 bluemonday 会进行 HTML 转义(写死在代码里面,
不可配置), 需要反转一下, 不过这里也有一个风险点, 如果输入里面包含了转义字符, 也会被转义.
func XSSFilterPlain(p *bluemonday.Policy, s string) string {
sanitized := p.Sanitize(s)
return html.UnescapeString(sanitized)
}JSON 解析
表面上 Go 里面解析 JSON 不像动态语言那样方便, 需要先定义类型. 但是别忘了, Go 里面
也有万能的 interface{}, 直接看代码:
func XSSFilterJSON(p *bluemonday.Policy, s string) (string, error) {
var data interface{}
err := json.Unmarshal([]byte(s), &data)
if err != nil {
return "", err
}
b := strings.Builder{}
e := json.NewEncoder(&b)
e.SetEscapeHTML(false)
err = e.Encode(xssFilterJSONData(p, data))
if err != nil {
return "", err
}
// use `TrimSpace` to trim newline char add by `Encode`.
return strings.TrimSpace(b.String()), nil
}
func xssFilterJSONData(p *bluemonday.Policy, data interface{}) interface{} {
if s, ok := data.([]interface{}); ok {
for i, v := range s {
s[i] = xssFilterJSONData(p, v)
}
return s
} else if m, ok := data.(map[string]interface{}); ok {
for k, v := range m {
m[k] = xssFilterJSONData(p, v)
}
return m
} else if str, ok := data.(string); ok {
return XSSFilterPlain(p, str)
}
return data
}Form 解析
GET 形式的 form 和 POST 形式的 form 本质一样, 只是一个放在 URL 里的, 一个放在
Request Body 里面的.
func XSSFilterQuery(p *bluemonday.Policy, s string) (string, error) {
values, err := url.ParseQuery(s)
if err != nil {
return "", err
}
for k, v := range values {
values.Del(k)
for _, vv := range v {
values.Add(k, XSSFilterPlain(p, vv))
}
}
return values.Encode(), nil
}Multipart Form 解析
Multipart form 是类似下面这种的结构, 我们可以直接当成纯文本解析即可.
------WebKitFormBoundaryAXJUb5Hid7KfCup1
Content-Disposition: form-data; name="f=>&name"
fdsf
------WebKitFormBoundaryAXJUb5Hid7KfCup1
Content-Disposition: form-data; name="lname"
jack
------WebKitFormBoundaryAXJUb5Hid7KfCup1--Gin 中间件
完成了上面这些, 编写中间件就简单了, 参考 gin.Context.ShouldBind 的实现, 先获取
binding, 根据 binding 的类型进行不同种类的过滤. 对于一些用户登录等包含密码的场景, 这个中
间件还需要提供白名单的机制. 对在白名单中的 URL 不进行过滤.
func XSS(whitelistURLs []string) gin.HandlerFunc {
// Do this once for each unique policy, and use the policy for the life of the
// program Policy creation/editing is not safe to use in multiple goroutines.
p := bluemonday.UGCPolicy()
return func(c *gin.Context) {
for _, u := range whitelistURLs {
if strings.HasPrefix(c.Request.URL.String(), u) {
c.Next()
return
}
}
sanitizedQuery, err := XSSFilterQuery(p, c.Request.URL.RawQuery)
if err != nil {
err = errors.Wrap(err, "filter query")
c.Error(err)
c.Abort()
return
}
c.Request.URL.RawQuery = sanitizedQuery
var sanitizedBody string
bding := binding.Default(c.Request.Method, c.ContentType())
body, err := c.GetRawData()
if err != nil {
err = errors.Wrap(err, "read body")
c.Error(err)
c.Abort()
return
}
// XSSFilterJSON() will return error when body is empty.
if len(body) == 0 {
c.Next()
return
}
switch bding {
case binding.JSON:
sanitizedBody, err = XSSFilterJSON(p, string(body))
err = errors.Wrap(err, "filter json")
case binding.FormMultipart:
sanitizedBody = XSSFilterPlain(p, string(body))
case binding.Form:
sanitizedBody, err = XSSFilterQuery(p, string(body))
err = errors.Wrap(err, "filter form")
}
if err != nil {
c.Error(err)
c.Abort()
return
}
c.Request.Body = ioutil.NopCloser(strings.NewReader(sanitizedBody))
c.Next()
}
}总结
这个简单的 XSS 中间件总共也就 100 多行代码, 相比 XssMw 的 600 多行少了很多, 不过基本功
能差别不大, 有以下几点可以继续优化.
- 更强大的白名单过滤, 支持类似
/posts/{id}/commentsURL - 解决纯文本过滤中的
HTML转义问题. - 支持
XML,MessagePack格式