fix(security): HTML-escape site_name 并对 doc_url 统一应用 sanitizeUrl

Refs #3839 (第 8、12 点)
This commit is contained in:
li
2026-07-09 10:03:49 +08:00
parent 6f43986c37
commit bfb827b879
9 changed files with 135 additions and 7 deletions
@@ -1,6 +1,7 @@
package admin
import (
"html"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
@@ -163,7 +164,7 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
<body>
<div class="container">
<div class="header">
<h1>` + siteName + `</h1>
<h1>` + html.EscapeString(siteName) + `</h1>
</div>
<div class="content">
<div class="success">✓</div>
@@ -0,0 +1,67 @@
//go:build unit
package service
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildVerifyCodeEmailBody_EscapesSiteName(t *testing.T) {
svc := &EmailService{}
t.Run("escapes_script_injection", func(t *testing.T) {
body := svc.buildVerifyCodeEmailBody("123456", `</h1><script>alert(1)</script><h1>`)
assert.NotContains(t, body, "<script>")
assert.Contains(t, body, "&lt;script&gt;")
})
t.Run("escapes_html_entities", func(t *testing.T) {
body := svc.buildVerifyCodeEmailBody("123456", `A&B<C>"D`)
assert.Contains(t, body, "A&amp;B&lt;C&gt;&#34;D")
})
t.Run("normal_site_name_unchanged", func(t *testing.T) {
body := svc.buildVerifyCodeEmailBody("654321", "My Site")
assert.Contains(t, body, "<h1>My Site</h1>")
assert.Contains(t, body, "654321")
})
}
func TestBuildPasswordResetEmailBody_EscapesSiteName(t *testing.T) {
svc := &EmailService{}
t.Run("escapes_html_tags_in_site_name", func(t *testing.T) {
body := svc.buildPasswordResetEmailBody("https://example.com/reset?token=abc", `</h1><img src=x onerror=alert(1)>`)
assert.NotContains(t, body, "<img src=x")
assert.True(t, strings.Contains(body, "&lt;img"))
})
t.Run("escapes_html_entities", func(t *testing.T) {
body := svc.buildPasswordResetEmailBody("https://example.com/reset", `A&B<C>`)
assert.Contains(t, body, "A&amp;B&lt;C&gt;")
})
t.Run("normal_site_name_and_url_unchanged", func(t *testing.T) {
resetURL := "https://example.com/reset?token=xyz"
body := svc.buildPasswordResetEmailBody(resetURL, "Sub2API")
assert.Contains(t, body, "<h1>Sub2API</h1>")
assert.Contains(t, body, resetURL)
})
t.Run("escapes_ampersand_in_reset_url", func(t *testing.T) {
resetURL := "https://example.com/reset?a=1&b=2"
body := svc.buildPasswordResetEmailBody(resetURL, "Site")
assert.NotContains(t, body, `href="https://example.com/reset?a=1&b=2"`)
assert.Contains(t, body, `href="https://example.com/reset?a=1&amp;b=2"`)
})
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"crypto/tls"
"encoding/hex"
"fmt"
"html"
"log/slog"
"math/big"
"net"
@@ -454,7 +455,7 @@ func (s *EmailService) buildVerifyCodeEmailBody(code, siteName string) string {
</div>
</body>
</html>
`, siteName, code)
`, html.EscapeString(siteName), code)
}
// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接
@@ -673,5 +674,5 @@ func (s *EmailService) buildPasswordResetEmailBody(resetURL, siteName string) st
</div>
</body>
</html>
`, siteName, resetURL, resetURL)
`, html.EscapeString(siteName), html.EscapeString(resetURL), html.EscapeString(resetURL))
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"context"
"embed"
"encoding/json"
htmlpkg "html"
"io"
"io/fs"
"net/http"
@@ -230,7 +231,7 @@ func injectSiteTitle(html, settingsJSON []byte) []byte {
return html
}
newTitle := []byte("<title>" + cfg.SiteName + " - AI API Gateway</title>")
newTitle := []byte("<title>" + htmlpkg.EscapeString(cfg.SiteName) + " - AI API Gateway</title>")
var buf bytes.Buffer
buf.Write(html[:titleStart])
buf.Write(newTitle)
+19
View File
@@ -79,6 +79,25 @@ func TestInjectSiteTitle(t *testing.T) {
assert.Equal(t, string(html), string(result))
})
t.Run("escapes_html_in_site_name", func(t *testing.T) {
html := []byte(`<html><head><title>Sub2API - AI API Gateway</title></head><body></body></html>`)
settingsJSON := []byte(`{"site_name":"</title><script>alert(1)</script><title>"}`)
result := injectSiteTitle(html, settingsJSON)
assert.NotContains(t, string(result), "<script>")
assert.Contains(t, string(result), "&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt;&lt;title&gt;")
})
t.Run("escapes_ampersand_in_site_name", func(t *testing.T) {
html := []byte(`<html><head><title>Sub2API</title></head><body></body></html>`)
settingsJSON := []byte(`{"site_name":"A&B"}`)
result := injectSiteTitle(html, settingsJSON)
assert.Contains(t, string(result), "<title>A&amp;B - AI API Gateway</title>")
})
t.Run("preserves_rest_of_html", func(t *testing.T) {
html := []byte(`<html><head><meta charset="UTF-8"><title>Sub2API</title><script src="app.js"></script></head><body><div id="app"></div></body></html>`)
settingsJSON := []byte(`{"site_name":"TestSite"}`)