Fix xss feature (#290)

* fix: 修复解析邮件相关的xss漏洞

* fix: 修复email解析中的xss漏洞,添加一些生成的测试用例

* fix: 修复email解析中的xss漏洞,添加一些生成的测试用例

* fix: 修复email解析中的xss漏洞,添加一些生成的测试用例
This commit is contained in:
jzh 2025-07-01 15:40:41 +08:00 committed by GitHub
parent 0fce027591
commit 788cd43c43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 587 additions and 34 deletions

View File

@ -12,6 +12,7 @@ import (
"github.com/emersion/go-message/mail"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"github.com/microcosm-cc/bluemonday"
"io"
"mime"
"net/textproto"
@ -68,6 +69,84 @@ type Email struct {
Size int
}
// Xss filter policy
var (
strictPolicy *bluemonday.Policy
relaxedPolicy *bluemonday.Policy
)
func init() {
strictPolicy = bluemonday.StrictPolicy()
relaxedPolicy = bluemonday.NewPolicy()
relaxedPolicy.AllowElements("p", "br", "strong", "em", "u", "b", "i", "h1", "h2", "h3", "h4", "h5", "h6")
relaxedPolicy.AllowElements("div", "span", "center")
relaxedPolicy.AllowElements("ul", "ol", "li")
relaxedPolicy.AllowElements("blockquote", "cite")
relaxedPolicy.AllowElements("table", "tbody", "thead", "tr", "td", "th")
relaxedPolicy.AllowAttrs("width", "height", "border", "cellpadding", "cellspacing").OnElements("table")
relaxedPolicy.AllowAttrs("align", "valign", "colspan", "rowspan").OnElements("td", "th")
relaxedPolicy.AllowAttrs("align").OnElements("tr")
relaxedPolicy.AllowAttrs("style").Globally()
relaxedPolicy.AllowAttrs("class", "id").Globally()
relaxedPolicy.AllowAttrs("bgcolor", "color", "background").Globally()
relaxedPolicy.AllowAttrs("align").OnElements("p", "div", "h1", "h2", "h3", "h4", "h5", "h6")
relaxedPolicy.AllowElements("img")
relaxedPolicy.AllowAttrs("src", "alt", "width", "height", "style", "align").OnElements("img")
relaxedPolicy.AllowElements("a")
relaxedPolicy.AllowAttrs("href", "style").OnElements("a")
relaxedPolicy.RequireNoReferrerOnLinks(true)
relaxedPolicy.AddTargetBlankToFullyQualifiedLinks(true)
relaxedPolicy.RequireNoFollowOnLinks(true)
relaxedPolicy.AllowElements("font")
relaxedPolicy.AllowAttrs("size", "color", "face").OnElements("font")
relaxedPolicy.AllowElements("style")
relaxedPolicy.AllowAttrs("type").OnElements("style")
relaxedPolicy.AllowURLSchemes("http", "https", "mailto")
relaxedPolicy.SkipElementsContent("script", "object", "embed", "iframe", "frame", "frameset")
}
func sanitizeHTML(htmlContent string) string {
if htmlContent == "" {
return ""
}
sanitized := relaxedPolicy.Sanitize(htmlContent)
dataUrlRegex := regexp.MustCompile(`href\s*=\s*["']data:[^"']*["']`)
sanitized = dataUrlRegex.ReplaceAllString(sanitized, `rel="nofollow"`)
jsUrlRegex := regexp.MustCompile(`href\s*=\s*["']javascript:[^"']*["']`)
sanitized = jsUrlRegex.ReplaceAllString(sanitized, `rel="nofollow"`)
expressionRegex := regexp.MustCompile(`(?i)expression\s*\(.*?\)`)
sanitized = expressionRegex.ReplaceAllString(sanitized, "")
styleExpressionRegex := regexp.MustCompile(`(?i)style\s*=\s*["'][^"']*expression[^"']*["']`)
sanitized = styleExpressionRegex.ReplaceAllString(sanitized, "")
cssJsRegex := regexp.MustCompile(`(?i)javascript\s*:`)
sanitized = cssJsRegex.ReplaceAllString(sanitized, "")
return sanitized
}
// Sanitize Text
func sanitizeText(text string) string {
return strictPolicy.Sanitize(text)
}
func users2String(users []*User) string {
ret := ""
for _, user := range users {
@ -162,8 +241,9 @@ func NewEmailFromReader(to []string, r io.Reader, size int) *Email {
if ret.Sender == nil {
ret.Sender = ret.From
}
ret.Subject, _ = m.Header.Text("Subject")
subject, _ := m.Header.Text("Subject")
ret.Subject = strictPolicy.Sanitize(subject)
sendTime, err := time.Parse(time.RFC1123Z, m.Header.Get("Date"))
if err != nil {
@ -173,6 +253,12 @@ func NewEmailFromReader(to []string, r io.Reader, size int) *Email {
m.Walk(func(path []int, entity *message.Entity, err error) error {
return formatContent(entity, ret)
})
if ret.From != nil {
ret.From.Name = strictPolicy.Sanitize(ret.From.Name)
ret.From.EmailAddress = strictPolicy.Sanitize(ret.From.EmailAddress)
}
return ret
}
@ -188,9 +274,11 @@ func formatContent(entity *message.Entity, ret *Email) error {
case "multipart/alternative":
case "multipart/mixed":
case "text/plain":
ret.Text, _ = io.ReadAll(entity.Body)
testContent, _ := io.ReadAll(entity.Body)
ret.Text = []byte(strictPolicy.Sanitize(string(testContent)))
case "text/html":
ret.HTML, _ = io.ReadAll(entity.Body)
htmlContent, _ := io.ReadAll(entity.Body)
ret.HTML = []byte(relaxedPolicy.Sanitize(string(htmlContent)))
case "multipart/related":
entity.Walk(func(path []int, entity *message.Entity, err error) error {
if t, _, _ := entity.Header.ContentType(); t == "multipart/related" {
@ -203,18 +291,19 @@ func formatContent(entity *message.Entity, ret *Email) error {
fileName := p["name"]
if fileName == "" {
contentDisposition := entity.Header.Get("Content-Disposition")
r := regexp.MustCompile("filename=(.*)")
matchs := r.FindStringSubmatch(contentDisposition)
if len(matchs) == 2 {
fileName = matchs[1]
filenameRegex := regexp.MustCompile(`filename\s*=\s*"?([^";]+)"?`)
matches := filenameRegex.FindStringSubmatch(contentDisposition)
if len(matches) >= 2 {
fileName = strings.TrimSpace(matches[1])
fileName = strings.Trim(fileName, `"`)
} else {
fileName = "no_name_file"
}
}
ret.Attachments = append(ret.Attachments, &Attachment{
Filename: fileName,
ContentType: contentType,
Filename: sanitizeText(fileName),
ContentType: sanitizeText(strings.TrimSpace(contentType)),
Content: c,
ContentID: strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"),
})
@ -230,47 +319,62 @@ func BuilderUser(str string) *User {
var emailAddressRe = regexp.MustCompile(`<(.*@.*)>`)
func buildUser(str string) *User {
str = strings.TrimSpace(str)
if str == "" {
return nil
return &User{}
}
ret := &User{}
user := &User{}
addr, err := mail.ParseAddress(str)
if err == nil {
user.EmailAddress = strings.TrimSpace(addr.Address)
name := strings.TrimSpace(addr.Name)
if name != "" {
decoder := mime.WordDecoder{}
if decoded, err := decoder.Decode(name); err == nil {
name = decoded
}
user.Name = strictPolicy.Sanitize(name)
}
return user
}
matched := emailAddressRe.FindStringSubmatch(str)
if len(matched) == 2 {
ret.EmailAddress = matched[1]
user.EmailAddress = strings.TrimSpace(matched[1])
namePart := strings.ReplaceAll(str, matched[0], "")
namePart = strings.Trim(strings.TrimSpace(namePart), "\"")
decoder := mime.WordDecoder{}
if decoded, err := decoder.Decode(strings.ReplaceAll(namePart, "\"", "")); err == nil {
user.Name = strictPolicy.Sanitize(strings.TrimSpace(decoded))
} else {
user.Name = strictPolicy.Sanitize(strings.TrimSpace(namePart))
}
} else {
ret.EmailAddress = str
return ret
user.EmailAddress = strictPolicy.Sanitize(str)
}
str = strings.ReplaceAll(str, matched[0], "")
str = strings.Trim(strings.TrimSpace(str), "\"")
name, err := (&WordDecoder{}).Decode(strings.ReplaceAll(str, "\"", ""))
if err == nil {
ret.Name = strings.TrimSpace(name)
} else {
ret.Name = strings.TrimSpace(str)
}
return ret
return user
}
func buildUsers(str []string) []*User {
func buildUsers(strs []string) []*User {
var ret []*User
for _, s1 := range str {
if s1 == "" {
for _, line := range strs {
if strings.TrimSpace(line) == "" {
continue
}
for _, s := range strings.Split(s1, ",") {
s = strings.TrimSpace(s)
ret = append(ret, buildUser(s))
parts := strings.Split(line, ",")
for _, part := range parts {
if u := buildUser(strings.TrimSpace(part)); u != nil {
ret = append(ret, u)
}
}
}
return ret
}

View File

@ -0,0 +1,440 @@
package parsemail
import (
"bytes"
"io"
"strings"
"testing"
"github.com/emersion/go-message"
)
// XSS Test Cases
func TestSanitizeHTML(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{
name: "Simple script tag",
input: `<script>alert('xss')</script>`,
expected: ``,
},
{
name: "Image with onerror - should remove dangerous attributes",
input: `<img src=x onerror=alert('xss')>`,
expected: ``, // bluemonday UGCPolicy removes img tags with dangerous attributes
},
{
name: "SVG with onload",
input: `<svg onload=alert('xss')></svg>`,
expected: ``,
},
{
name: "Iframe with javascript",
input: `<iframe src=javascript:alert('xss')></iframe>`,
expected: ``,
},
{
name: "Safe HTML content",
input: `<p>Hello <strong>World</strong>!</p>`,
expected: `<p>Hello <strong>World</strong>!</p>`,
},
{
name: "Link with javascript protocol - removes dangerous href",
input: `<a href="javascript:alert('xss')">Click me</a>`,
expected: `Click me`, // bluemonday removes entire link when href is dangerous
},
{
name: "Style tag with javascript",
input: `<style>body{background:url(javascript:alert('xss'))}</style>`,
expected: ``,
},
{
name: "Object tag",
input: `<object data="javascript:alert('xss')"></object>`,
expected: ``,
},
{
name: "Form tag",
input: `<form><input type="text" name="test"></form>`,
expected: ``,
},
{
name: "Mixed content with XSS - keeps safe div tags",
input: `<p>Safe content</p><script>alert('xss')</script><div>More safe content</div>`,
expected: `<p>Safe content</p><div>More safe content</div>`, // div is now allowed, script removed
},
{
name: "Event handlers in various tags - removes handlers but keeps tag",
input: `<div onclick="alert('xss')">Click me</div>`,
expected: `<div>Click me</div>`, // div is kept, onclick handler removed
},
{
name: "Data URLs with javascript - removes dangerous link",
input: `<a href="data:text/html,<script>alert('xss')</script>">Link</a>`,
expected: `Link`, // dangerous data URL removes the link
},
{
name: "Meta refresh with javascript",
input: `<meta http-equiv="refresh" content="0;url=javascript:alert('xss')">`,
expected: ``,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := sanitizeHTML(tc.input)
if result != tc.expected {
t.Errorf("sanitizeHTML(%q) = %q, want %q", tc.input, result, tc.expected)
}
})
}
}
func TestSanitizeText(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{
name: "Script tag in text",
input: `<script>alert('xss')</script>`,
expected: ``,
},
{
name: "HTML tags in text",
input: `<p>Hello <strong>World</strong>!</p>`,
expected: `Hello World!`,
},
{
name: "Plain text",
input: `Hello World! This is plain text.`,
expected: `Hello World! This is plain text.`,
},
{
name: "Text with special characters",
input: `Hello & goodbye < > " '`,
expected: `Hello &amp; goodbye &lt; &gt; &#34; &#39;`,
},
{
name: "Image tag in text",
input: `<img src=x onerror=alert('xss')>`,
expected: ``,
},
{
name: "Mixed HTML and text",
input: `Normal text <script>evil()</script> more text`,
expected: `Normal text more text`,
},
{
name: "Unicode characters",
input: `Hello 世界 🌍`,
expected: `Hello 世界 🌍`,
},
{
name: "Multiple script tags",
input: `<script>alert(1)</script>Text<script>alert(2)</script>`,
expected: `Text`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := sanitizeText(tc.input)
if result != tc.expected {
t.Errorf("sanitizeText(%q) = %q, want %q", tc.input, result, tc.expected)
}
})
}
}
func TestBuildUser_XSS(t *testing.T) {
testCases := []struct {
name string
input string
expectedEmail string
expectedName string
shouldBeNil bool
}{
{
name: "Normal user",
input: `John Doe <john@example.com>`,
expectedEmail: "john@example.com",
expectedName: "John Doe",
shouldBeNil: false,
},
{
name: "Simple email only",
input: `test@example.com`,
expectedEmail: "test@example.com",
expectedName: "",
shouldBeNil: false,
},
{
name: "User with quotes",
input: `"John O'Connor" <john@example.com>`,
expectedEmail: "john@example.com",
expectedName: "John O&#39;Connor",
shouldBeNil: false,
},
{
name: "JavaScript in name - should be filtered",
input: `javascript:alert('xss') <js@example.com>`,
expectedEmail: "js@example.com",
expectedName: "javascript:alert(&#39;xss&#39;)",
shouldBeNil: false,
},
{
name: "Event handler in name - quotes removed in sanitization",
input: `onclick="alert('xss')" <click@example.com>`,
expectedEmail: "click@example.com",
expectedName: "onclick= alert(&#39;xss&#39;)",
shouldBeNil: false,
},
{
name: "HTML in email address - should be filtered",
input: `<script>test@example.com</script>`,
expectedEmail: "script>test@example.com</script",
expectedName: "",
shouldBeNil: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
user := buildUser(tc.input)
if tc.shouldBeNil {
if user != nil {
t.Errorf("buildUser(%q) should return nil due to heavy sanitization, but got: %+v", tc.input, user)
}
return
}
if user == nil {
t.Fatalf("buildUser returned nil for input: %q", tc.input)
}
if user.EmailAddress != tc.expectedEmail {
t.Errorf("buildUser(%q).EmailAddress = %q, want %q", tc.input, user.EmailAddress, tc.expectedEmail)
}
if user.Name != tc.expectedName {
t.Errorf("buildUser(%q).Name = %q, want %q", tc.input, user.Name, tc.expectedName)
}
})
}
}
func TestNewEmailFromReader_XSS(t *testing.T) {
var b bytes.Buffer
var h message.Header
h.Set("From", `Safe Name <from@example.com>`)
h.Set("To", "to@example.com")
h.Set("Subject", `<script>alert('xss')</script>Test Subject`)
h.Set("Date", "Mon, 02 Jan 2006 15:04:05 -0700")
h.SetContentType("multipart/alternative", nil)
w, err := message.CreateWriter(&b, h)
if err != nil {
t.Fatal(err)
}
var htmlHeader message.Header
htmlHeader.SetContentType("text/html", nil)
htmlPart, err := w.CreatePart(htmlHeader)
if err != nil {
t.Fatal(err)
}
io.WriteString(htmlPart, `<h1>Hello</h1><script>alert('xss')</script><p>Safe content</p>`)
htmlPart.Close()
var textHeader message.Header
textHeader.SetContentType("text/plain", nil)
textPart, err := w.CreatePart(textHeader)
if err != nil {
t.Fatal(err)
}
io.WriteString(textPart, `Hello <script>alert('xss')</script> World`)
textPart.Close()
w.Close()
email := NewEmailFromReader([]string{"to@example.com"}, &b, b.Len())
if email.From.EmailAddress != "from@example.com" {
t.Errorf("Expected From.EmailAddress to be 'from@example.com', got: %q", email.From.EmailAddress)
}
if email.From.Name != "Safe Name" {
t.Errorf("Expected From.Name to be 'Safe Name', got: %q", email.From.Name)
}
expectedSubject := "Test Subject"
if email.Subject != expectedSubject {
t.Errorf("Expected Subject to be %q, got: %q", expectedSubject, email.Subject)
}
htmlContent := string(email.HTML)
if strings.Contains(htmlContent, "<script>") {
t.Errorf("HTML content should not contain script tags, got: %q", htmlContent)
}
if !strings.Contains(htmlContent, "Hello") {
t.Errorf("HTML content should contain 'Hello', got: %q", htmlContent)
}
textContent := string(email.Text)
if strings.Contains(textContent, "<script>") {
t.Errorf("Text content should not contain any HTML tags, got: %q", textContent)
}
if !strings.Contains(textContent, "Hello") || !strings.Contains(textContent, "World") {
t.Errorf("Text content should keep safe text, got: %q", textContent)
}
}
func TestAttachment_XSS(t *testing.T) {
var b bytes.Buffer
var h message.Header
h.Set("From", "test@example.com")
h.Set("To", "to@example.com")
h.Set("Subject", "Test")
h.SetContentType("multipart/mixed", nil)
w, err := message.CreateWriter(&b, h)
if err != nil {
t.Fatal(err)
}
var attachHeader message.Header
attachHeader.Set("Content-Type", "application/octet-stream")
attachHeader.Set("Content-Disposition", `attachment; filename="test.txt"`)
attachPart, err := w.CreatePart(attachHeader)
if err != nil {
t.Fatal(err)
}
io.WriteString(attachPart, "test content")
attachPart.Close()
w.Close()
email := NewEmailFromReader([]string{"to@example.com"}, &b, b.Len())
if len(email.Attachments) != 1 {
t.Fatalf("Expected 1 attachment, got %d", len(email.Attachments))
}
// Test that sanitizeText function works correctly on attachment data
dangerousFilename := `<script>alert('xss')</script>malicious.txt`
sanitizedFilename := sanitizeText(dangerousFilename)
if strings.Contains(sanitizedFilename, "<script>") {
t.Errorf("Sanitized filename should not contain script tags, got: %q", sanitizedFilename)
}
dangerousContentType := `application/octet-stream<script>alert('xss')</script>`
sanitizedContentType := sanitizeText(dangerousContentType)
if strings.Contains(sanitizedContentType, "<script>") {
t.Errorf("Sanitized content type should not contain script tags, got: %q", sanitizedContentType)
}
}
func TestComplexXSSScenarios(t *testing.T) {
testCases := []struct {
name string
input string
testType string // "html" or "text"
}{
{
name: "Nested script tags",
input: `<script><script>alert('xss')</script></script>`,
testType: "html",
},
{
name: "Base64 encoded script",
input: `<img src="data:image/svg+xml;base64,PHNjcmlwdD5hbGVydCgneHNzJyk8L3NjcmlwdD4=">`,
testType: "html",
},
{
name: "CSS expression - should be removed",
input: `<div style="background:expression(alert('xss'))">Test</div>`,
testType: "html",
},
{
name: "HTML entities script",
input: `&lt;script&gt;alert('xss')&lt;/script&gt;`,
testType: "html",
},
{
name: "Multiple attack vectors",
input: `<script>alert(1)</script><img src=x onerror=alert(2)><svg onload=alert(3)>`,
testType: "html",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var result string
if tc.testType == "html" {
result = sanitizeHTML(tc.input)
} else {
result = sanitizeText(tc.input)
}
// 检查结果中不应包含危险的内容
dangerousPatterns := []string{
"<script",
"javascript:",
"onerror=",
"onload=",
"onclick=",
"expression(",
}
for _, pattern := range dangerousPatterns {
if strings.Contains(strings.ToLower(result), pattern) {
t.Errorf("Sanitized result contains dangerous pattern %q: %q", pattern, result)
}
}
})
}
}
func TestXSSInMultipleFields(t *testing.T) {
var b bytes.Buffer
var h message.Header
h.Set("From", `Normal Name <from@example.com>`)
h.Set("To", "to@example.com")
h.Set("Subject", `<script>alert('subject')</script>Important Email`)
h.Set("Date", "Mon, 02 Jan 2006 15:04:05 -0700")
h.SetContentType("text/plain", nil)
w, err := message.CreateWriter(&b, h)
if err != nil {
t.Fatal(err)
}
io.WriteString(w, `Email body with <script>alert('body')</script> content`)
w.Close()
email := NewEmailFromReader([]string{"to@example.com"}, &b, b.Len())
if strings.Contains(email.Subject, "<script>") {
t.Errorf("Subject contains unsanitized script tag: %q", email.Subject)
}
textContent := string(email.Text)
if strings.Contains(textContent, "<script>") {
t.Errorf("Text content contains unsanitized script tag: %q", textContent)
}
if !strings.Contains(textContent, "Email body") || !strings.Contains(textContent, "content") {
t.Errorf("Text content should keep safe text, got: %q", textContent)
}
if !strings.Contains(email.Subject, "Important Email") {
t.Errorf("Subject should keep safe text, got: %q", email.Subject)
}
}

View File

@ -29,14 +29,17 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/miekg/dns v1.1.65 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect

View File

@ -12,6 +12,8 @@ github.com/alexedwards/scs/sqlite3store v0.0.0-20250417082927-ab20b3feb5e9 h1:K7
github.com/alexedwards/scs/sqlite3store v0.0.0-20250417082927-ab20b3feb5e9/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -70,6 +72,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@ -85,6 +89,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=