Merge pull request #3841 from fengshao1227/fix/html-escape-site-name-and-sanitize-doc-url

fix(security): HTML-escape site_name 并对 doc_url 统一应用 sanitizeUrl
This commit is contained in:
Wesley Liddick
2026-07-09 14:39:26 +08:00
committed by GitHub
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"}`)
+2 -1
View File
@@ -249,6 +249,7 @@ import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeUrl } from '@/utils/url'
const router = useRouter()
const route = useRoute()
@@ -262,7 +263,7 @@ const user = computed(() => authStore.user)
const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const contactInfo = computed(() => appStore.contactInfo)
const docUrl = computed(() => appStore.docUrl)
const docUrl = computed(() => sanitizeUrl(appStore.docUrl))
const avatarUrl = computed(() => user.value?.avatar_url?.trim() || '')
const availableBalance = computed(() => Number(user.value?.balance || 0))
const frozenBalance = computed(() => Number(user.value?.frozen_balance || 0))
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const dir = dirname(fileURLToPath(import.meta.url))
const headerSource = readFileSync(resolve(dir, '../AppHeader.vue'), 'utf8')
const homeViewSource = readFileSync(resolve(dir, '../../../views/HomeView.vue'), 'utf8')
const keyUsageViewSource = readFileSync(resolve(dir, '../../../views/KeyUsageView.vue'), 'utf8')
describe('doc_url sanitization', () => {
it('AppHeader imports sanitizeUrl', () => {
expect(headerSource).toContain("import { sanitizeUrl } from '@/utils/url'")
})
it('AppHeader applies sanitizeUrl to docUrl', () => {
expect(headerSource).toContain('sanitizeUrl(appStore.docUrl)')
})
it('HomeView imports sanitizeUrl', () => {
expect(homeViewSource).toContain("import { sanitizeUrl } from '@/utils/url'")
})
it('HomeView applies sanitizeUrl to docUrl', () => {
expect(homeViewSource).toContain('sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl')
})
it('KeyUsageView imports sanitizeUrl', () => {
expect(keyUsageViewSource).toContain("import { sanitizeUrl } from '@/utils/url'")
})
it('KeyUsageView applies sanitizeUrl to docUrl', () => {
expect(keyUsageViewSource).toContain('sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl')
})
})
+2 -1
View File
@@ -410,6 +410,7 @@ import { useI18n } from 'vue-i18n'
import { useAuthStore, useAppStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeUrl } from '@/utils/url'
const { t } = useI18n()
@@ -420,7 +421,7 @@ const appStore = useAppStore()
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || ''))
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
// Check if homeContent is a URL (for iframe display)
+2 -1
View File
@@ -423,6 +423,7 @@ import { useAppStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import Icon from '@/components/icons/Icon.vue'
import { buildGatewayUrl } from '@/api/client'
import { sanitizeUrl } from '@/utils/url'
const { t, locale } = useI18n()
const appStore = useAppStore()
@@ -431,7 +432,7 @@ const appStore = useAppStore()
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
const docUrl = computed(() => sanitizeUrl(appStore.cachedPublicSettings?.doc_url || appStore.docUrl || ''))
const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
// ==================== Theme (same as HomeView) ====================