Commit bd4bf008 authored by yangjianbo's avatar yangjianbo
Browse files

feat(安全): 强化安全策略与配置校验

- 增加 CORS/CSP/安全响应头与代理信任配置

- 引入 URL 白名单与私网开关,校验上游与价格源

- 改善 API Key 处理与网关错误返回

- 管理端设置隐藏敏感字段并优化前端提示

- 增加计费熔断与相关配置示例

测试: go test ./...
parent 3fd9bd4a
......@@ -197,6 +197,7 @@ export default {
registrationFailed: 'Registration failed. Please try again.',
loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
reloginRequired: 'Session expired. Please log in again.',
turnstileExpired: 'Verification expired, please try again',
turnstileFailed: 'Verification failed, please try again',
completeVerification: 'Please complete the verification',
......@@ -991,13 +992,13 @@ export default {
'One sessionKey per line, e.g.:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...',
sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...',
howToGetSessionKey: 'How to get sessionKey',
step1: 'Login to <strong>claude.ai</strong> in your browser',
step2: 'Press <kbd>F12</kbd> to open Developer Tools',
step3: 'Go to <strong>Application</strong> tab',
step4: 'Find <strong>Cookies</strong> → <strong>https://claude.ai</strong>',
step5: 'Find the row with key <strong>sessionKey</strong>',
step6: 'Copy the <strong>Value</strong>',
sessionKeyFormat: 'sessionKey usually starts with <code>sk-ant-sid01-</code>',
step1: 'Login to claude.ai in your browser',
step2: 'Press F12 to open Developer Tools',
step3: 'Go to Application tab',
step4: 'Find Cookies → https://claude.ai',
step5: 'Find the row with key sessionKey',
step6: 'Copy the Value',
sessionKeyFormat: 'sessionKey usually starts with sk-ant-sid01-',
startAutoAuth: 'Start Auto-Auth',
authorizing: 'Authorizing...',
followSteps: 'Follow these steps to authorize your Claude account:',
......@@ -1009,10 +1010,10 @@ export default {
openUrlDesc:
'Open the authorization URL in a new tab, log in to your Claude account and authorize.',
proxyWarning:
'<strong>Note:</strong> If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.',
'Note: If you configured a proxy, make sure your browser uses the same proxy to access the authorization page.',
step3EnterCode: 'Enter the Authorization Code',
authCodeDesc:
'After authorization is complete, the page will display an <strong>Authorization Code</strong>. Copy and paste it below:',
'After authorization is complete, the page will display an Authorization Code. Copy and paste it below:',
authCode: 'Authorization Code',
authCodePlaceholder: 'Paste the Authorization Code from Claude page...',
authCodeHint: 'Paste the Authorization Code copied from the Claude page',
......@@ -1033,10 +1034,10 @@ export default {
openUrlDesc:
'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.',
importantNotice:
'<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to <code>http://localhost...</code>, the authorization is complete.',
'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to http://localhost..., the authorization is complete.',
step3EnterCode: 'Enter Authorization URL or Code',
authCodeDesc:
'After authorization is complete, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
'After authorization is complete, when the page URL becomes http://localhost:xxx/auth/callback?code=...:',
authCode: 'Authorization URL or Code',
authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
......@@ -1059,7 +1060,7 @@ export default {
'Open the authorization URL in a new tab, log in to your Google account and authorize.',
step3EnterCode: 'Enter Authorization URL or Code',
authCodeDesc:
'After authorization, copy the callback URL (recommended) or just the <code>code</code> and paste it below.',
'After authorization, copy the callback URL (recommended) or just the code and paste it below.',
authCode: 'Callback URL or Code',
authCodePlaceholder:
'Option 1 (recommended): Paste the callback URL\nOption 2: Paste only the code value',
......@@ -1100,10 +1101,10 @@ export default {
step2OpenUrl: 'Open the URL in your browser and complete authorization',
openUrlDesc: 'Open the authorization URL in a new tab, log in to your Google account and authorize.',
importantNotice:
'<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows <code>http://localhost...</code>, authorization is complete.',
'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows http://localhost..., authorization is complete.',
step3EnterCode: 'Enter Authorization URL or Code',
authCodeDesc:
'After authorization, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
'After authorization, when the page URL becomes http://localhost:xxx/auth/callback?code=...:',
authCode: 'Authorization URL or Code',
authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
......@@ -1377,7 +1378,8 @@ export default {
siteKey: 'Site Key',
secretKey: 'Secret Key',
siteKeyHint: 'Get this from your Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)'
secretKeyHint: 'Server-side verification key (keep this secret)',
secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.'
},
defaults: {
title: 'Default User Settings',
......@@ -1428,6 +1430,8 @@ export default {
password: 'SMTP Password',
passwordPlaceholder: '********',
passwordHint: 'Leave empty to keep existing password',
passwordConfiguredPlaceholder: '********',
passwordConfiguredHint: 'Password configured. Leave empty to keep the current value.',
fromEmail: 'From Email',
fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: 'From Name',
......
......@@ -194,6 +194,7 @@ export default {
registrationFailed: '注册失败,请重试。',
loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
reloginRequired: '会话已过期,请重新登录。',
turnstileExpired: '验证已过期,请重试',
turnstileFailed: '验证失败,请重试',
completeVerification: '请完成验证',
......@@ -1137,13 +1138,13 @@ export default {
'每行一个 sessionKey,例如:\nsk-ant-sid01-xxxxx...\nsk-ant-sid01-yyyyy...',
sessionKeyPlaceholderSingle: 'sk-ant-sid01-xxxxx...',
howToGetSessionKey: '如何获取 sessionKey',
step1: '在浏览器中登录 <strong>claude.ai</strong>',
step2: '<kbd>F12</kbd> 打开开发者工具',
step3: '切换到 <strong>Application</strong> 标签',
step4: '找到 <strong>Cookies</strong> → <strong>https://claude.ai</strong>',
step5: '找到 <strong>sessionKey</strong> 所在行',
step6: '复制 <strong>Value</strong> 列的值',
sessionKeyFormat: 'sessionKey 通常以 <code>sk-ant-sid01-</code> 开头',
step1: '在浏览器中登录 claude.ai',
step2: 'F12 打开开发者工具',
step3: '切换到 Application 标签',
step4: '找到 Cookies → https://claude.ai',
step5: '找到 sessionKey 所在行',
step6: '复制 Value 列的值',
sessionKeyFormat: 'sessionKey 通常以 sk-ant-sid01- 开头',
startAutoAuth: '开始自动授权',
authorizing: '授权中...',
followSteps: '按照以下步骤授权您的 Claude 账号:',
......@@ -1154,9 +1155,9 @@ export default {
step2OpenUrl: '在浏览器中打开 URL 并完成授权',
openUrlDesc: '在新标签页中打开授权 URL,登录您的 Claude 账号并授权。',
proxyWarning:
'<strong>注意:</strong>如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
'注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。',
step3EnterCode: '输入授权码',
authCodeDesc: '授权完成后,页面会显示一个 <strong>授权码</strong>。复制并粘贴到下方:',
authCodeDesc: '授权完成后,页面会显示一个授权码。复制并粘贴到下方:',
authCode: '授权码',
authCodePlaceholder: '粘贴 Claude 页面的授权码...',
authCodeHint: '粘贴从 Claude 页面复制的授权码',
......@@ -1176,10 +1177,10 @@ export default {
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。',
importantNotice:
'<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
'重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。',
step3EnterCode: '输入授权链接或 Code',
authCodeDesc:
'授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
'授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:',
authCode: '授权链接或 Code',
authCodePlaceholder:
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
......@@ -1198,7 +1199,7 @@ export default {
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
step3EnterCode: '输入回调链接或 Code',
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 <code>code</code>,粘贴到下方即可。',
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。',
authCode: '回调链接或 Code',
authCodePlaceholder: '方式1(推荐):粘贴回调链接\n方式2:仅粘贴 code 参数的值',
authCodeHint: '系统会自动从链接中解析 code/state。',
......@@ -1233,10 +1234,10 @@ export default {
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
importantNotice:
'<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
'重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。',
step3EnterCode: '输入授权链接或 Code',
authCodeDesc:
'授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
'授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:',
authCode: '授权链接或 Code',
authCodePlaceholder:
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
......@@ -1576,7 +1577,8 @@ export default {
siteKey: '站点密钥',
secretKey: '私密密钥',
siteKeyHint: '从 Cloudflare Dashboard 获取',
secretKeyHint: '服务端验证密钥(请保密)'
secretKeyHint: '服务端验证密钥(请保密)',
secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。'
},
defaults: {
title: '用户默认设置',
......@@ -1626,6 +1628,8 @@ export default {
password: 'SMTP 密码',
passwordPlaceholder: '********',
passwordHint: '留空以保留现有密码',
passwordConfiguredPlaceholder: '********',
passwordConfiguredHint: '密码已配置,留空以保留当前值。',
fromEmail: '发件人邮箱',
fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: '发件人名称',
......
/**
* 验证并规范化 URL
* 默认只接受绝对 URL(以 http:// 或 https:// 开头),可按需允许相对路径
* @param value 用户输入的 URL
* @returns 规范化后的 URL,如果无效则返回空字符串
*/
type SanitizeOptions = {
allowRelative?: boolean
}
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
if (options.allowRelative && trimmed.startsWith('/')) {
return trimmed
}
// 只接受绝对 URL,不使用 base URL 来避免相对路径被解析为当前域名
// 检查是否以 http:// 或 https:// 开头
if (!trimmed.match(/^https?:\/\//i)) {
return ''
}
try {
const parsed = new URL(trimmed)
const protocol = parsed.protocol.toLowerCase()
if (protocol !== 'http:' && protocol !== 'https:') {
return ''
}
return parsed.toString()
} catch {
return ''
}
}
......@@ -493,6 +493,7 @@ import { useI18n } from 'vue-i18n'
import { getPublicSettings } from '@/api/auth'
import { useAuthStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import { sanitizeUrl } from '@/utils/url'
const { t } = useI18n()
......@@ -549,9 +550,9 @@ onMounted(async () => {
try {
const settings = await getPublicSettings()
siteName.value = settings.site_name || 'Sub2API'
siteLogo.value = settings.site_logo || ''
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
siteSubtitle.value = settings.site_subtitle || 'AI API Gateway Platform'
docUrl.value = settings.doc_url || ''
docUrl.value = sanitizeUrl(settings.doc_url || '', { allowRelative: true })
} catch (error) {
console.error('Failed to load public settings:', error)
}
......
......@@ -255,7 +255,11 @@
placeholder="0x4AAAAAAA..."
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.turnstile.secretKeyHint') }}
{{
form.turnstile_secret_key_configured
? t('admin.settings.turnstile.secretKeyConfiguredHint')
: t('admin.settings.turnstile.secretKeyHint')
}}
</p>
</div>
</div>
......@@ -577,10 +581,18 @@
v-model="form.smtp_password"
type="password"
class="input"
:placeholder="t('admin.settings.smtp.passwordPlaceholder')"
:placeholder="
form.smtp_password_configured
? t('admin.settings.smtp.passwordConfiguredPlaceholder')
: t('admin.settings.smtp.passwordPlaceholder')
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.smtp.passwordHint') }}
{{
form.smtp_password_configured
? t('admin.settings.smtp.passwordConfiguredHint')
: t('admin.settings.smtp.passwordHint')
}}
</p>
</div>
<div>
......@@ -713,7 +725,7 @@
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
import type { SystemSettings } from '@/api/admin/settings'
import type { SystemSettings, UpdateSettingsRequest } from '@/api/admin/settings'
import AppLayout from '@/components/layout/AppLayout.vue'
import Toggle from '@/components/common/Toggle.vue'
import { useAppStore } from '@/stores'
......@@ -735,7 +747,12 @@ const adminApiKeyMasked = ref('')
const adminApiKeyOperating = ref(false)
const newAdminApiKey = ref('')
const form = reactive<SystemSettings>({
type SettingsForm = SystemSettings & {
smtp_password: string
turnstile_secret_key: string
}
const form = reactive<SettingsForm>({
registration_enabled: true,
email_verify_enabled: false,
default_balance: 0,
......@@ -750,13 +767,15 @@ const form = reactive<SystemSettings>({
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_password_configured: false,
smtp_from_email: '',
smtp_from_name: '',
smtp_use_tls: true,
// Cloudflare Turnstile
turnstile_enabled: false,
turnstile_site_key: '',
turnstile_secret_key: ''
turnstile_secret_key: '',
turnstile_secret_key_configured: false
})
function handleLogoUpload(event: Event) {
......@@ -802,6 +821,8 @@ async function loadSettings() {
try {
const settings = await adminAPI.settings.getSettings()
Object.assign(form, settings)
form.smtp_password = ''
form.turnstile_secret_key = ''
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
......@@ -814,7 +835,32 @@ async function loadSettings() {
async function saveSettings() {
saving.value = true
try {
await adminAPI.settings.updateSettings(form)
const payload: UpdateSettingsRequest = {
registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled,
default_balance: form.default_balance,
default_concurrency: form.default_concurrency,
site_name: form.site_name,
site_logo: form.site_logo,
site_subtitle: form.site_subtitle,
api_base_url: form.api_base_url,
contact_info: form.contact_info,
doc_url: form.doc_url,
smtp_host: form.smtp_host,
smtp_port: form.smtp_port,
smtp_username: form.smtp_username,
smtp_password: form.smtp_password || undefined,
smtp_from_email: form.smtp_from_email,
smtp_from_name: form.smtp_from_name,
smtp_use_tls: form.smtp_use_tls,
turnstile_enabled: form.turnstile_enabled,
turnstile_site_key: form.turnstile_site_key,
turnstile_secret_key: form.turnstile_secret_key || undefined
}
const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated)
form.smtp_password = ''
form.turnstile_secret_key = ''
// Refresh cached public settings so sidebar/header update immediately
await appStore.fetchPublicSettings(true)
appStore.showSuccess(t('admin.settings.settingsSaved'))
......
......@@ -277,6 +277,14 @@ const errors = reactive({
// ==================== Lifecycle ====================
onMounted(async () => {
const expiredFlag = sessionStorage.getItem('auth_expired')
if (expiredFlag) {
sessionStorage.removeItem('auth_expired')
const message = t('auth.reloginRequired')
errorMessage.value = message
appStore.showWarning(message)
}
try {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment