mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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, "<script>")
|
||||
})
|
||||
|
||||
t.Run("escapes_html_entities", func(t *testing.T) {
|
||||
body := svc.buildVerifyCodeEmailBody("123456", `A&B<C>"D`)
|
||||
|
||||
assert.Contains(t, body, "A&B<C>"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, "<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&B<C>")
|
||||
})
|
||||
|
||||
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&b=2"`)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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), "</title><script>alert(1)</script><title>")
|
||||
})
|
||||
|
||||
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&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"}`)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ====================
|
||||
|
||||
Reference in New Issue
Block a user