From bfb827b879f3b41dac899b6c044f9510b8abc8fd Mon Sep 17 00:00:00 2001 From: li Date: Thu, 9 Jul 2026 10:03:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20HTML-escape=20site=5Fname=20?= =?UTF-8?q?=E5=B9=B6=E5=AF=B9=20doc=5Furl=20=E7=BB=9F=E4=B8=80=E5=BA=94?= =?UTF-8?q?=E7=94=A8=20sanitizeUrl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #3839 (第 8、12 点) --- .../handler/admin/setting_handler_email.go | 3 +- .../service/email_html_escape_test.go | 67 +++++++++++++++++++ backend/internal/service/email_service.go | 5 +- backend/internal/web/embed_on.go | 3 +- backend/internal/web/embed_test.go | 19 ++++++ frontend/src/components/layout/AppHeader.vue | 3 +- .../__tests__/docUrlSanitization.spec.ts | 36 ++++++++++ frontend/src/views/HomeView.vue | 3 +- frontend/src/views/KeyUsageView.vue | 3 +- 9 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 backend/internal/service/email_html_escape_test.go create mode 100644 frontend/src/components/layout/__tests__/docUrlSanitization.spec.ts diff --git a/backend/internal/handler/admin/setting_handler_email.go b/backend/internal/handler/admin/setting_handler_email.go index 9ff0529a5b..68d76de6cc 100644 --- a/backend/internal/handler/admin/setting_handler_email.go +++ b/backend/internal/handler/admin/setting_handler_email.go @@ -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) {
-

` + siteName + `

+

` + html.EscapeString(siteName) + `

✓
diff --git a/backend/internal/service/email_html_escape_test.go b/backend/internal/service/email_html_escape_test.go new file mode 100644 index 0000000000..d1e7aca3bc --- /dev/null +++ b/backend/internal/service/email_html_escape_test.go @@ -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", `

`) + + assert.NotContains(t, body, ""}`) + + result := injectSiteTitle(html, settingsJSON) + + assert.NotContains(t, string(result), "<script>") + assert.Contains(t, string(result), "</title><script>alert(1)</script><title>") + }) + + t.Run("escapes_ampersand_in_site_name", func(t *testing.T) { + html := []byte(`<html><head><title>Sub2API`) + settingsJSON := []byte(`{"site_name":"A&B"}`) + + result := injectSiteTitle(html, settingsJSON) + + assert.Contains(t, string(result), "A&B - AI API Gateway") + }) + t.Run("preserves_rest_of_html", func(t *testing.T) { html := []byte(`Sub2API
`) settingsJSON := []byte(`{"site_name":"TestSite"}`) diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index 126eddf6cc..d0e4526055 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -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(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)) diff --git a/frontend/src/components/layout/__tests__/docUrlSanitization.spec.ts b/frontend/src/components/layout/__tests__/docUrlSanitization.spec.ts new file mode 100644 index 0000000000..3b0c6062b2 --- /dev/null +++ b/frontend/src/components/layout/__tests__/docUrlSanitization.spec.ts @@ -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') + }) +}) diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 6a3753f1c1..d8eb176998 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -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) diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue index 8bc429ca3d..dda7590dce 100644 --- a/frontend/src/views/KeyUsageView.vue +++ b/frontend/src/views/KeyUsageView.vue @@ -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) ====================