fix: 完善腾讯验证码区域适配与 CSP 白名单

修复国内站和国际站 SDK 构造、验证容器、票据重置及动态资源加载问题,并补充认证流程回归测试。
This commit is contained in:
shaw
2026-08-06 20:34:37 +08:00
parent a19c9f8d8a
commit 8e102b3a0f
42 changed files with 922 additions and 70 deletions
+1 -1
View File
@@ -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'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.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:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.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://*.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'"
// UMQ(用户消息队列)模式常量
const (
@@ -168,6 +168,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
TencentCaptchaAppSecretKeyConfigured: settings.TencentCaptchaAppSecretKeyConfigured,
TencentCaptchaCloudSecretIDConfigured: settings.TencentCaptchaCloudSecretIDConfigured,
TencentCaptchaCloudSecretKeyConfigured: settings.TencentCaptchaCloudSecretKeyConfigured,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: settings.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecretConfigured: settings.AliyunCaptchaAccessKeySecretConfigured,
@@ -122,6 +122,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if req.TencentCaptchaCloudSecretKey != "" {
changed = append(changed, "tencent_captcha_cloud_secret_key")
}
if before.TencentCaptchaRegion != after.TencentCaptchaRegion {
changed = append(changed, "tencent_captcha_region")
}
if before.AliyunCaptchaEnabled != after.AliyunCaptchaEnabled {
changed = append(changed, "aliyun_captcha_enabled")
}
@@ -121,6 +121,43 @@ func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *
require.Equal(t, "stored-cloud-secret-key", repo.values[service.SettingKeyTencentCaptchaCloudSecretKey])
}
// 天御站点决定前端加载哪个 SDK 与服务端打哪个接入点,两端必须一致。
// 部分载荷把它重置回中国站,会让已配国际站的部署在下一次任意保存后整体失效。
func TestUpdateSettingsPartialPayloadKeepsTencentCaptchaRegion(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
})
rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionINTL,
repo.values[service.SettingKeyTencentCaptchaRegion])
}
func TestUpdateSettingsNormalizesUnknownTencentCaptchaRegion(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
})
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "sgp"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionCN,
repo.values[service.SettingKeyTencentCaptchaRegion],
"未知站点必须落回中国站,不能写入无法识别的值")
}
func TestUpdateSettingsWritesTencentCaptchaRegionWhenSent(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "intl"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionINTL,
repo.values[service.SettingKeyTencentCaptchaRegion])
}
func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *testing.T) {
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaEnabled: "true",
@@ -60,6 +60,7 @@ type UpdateSettingsRequest struct {
TencentCaptchaAppSecretKey string `json:"tencent_captcha_app_secret_key"`
TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"`
TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
// 阿里云验证码 2.0 设置
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
@@ -644,6 +645,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.AliyunCaptchaRegion != service.AliyunCaptchaRegionSGP {
req.AliyunCaptchaRegion = service.AliyunCaptchaRegionCN
}
// 天御站点 normalize:未发送保留已存值,非法值一律按中国站落库
if _, sent := sentFields["tencent_captcha_region"]; !sent {
req.TencentCaptchaRegion = previousSettings.TencentCaptchaRegion
}
if req.TencentCaptchaRegion != service.TencentCaptchaRegionINTL {
req.TencentCaptchaRegion = service.TencentCaptchaRegionCN
}
// Turnstile 参数验证
if req.TurnstileEnabled {
@@ -1501,6 +1509,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey,
TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID,
TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey,
TencentCaptchaRegion: req.TencentCaptchaRegion,
AliyunCaptchaEnabled: req.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret,
@@ -2084,6 +2093,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured,
TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured,
TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured,
TencentCaptchaRegion: updatedSettings.TencentCaptchaRegion,
AliyunCaptchaEnabled: updatedSettings.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: updatedSettings.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecretConfigured: updatedSettings.AliyunCaptchaAccessKeySecretConfigured,
@@ -6,21 +6,88 @@ import (
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestAuthRequestsBindTencentCaptchaProof(t *testing.T) {
const payload = `{"email":"user@example.com","password":"secret-123","tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`
var login LoginRequest
require.NoError(t, json.Unmarshal([]byte(payload), &login))
proof := captchaProof(login.TurnstileToken, login.TencentCaptchaTicket, login.TencentCaptchaRandstr)
require.Equal(t, "ticket-value", proof.TencentTicket)
require.Equal(t, "@rand-value", proof.TencentRandstr)
tests := []struct {
name string
decode func([]byte) service.CaptchaProof
}{
{
name: "登录",
decode: func(raw []byte) service.CaptchaProof {
var req LoginRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "注册",
decode: func(raw []byte) service.CaptchaProof {
var req RegisterRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "发送邮箱验证码",
decode: func(raw []byte) service.CaptchaProof {
var req SendVerifyCodeRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "忘记密码",
decode: func(raw []byte) service.CaptchaProof {
var req ForgotPasswordRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth启动",
decode: func(raw []byte) service.CaptchaProof {
var req oauthStartCaptchaRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "Passkey登录",
decode: func(raw []byte) service.CaptchaProof {
var req passkeyBeginLoginRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth待处理账号发送邮箱验证码",
decode: func(raw []byte) service.CaptchaProof {
var req sendPendingOAuthVerifyCodeRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth待处理账号创建",
decode: func(raw []byte) service.CaptchaProof {
var req createPendingOAuthAccountRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
}
var pending createPendingOAuthAccountRequest
require.NoError(t, json.Unmarshal([]byte(payload), &pending))
proof = captchaProof(pending.TurnstileToken, pending.TencentCaptchaTicket, pending.TencentCaptchaRandstr)
require.Equal(t, "ticket-value", proof.TencentTicket)
require.Equal(t, "@rand-value", proof.TencentRandstr)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
proof := test.decode([]byte(payload))
require.Equal(t, "ticket-value", proof.TencentTicket)
require.Equal(t, "@rand-value", proof.TencentRandstr)
})
}
}
+2
View File
@@ -64,6 +64,7 @@ type SystemSettings struct {
TencentCaptchaAppSecretKeyConfigured bool `json:"tencent_captcha_app_secret_key_configured"`
TencentCaptchaCloudSecretIDConfigured bool `json:"tencent_captcha_cloud_secret_id_configured"`
TencentCaptchaCloudSecretKeyConfigured bool `json:"tencent_captcha_cloud_secret_key_configured"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"`
AliyunCaptchaAccessKeySecretConfigured bool `json:"aliyun_captcha_access_key_secret_configured"`
@@ -354,6 +355,7 @@ type PublicSettings struct {
TurnstileSiteKey string `json:"turnstile_site_key"`
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
@@ -62,6 +62,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
TurnstileSiteKey: settings.TurnstileSiteKey,
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
TencentCaptchaAppID: settings.TencentCaptchaAppID,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
@@ -89,6 +89,7 @@ func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *
values: map[string]string{
service.SettingKeyTencentCaptchaEnabled: "true",
service.SettingKeyTencentCaptchaAppID: "123456789",
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
},
}
h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version")
@@ -106,12 +107,14 @@ func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *
Data struct {
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.True(t, resp.Data.TencentCaptchaEnabled)
require.Equal(t, "123456789", resp.Data.TencentCaptchaAppID)
require.Equal(t, service.TencentCaptchaRegionINTL, resp.Data.TencentCaptchaRegion)
}
func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
+21 -6
View File
@@ -113,13 +113,13 @@ func resolveCustomForwardedClientIP(c *gin.Context, headers []string) (string, s
func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) {
var fallback string
if forwarded := normalizeIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" {
if forwarded := normalizeValidIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" {
fallback = forwarded
if !isPrivateIP(forwarded) {
return forwarded, fallback
}
}
if realIP := normalizeIP(c.GetHeader("X-Real-IP")); realIP != "" {
if realIP := normalizeValidIP(c.GetHeader("X-Real-IP")); realIP != "" {
if fallback == "" {
fallback = realIP
}
@@ -130,13 +130,18 @@ func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
ips := strings.Split(xff, ",")
for _, candidate := range ips {
candidate = strings.TrimSpace(candidate)
candidate = normalizeValidIP(candidate)
if candidate != "" && !isPrivateIP(candidate) {
return normalizeIP(candidate), fallback
return candidate, fallback
}
}
if fallback == "" && len(ips) > 0 {
fallback = normalizeIP(strings.TrimSpace(ips[0]))
if fallback == "" {
for _, candidate := range ips {
if candidate = normalizeValidIP(candidate); candidate != "" {
fallback = candidate
break
}
}
}
}
return "", fallback
@@ -176,6 +181,16 @@ func normalizeIP(ip string) string {
return ip
}
// normalizeValidIP 规范化并验证代理头中的候选值,避免把 unknown、主机名等非法值传给安全服务。
func normalizeValidIP(value string) string {
normalized := normalizeIP(value)
parsed := net.ParseIP(normalized)
if parsed == nil {
return ""
}
return parsed.String()
}
// privateNets contains the private/loopback ranges skipped while selecting a
// public address from a legacy X-Forwarded-For chain.
var privateNets []*net.IPNet
+20
View File
@@ -153,6 +153,26 @@ func TestGetSecurityClientIPCustomHeaderPrecedenceAndFallback(t *testing.T) {
},
want: "4.4.4.4",
},
{
name: "invalid legacy values continue to a valid forwarded address",
trustForward: true,
requestHeaders: map[string]string{
"CF-Connecting-IP": "unknown",
"X-Real-IP": "proxy.internal",
"X-Forwarded-For": "also-invalid, 203.0.113.50",
},
want: "203.0.113.50",
},
{
name: "all invalid legacy values fall back to the connection address",
trustForward: true,
requestHeaders: map[string]string{
"CF-Connecting-IP": "unknown",
"X-Real-IP": "proxy.internal",
"X-Forwarded-For": "also-invalid",
},
want: "9.9.9.9",
},
{
name: "disabled mode ignores custom and legacy headers",
trustForward: false,
@@ -10,13 +10,11 @@ import (
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
)
const tencentCaptchaEndpoint = "captcha.tencentcloudapi.com"
type tencentCaptchaAPI interface {
DescribeCaptchaResultWithContext(context.Context, *captcha.DescribeCaptchaResultRequest) (*captcha.DescribeCaptchaResultResponse, error)
}
type tencentCaptchaClientFactory func(secretID, secretKey string) (tencentCaptchaAPI, error)
type tencentCaptchaClientFactory func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error)
type tencentCaptchaVerifier struct {
newClient tencentCaptchaClientFactory
@@ -26,16 +24,19 @@ func NewTencentCaptchaVerifier() service.TencentCaptchaVerifier {
return &tencentCaptchaVerifier{newClient: newTencentCaptchaSDKClient}
}
func newTencentCaptchaSDKClient(secretID, secretKey string) (tencentCaptchaAPI, error) {
// newTencentCaptchaSDKClient 接入点由调用方按站点下发(中国站 / 国际站)。
// service 层的 tencentCaptchaEndpoint 已保证 endpoint 非空;即便为空,
// 腾讯 SDK 也会回落到服务默认域 captcha.tencentcloudapi.com(即中国站),不会失败。
func newTencentCaptchaSDKClient(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) {
clientProfile := profile.NewClientProfile()
clientProfile.HttpProfile.Endpoint = tencentCaptchaEndpoint
clientProfile.HttpProfile.Endpoint = endpoint
clientProfile.HttpProfile.ReqMethod = "POST"
clientProfile.HttpProfile.ReqTimeout = 5
return captcha.NewClient(common.NewCredential(secretID, secretKey), "", clientProfile)
}
func (v *tencentCaptchaVerifier) VerifyTicket(ctx context.Context, credentials service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, remoteIP string) (*service.TencentCaptchaVerifyResponse, error) {
client, err := v.newClient(credentials.CloudSecretID, credentials.CloudSecretKey)
client, err := v.newClient(credentials.CloudSecretID, credentials.CloudSecretKey, credentials.Endpoint)
if err != nil {
return nil, fmt.Errorf("create tencent captcha client: %w", err)
}
@@ -33,10 +33,10 @@ func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) {
RequestId: &requestID,
},
}}
var gotSecretID, gotSecretKey string
var gotSecretID, gotSecretKey, gotEndpoint string
verifier := &tencentCaptchaVerifier{
newClient: func(secretID, secretKey string) (tencentCaptchaAPI, error) {
gotSecretID, gotSecretKey = secretID, secretKey
newClient: func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) {
gotSecretID, gotSecretKey, gotEndpoint = secretID, secretKey, endpoint
return client, nil
},
}
@@ -46,11 +46,14 @@ func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) {
AppSecretKey: "app-secret",
CloudSecretID: "cloud-secret-id",
CloudSecretKey: "cloud-secret-key",
Endpoint: "captcha.intl.tencentcloudapi.com",
}, service.TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, "203.0.113.10")
require.NoError(t, err)
require.Equal(t, "cloud-secret-id", gotSecretID)
require.Equal(t, "cloud-secret-key", gotSecretKey)
// 接入点由 service 按站点下发,repository 不得再写死国内站
require.Equal(t, "captcha.intl.tencentcloudapi.com", gotEndpoint)
require.NotNil(t, client.request)
require.Equal(t, uint64(9), *client.request.CaptchaType)
require.Equal(t, uint64(123456789), *client.request.CaptchaAppId)
@@ -746,6 +746,7 @@ func TestAPIContracts(t *testing.T) {
"tencent_captcha_app_secret_key_configured": false,
"tencent_captcha_cloud_secret_id_configured": false,
"tencent_captcha_cloud_secret_key_configured": false,
"tencent_captcha_region": "cn",
"aliyun_captcha_enabled": false,
"aliyun_captcha_access_key_id": "",
"aliyun_captcha_access_key_secret_configured": false,
@@ -1085,6 +1086,7 @@ func TestAPIContracts(t *testing.T) {
"tencent_captcha_app_secret_key_configured": false,
"tencent_captcha_cloud_secret_id_configured": false,
"tencent_captcha_cloud_secret_key_configured": false,
"tencent_captcha_region": "cn",
"aliyun_captcha_enabled": false,
"aliyun_captcha_access_key_id": "",
"aliyun_captcha_access_key_secret_configured": false,
@@ -18,10 +18,21 @@ const (
NonceTemplate = "__CSP_NONCE__"
// CloudflareInsightsDomain is the domain for Cloudflare Web Analytics
CloudflareInsightsDomain = "https://static.cloudflareinsights.com"
// TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain.
// TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain (Chinese mainland site).
TencentCaptchaDomain = "https://turing.captcha.qcloud.com"
// TencentCaptchaStaticDomain is the Tencent Captcha static asset domain.
TencentCaptchaStaticDomain = "https://*.captcha.gtimg.com"
// TencentCaptchaCDNDomain 是天御国内站的核心 JS CDN 主机:
// 入口脚本 TJCaptcha.js 会再从这里加载 /1/tgJCap.*.js,缺失时会被 script-src 拦截。
TencentCaptchaCDNDomain = "https://turing.captcha.gtimg.com"
// TencentCaptchaGlobalDomain 是天御国际站的 Web SDK 与验证弹窗 iframe 主机。
TencentCaptchaGlobalDomain = "https://ca.turing.captcha.qcloud.com"
// TencentCaptchaGlobalCDNDomain 是天御国际站的核心 JS CDN 主机。
TencentCaptchaGlobalCDNDomain = "https://global.turing.captcha.gtimg.com"
// TencentCaptchaPrehandleDomain 是天御 SDK 动态预处理脚本与预处理接口主机。
TencentCaptchaPrehandleDomain = "https://www.tycaptcha.com"
// TencentCaptchaWorkerSource 是天御国际站创建验证码 Web Worker 时使用的来源。
TencentCaptchaWorkerSource = "blob:"
// StripeDomain is the domain for Stripe.js SDK
StripeDomain = "https://*.stripe.com"
// AirwallexStaticDomain 是 Airwallex 生产环境 SDK 脚本域名。
@@ -42,6 +53,15 @@ var requiredCSPDirectiveValues = []struct {
{"script-src", TencentCaptchaDomain},
{"frame-src", TencentCaptchaDomain},
{"style-src", TencentCaptchaStaticDomain},
{"script-src", TencentCaptchaCDNDomain},
{"script-src", TencentCaptchaGlobalDomain},
{"script-src", TencentCaptchaGlobalCDNDomain},
{"script-src", TencentCaptchaPrehandleDomain},
{"connect-src", TencentCaptchaDomain},
{"connect-src", TencentCaptchaPrehandleDomain},
{"frame-src", TencentCaptchaGlobalDomain},
{"frame-src", TencentCaptchaPrehandleDomain},
{"worker-src", TencentCaptchaWorkerSource},
{"script-src", StripeDomain},
{"frame-src", StripeDomain},
{"script-src", AirwallexStaticDomain},
@@ -129,6 +129,7 @@ func TestSecurityHeaders(t *testing.T) {
assert.Contains(t, csp, "default-src 'self'")
assert.Contains(t, csp, "'nonce-")
assert.Contains(t, csp, CloudflareInsightsDomain)
assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource))
})
t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) {
@@ -322,6 +323,33 @@ func TestEnhanceCSPPolicy(t *testing.T) {
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", TencentCaptchaStaticDomain))
assert.Contains(t, config.DefaultCSPPolicy, "style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com")
// 入口脚本会再从 CDN 拉核心 JS,国际站还会换用 ca./global. 两个主机;
// 缺任意一个都会让天御 SDK 触发 script-src 拦截。
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaCDNDomain))
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, "connect-src", TencentCaptchaDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaPrehandleDomain))
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))
})
t.Run("does_not_duplicate_tencent_captcha_worker_source", func(t *testing.T) {
policy := "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__"
enhanced := enhanceCSPPolicy(policy)
assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource))
})
t.Run("default_policy_already_carries_tencent_captcha_domains", func(t *testing.T) {
// 默认策略与中间件强制注入表必须同形,否则 config.example.yaml 会误导自建用户
for _, required := range requiredCSPDirectiveValues {
assert.Equal(t, 1, countDirectiveValue(config.DefaultCSPPolicy, required.directive, required.value),
"DefaultCSPPolicy 缺少 %s %s", required.directive, required.value)
}
})
t.Run("handles_policy_without_script_src", func(t *testing.T) {
@@ -170,6 +170,7 @@ const (
SettingKeyTencentCaptchaAppSecretKey = "tencent_captcha_app_secret_key"
SettingKeyTencentCaptchaCloudSecretID = "tencent_captcha_cloud_secret_id"
SettingKeyTencentCaptchaCloudSecretKey = "tencent_captcha_cloud_secret_key"
SettingKeyTencentCaptchaRegion = "tencent_captcha_region" // 站点:"cn"|"intl",决定前端 SDK 脚本与服务端接入点
// 阿里云验证码 2.0 设置(与 Turnstile、腾讯天御互斥,同一时间仅可启用一家)
SettingKeyAliyunCaptchaEnabled = "aliyun_captcha_enabled" // 是否启用阿里云验证码
@@ -463,6 +463,7 @@ type TencentCaptchaConfig struct {
AppSecretKey string
CloudSecretID string
CloudSecretKey string
Region string
}
// AliyunCaptchaConfig contains the credentials required by Aliyun Captcha 2.0's
@@ -491,6 +492,7 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP
SettingKeyTencentCaptchaAppSecretKey,
SettingKeyTencentCaptchaCloudSecretID,
SettingKeyTencentCaptchaCloudSecretKey,
SettingKeyTencentCaptchaRegion,
SettingKeyAliyunCaptchaEnabled,
SettingKeyAliyunCaptchaAccessKeyID,
SettingKeyAliyunCaptchaAccessKeySecret,
@@ -509,6 +511,7 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP
AppSecretKey: values[SettingKeyTencentCaptchaAppSecretKey],
CloudSecretID: values[SettingKeyTencentCaptchaCloudSecretID],
CloudSecretKey: values[SettingKeyTencentCaptchaCloudSecretKey],
Region: normalizeTencentCaptchaRegion(values[SettingKeyTencentCaptchaRegion]),
},
Aliyun: AliyunCaptchaConfig{
Enabled: values[SettingKeyAliyunCaptchaEnabled] == "true",
@@ -329,6 +329,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
TencentCaptchaAppSecretKeyConfigured: settings[SettingKeyTencentCaptchaAppSecretKey] != "",
TencentCaptchaCloudSecretIDConfigured: settings[SettingKeyTencentCaptchaCloudSecretID] != "",
TencentCaptchaCloudSecretKeyConfigured: settings[SettingKeyTencentCaptchaCloudSecretKey] != "",
TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
AliyunCaptchaAccessKeyID: settings[SettingKeyAliyunCaptchaAccessKeyID],
AliyunCaptchaAccessKeySecretConfigured: settings[SettingKeyAliyunCaptchaAccessKeySecret] != "",
@@ -173,6 +173,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyTurnstileSiteKey,
SettingKeyTencentCaptchaEnabled,
SettingKeyTencentCaptchaAppID,
SettingKeyTencentCaptchaRegion,
SettingKeyAliyunCaptchaEnabled,
SettingKeyAliyunCaptchaSceneID,
SettingKeyAliyunCaptchaPrefix,
@@ -309,6 +310,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true",
TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID],
TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID],
AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix],
@@ -504,6 +506,7 @@ type PublicSettingsInjectionPayload struct {
TurnstileSiteKey string `json:"turnstile_site_key"`
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
@@ -583,6 +586,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
TurnstileSiteKey: settings.TurnstileSiteKey,
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
TencentCaptchaAppID: settings.TencentCaptchaAppID,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
@@ -221,6 +221,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
if settings.TencentCaptchaCloudSecretKey != "" {
updates[SettingKeyTencentCaptchaCloudSecretKey] = settings.TencentCaptchaCloudSecretKey
}
updates[SettingKeyTencentCaptchaRegion] = normalizeTencentCaptchaRegion(settings.TencentCaptchaRegion)
// 阿里云验证码 2.0 设置(只有非空才更新密钥)
updates[SettingKeyAliyunCaptchaEnabled] = strconv.FormatBool(settings.AliyunCaptchaEnabled)
updates[SettingKeyAliyunCaptchaAccessKeyID] = settings.AliyunCaptchaAccessKeyID
@@ -50,6 +50,7 @@ type SystemSettings struct {
TencentCaptchaCloudSecretIDConfigured bool
TencentCaptchaCloudSecretKey string
TencentCaptchaCloudSecretKeyConfigured bool
TencentCaptchaRegion string
AliyunCaptchaEnabled bool
AliyunCaptchaAccessKeyID string
AliyunCaptchaAccessKeySecret string
@@ -319,6 +320,7 @@ type PublicSettings struct {
TurnstileSiteKey string
TencentCaptchaEnabled bool
TencentCaptchaAppID string
TencentCaptchaRegion string
AliyunCaptchaEnabled bool
AliyunCaptchaSceneID string
AliyunCaptchaPrefix string
@@ -25,6 +25,35 @@ type TencentCaptchaCredentials struct {
AppSecretKey string
CloudSecretID string
CloudSecretKey string
// Endpoint 服务端票据校验接入点,由地域推导,repository 层直接使用
Endpoint string
}
const (
// TencentCaptchaRegionCN 中国站(cloud.tencent.com);TencentCaptchaRegionINTL 国际站(tencentcloud.com)。
// 该值同时决定前端加载的 SDK 脚本与服务端校验接入点,两端必须一致:
// 国际站 CaptchaAppId 配国内站 SDK 会被腾讯直接判为「appid 所属地域与实际使用地域不符」。
TencentCaptchaRegionCN = "cn"
TencentCaptchaRegionINTL = "intl"
tencentCaptchaEndpointCN = "captcha.tencentcloudapi.com"
tencentCaptchaEndpointINTL = "captcha.intl.tencentcloudapi.com"
)
// tencentCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国站
func tencentCaptchaEndpoint(region string) string {
if region == TencentCaptchaRegionINTL {
return tencentCaptchaEndpointINTL
}
return tencentCaptchaEndpointCN
}
// normalizeTencentCaptchaRegion 非法值一律视为中国站
func normalizeTencentCaptchaRegion(value string) string {
if value == TencentCaptchaRegionINTL {
return TencentCaptchaRegionINTL
}
return TencentCaptchaRegionCN
}
type TencentCaptchaVerifyResponse struct {
@@ -78,12 +107,31 @@ func (s *TencentCaptchaService) VerifyTicketWithConfig(ctx context.Context, conf
result, err := s.verifier.VerifyTicket(ctx, credentials, proof, remoteIP)
if err != nil {
logger.LegacyPrintf("service.tencent_captcha", "%s", "[TencentCaptcha] verification request failed")
logger.LegacyPrintf(
"service.tencent_captcha",
"[TencentCaptcha] verification request failed region=%s endpoint=%s error=%v",
normalizeTencentCaptchaRegion(config.Region),
credentials.Endpoint,
err,
)
return fmt.Errorf("%w: verifier request failed", ErrTencentCaptchaVerificationFailed)
}
if result == nil || result.CaptchaCode != 1 {
if result != nil {
logger.LegacyPrintf("service.tencent_captcha", "[TencentCaptcha] rejected code=%d request_id=%s", result.CaptchaCode, result.RequestID)
logger.LegacyPrintf(
"service.tencent_captcha",
"[TencentCaptcha] rejected region=%s code=%d message=%q request_id=%q",
normalizeTencentCaptchaRegion(config.Region),
result.CaptchaCode,
result.CaptchaMsg,
result.RequestID,
)
} else {
logger.LegacyPrintf(
"service.tencent_captcha",
"[TencentCaptcha] rejected region=%s empty_response=true",
normalizeTencentCaptchaRegion(config.Region),
)
}
return ErrTencentCaptchaVerificationFailed
}
@@ -100,6 +148,7 @@ func parseTencentCaptchaCredentials(config TencentCaptchaConfig) (TencentCaptcha
AppSecretKey: strings.TrimSpace(config.AppSecretKey),
CloudSecretID: strings.TrimSpace(config.CloudSecretID),
CloudSecretKey: strings.TrimSpace(config.CloudSecretKey),
Endpoint: tencentCaptchaEndpoint(config.Region),
}
if credentials.AppSecretKey == "" || credentials.CloudSecretID == "" || credentials.CloudSecretKey == "" {
return TencentCaptchaCredentials{}, false
@@ -12,31 +12,65 @@ import (
)
type tencentCaptchaVerifierStub struct {
response *TencentCaptchaVerifyResponse
err error
calls int
proof TencentCaptchaProof
remoteIP string
response *TencentCaptchaVerifyResponse
err error
calls int
proof TencentCaptchaProof
remoteIP string
credentials TencentCaptchaCredentials
}
func (s *tencentCaptchaVerifierStub) VerifyTicket(_ context.Context, _ TencentCaptchaCredentials, proof TencentCaptchaProof, remoteIP string) (*TencentCaptchaVerifyResponse, error) {
func (s *tencentCaptchaVerifierStub) VerifyTicket(_ context.Context, credentials TencentCaptchaCredentials, proof TencentCaptchaProof, remoteIP string) (*TencentCaptchaVerifyResponse, error) {
s.calls++
s.credentials = credentials
s.proof = proof
s.remoteIP = remoteIP
return s.response, s.err
}
func newTencentCaptchaTestService(verifier TencentCaptchaVerifier) *TencentCaptchaService {
settings := NewSettingService(&settingPublicRepoStub{values: map[string]string{
return newTencentCaptchaTestServiceWithRegion(verifier, "")
}
func newTencentCaptchaTestServiceWithRegion(verifier TencentCaptchaVerifier, region string) *TencentCaptchaService {
values := map[string]string{
SettingKeyTencentCaptchaEnabled: "true",
SettingKeyTencentCaptchaAppID: "123456789",
SettingKeyTencentCaptchaAppSecretKey: "app-secret",
SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
}}, &config.Config{})
}
if region != "" {
values[SettingKeyTencentCaptchaRegion] = region
}
settings := NewSettingService(&settingPublicRepoStub{values: values}, &config.Config{})
return NewTencentCaptchaService(settings, verifier)
}
// 站点决定服务端票据校验接入点:国际站账号的密钥在国内站接入点上无法通过鉴权,
// 因此这条映射一旦错位,国际站验证码会整体失效。
func TestTencentCaptchaServiceRoutesVerifyEndpointByRegion(t *testing.T) {
cases := []struct {
name string
region string
wantEndpoint string
}{
{"未配置回落中国站", "", "captcha.tencentcloudapi.com"},
{"中国站", TencentCaptchaRegionCN, "captcha.tencentcloudapi.com"},
{"国际站", TencentCaptchaRegionINTL, "captcha.intl.tencentcloudapi.com"},
{"非法值回落中国站", "sgp", "captcha.tencentcloudapi.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
svc := newTencentCaptchaTestServiceWithRegion(verifier, tc.region)
require.NoError(t, svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10"))
require.Equal(t, tc.wantEndpoint, verifier.credentials.Endpoint)
})
}
}
func TestTencentCaptchaServiceAcceptsCaptchaCodeOne(t *testing.T) {
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
svc := newTencentCaptchaTestService(verifier)
@@ -40,12 +40,15 @@ func TestSettingService_GetPublicSettingsExposesOnlyTencentCaptchaAppID(t *testi
SettingKeyTencentCaptchaAppSecretKey: "app-secret",
SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
SettingKeyTencentCaptchaRegion: TencentCaptchaRegionINTL,
}}, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.TencentCaptchaEnabled)
require.Equal(t, "123456789", settings.TencentCaptchaAppID)
// 站点必须原样公开下发:前端据此决定加载哪个站点的 SDK 脚本与构造函数形态。
require.Equal(t, TencentCaptchaRegionINTL, settings.TencentCaptchaRegion)
raw, err := json.Marshal(settings)
require.NoError(t, err)
@@ -72,5 +75,7 @@ func TestSettingService_GetTencentCaptchaConfig(t *testing.T) {
AppSecretKey: "app-secret",
CloudSecretID: "cloud-secret-id",
CloudSecretKey: "cloud-secret-key",
// 未配置站点时回落中国站,保持存量部署行为不变
Region: TencentCaptchaRegionCN,
}, got)
}