diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4c409ee350..ab83333eb4 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -32,7 +32,7 @@ const ( // DefaultCSPPolicy is the default Content-Security-Policy with nonce support // __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware -const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" +const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // UMQ(用户消息队列)模式常量 const ( diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index 7f9a1fc93b..bb5d99abe2 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -31,6 +31,10 @@ const ( TencentCaptchaGlobalCDNDomain = "https://global.turing.captcha.gtimg.com" // TencentCaptchaPrehandleDomain 是天御 SDK 动态预处理脚本与预处理接口主机。 TencentCaptchaPrehandleDomain = "https://www.tycaptcha.com" + // TencentCaptchaJQueryDomain 是国内站入口脚本动态加载的 jQuery CDN 主机。 + TencentCaptchaJQueryDomain = "https://cloudcache.tencentcs.com" + // TencentCaptchaRceDomain 是国际站风控校验接口主机。 + TencentCaptchaRceDomain = "https://rce.tencentrio.com" // TencentCaptchaWorkerSource 是天御国际站创建验证码 Web Worker 时使用的来源。 TencentCaptchaWorkerSource = "blob:" // StripeDomain is the domain for Stripe.js SDK @@ -57,8 +61,10 @@ var requiredCSPDirectiveValues = []struct { {"script-src", TencentCaptchaGlobalDomain}, {"script-src", TencentCaptchaGlobalCDNDomain}, {"script-src", TencentCaptchaPrehandleDomain}, + {"script-src", TencentCaptchaJQueryDomain}, {"connect-src", TencentCaptchaDomain}, {"connect-src", TencentCaptchaPrehandleDomain}, + {"connect-src", TencentCaptchaRceDomain}, {"frame-src", TencentCaptchaGlobalDomain}, {"frame-src", TencentCaptchaPrehandleDomain}, {"worker-src", TencentCaptchaWorkerSource}, diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index 1fc36ccd85..cb355e0c4d 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -330,8 +330,10 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalDomain)) assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalCDNDomain)) assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaPrehandleDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaJQueryDomain)) assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaDomain)) assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaPrehandleDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaRceDomain)) assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaGlobalDomain)) assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaPrehandleDomain)) assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource)) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index fea375dedf..a6ef8a9813 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -181,7 +181,7 @@ security: # 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖) # Note: __CSP_NONCE__ will be replaced with 'nonce-xxx' at request time for inline script security # 注意:__CSP_NONCE__ 会在请求时被替换为 'nonce-xxx',用于内联脚本安全 - policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" proxy_probe: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) diff --git a/frontend/src/components/TencentCaptchaGate.vue b/frontend/src/components/TencentCaptchaGate.vue index 794b33bbd2..610ba8a94c 100644 --- a/frontend/src/components/TencentCaptchaGate.vue +++ b/frontend/src/components/TencentCaptchaGate.vue @@ -33,8 +33,12 @@ let instance: { show(): void; destroy(): void } | null = null let pending: Promise | null = null let cancelPending: (() => void) | null = null let cachedProof: TencentCaptchaProof | null = null +let cachedProofCreatedAt = 0 let isMounted = false +// 腾讯国际站票据官方有效期为 5 分钟,提前 1 分钟放弃缓存,避免提交时刚好过期。 +const cachedProofMaxAgeMs = 4 * 60 * 1000 + function createVerificationPromise(revealInternational: boolean = true): Promise { return new Promise((resolve, reject) => { let settled = false @@ -106,9 +110,17 @@ function createVerificationPromise(revealInternational: boolean = true): Promise function verify(): Promise { if (cachedProof) { - const proof = cachedProof - cachedProof = null - return Promise.resolve(proof) + if (Date.now() - cachedProofCreatedAt >= cachedProofMaxAgeMs) { + // 过期票据不能再次提交;先同步销毁旧实例,再在本次调用中创建新验证。 + reset(false) + } else { + const proof = cachedProof + cachedProof = null + cachedProofCreatedAt = 0 + // 预加载 promise 已经完成,消费缓存时同步清除引用,避免同一票据被并发复用。 + pending = null + return Promise.resolve(proof) + } } if (pending) { @@ -119,7 +131,10 @@ function verify(): Promise { if (pending === verification) instance?.show() }) return verification.then((proof) => { - if (cachedProof === proof) cachedProof = null + if (cachedProof === proof) { + cachedProof = null + cachedProofCreatedAt = 0 + } return proof }) } @@ -146,7 +161,10 @@ function preload(): void { pending = verification void verification .then((proof) => { - if (proof) cachedProof = proof + if (proof) { + cachedProof = proof + cachedProofCreatedAt = Date.now() + } }) .catch(() => undefined) .finally(() => { @@ -154,18 +172,19 @@ function preload(): void { }) } -function reset(): void { +function reset(reinitialize: boolean = true): void { instance?.destroy() instance = null cancelPending?.() cancelPending = null pending = null cachedProof = null + cachedProofCreatedAt = 0 internationalContainerVisible.value = isInternational.value // 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox, // 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。 - if (isMounted && isInternational.value) { + if (reinitialize && isMounted && isInternational.value) { void nextTick().then(() => { if (isMounted) preload() }) diff --git a/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts b/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts index 19c68d2394..d6ffc3bd43 100644 --- a/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts +++ b/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts @@ -1,7 +1,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue' -import { resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha' +import { loadTencentCaptcha, resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha' const locale = { value: 'zh' } @@ -197,6 +197,43 @@ describe('TencentCaptchaGate', () => { ) }) + it('refreshes a preloaded proof before the provider ticket expires', async () => { + vi.useFakeTimers() + try { + let callback: ((result: CaptchaResult) => void) | undefined + let constructorCount = 0 + window.TCaptchaGlobal = true + window.TencentCaptcha = class { + constructor(...received: unknown[]) { + constructorCount += 1 + callback = received[2] as (result: CaptchaResult) => void + } + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + const wrapper = mount(TencentCaptchaGate, { + props: { appId: '123456789', region: 'intl' } + }) + + await flushPromises() + callback?.({ ret: 0, ticket: 'expired-ticket', randstr: 'expired-rand' }) + await flushPromises() + vi.advanceTimersByTime(4 * 60 * 1000) + + const verification = wrapper.vm.verify() + await flushPromises() + expect(constructorCount).toBe(2) + + callback?.({ ret: 0, ticket: 'fresh-ticket', randstr: 'fresh-rand' }) + await expect(verification).resolves.toEqual({ + ticket: 'fresh-ticket', + randstr: 'fresh-rand' + }) + } finally { + vi.useRealTimers() + } + }) + it('keeps the three-argument form for the Chinese mainland site', async () => { const args: unknown[][] = [] window.TencentCaptcha = class { @@ -248,6 +285,26 @@ describe('TencentCaptchaGate', () => { ).not.toBeNull() }) + it('does not inject a second SDK when the region changes in one page', async () => { + const constructor = class { + constructor() {} + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + + const firstLoad = loadTencentCaptcha('cn') + const firstScript = document.head.querySelector('script[src*="TJCaptcha.js"]') + expect(firstScript).not.toBeNull() + window.TencentCaptcha = constructor + firstScript?.dispatchEvent(new Event('load')) + await expect(firstLoad).resolves.toBe(constructor) + + await expect(loadTencentCaptcha('intl')).rejects.toThrow( + 'Tencent Captcha region changed; reload the page to apply it' + ) + expect(document.head.querySelector('script[src*="TJNCaptcha-global.js"]')).toBeNull() + }) + it('settles a pending verification when reset', async () => { const destroy = vi.fn() window.TencentCaptcha = class { diff --git a/frontend/src/utils/tencentCaptcha.ts b/frontend/src/utils/tencentCaptcha.ts index 85b736a09c..48495549a3 100644 --- a/frontend/src/utils/tencentCaptcha.ts +++ b/frontend/src/utils/tencentCaptcha.ts @@ -79,6 +79,11 @@ export function loadTencentCaptcha( if (window.TencentCaptcha && (loadedRegion === region || globalRegion === region)) { return Promise.resolve(window.TencentCaptcha) } + if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) { + // 两个站点共享 TencentCaptcha 全局且构造签名不兼容,不能在同一页面注入第二份 SDK。 + // 管理员切换区域后由部署流程刷新页面,刷新前直接失败可避免全局构造函数被污染。 + return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it')) + } if (scriptPromise && loadedRegion === region) return scriptPromise loadedRegion = region