cn_provider_probe_url_test.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. package service
  2. // CN 供应商探测端点 URL 安全策略回归测试(review B4):
  3. // 配额/余额探测不得绕过 security.url_allowlist——base_url 衍生的探测端点
  4. // 必须先过运营者策略,被拒绝时不得发起任何上游请求(API key 不出站)。
  5. import (
  6. "context"
  7. "net/http"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func cnProbeAllowlistConfig(hosts ...string) *config.Config {
  14. return &config.Config{
  15. Security: config.SecurityConfig{
  16. URLAllowlist: config.URLAllowlistConfig{
  17. Enabled: true,
  18. UpstreamHosts: hosts,
  19. },
  20. },
  21. }
  22. }
  23. func TestCNValidateProbeURL_AllowlistPolicy(t *testing.T) {
  24. cfg := cnProbeAllowlistConfig("api.moonshot.cn", "api.deepseek.com")
  25. // 白名单内主机放行(保留完整路径)。
  26. ok, err := cnValidateProbeURL(cfg, "https://api.moonshot.cn/v1/users/me/balance")
  27. require.NoError(t, err)
  28. require.Equal(t, "https://api.moonshot.cn/v1/users/me/balance", ok)
  29. // 白名单外主机拒绝。
  30. _, err = cnValidateProbeURL(cfg, "https://relay.attacker.example/v1/usages")
  31. require.Error(t, err)
  32. require.Contains(t, err.Error(), "rejected by URL security policy")
  33. // 私网主机拒绝(内网探测面)。
  34. _, err = cnValidateProbeURL(cfg, "http://169.254.169.254/latest/meta-data")
  35. require.Error(t, err)
  36. // 白名单关闭:仅格式校验,任意 https 主机放行。
  37. formatOnly, err := cnValidateProbeURL(&config.Config{
  38. Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}},
  39. }, "https://relay.attacker.example/v1/usages")
  40. require.NoError(t, err)
  41. require.Equal(t, "https://relay.attacker.example/v1/usages", formatOnly)
  42. }
  43. // recordingHTTPUpstream 断言探测被策略拒绝时没有任何上游请求发出。
  44. type recordingHTTPUpstream struct{ calls int }
  45. func (u *recordingHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  46. u.calls++
  47. return nil, context.DeadlineExceeded
  48. }
  49. func (u *recordingHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  50. u.calls++
  51. return nil, context.DeadlineExceeded
  52. }
  53. type fakeCNProbeAccountRepo struct {
  54. AccountRepository
  55. account *Account
  56. }
  57. func (r *fakeCNProbeAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
  58. return r.account, nil
  59. }
  60. // kimi coding 账号的 base_url 指向中转(含 api.kimi.com/coding 路径段即可被识别
  61. // 为 kimi coding plan)→ 衍生额度端点落在中转主机上,白名单未列名必须拒绝。
  62. func TestCNProviderQuotaService_RejectsURLBlockedByPolicy(t *testing.T) {
  63. repo := &fakeCNProbeAccountRepo{account: &Account{
  64. ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive,
  65. Credentials: map[string]any{
  66. "account_mode": "coding",
  67. "api_key": "sk-test",
  68. "base_url": "https://relay.attacker.example/api.kimi.com/coding",
  69. },
  70. }}
  71. upstream := &recordingHTTPUpstream{}
  72. svc := NewCNProviderQuotaService(repo, nil, upstream, cnProbeAllowlistConfig("api.kimi.com"))
  73. _, err := svc.QueryUsage(context.Background(), 1)
  74. require.Error(t, err)
  75. require.Contains(t, err.Error(), "CN_QUOTA_URL_REJECTED")
  76. require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target")
  77. }
  78. // deepseek payg 账号自定义 base_url → 余额端点落在中转主机上,必须先过策略。
  79. func TestCNProviderBalanceService_RejectsURLBlockedByPolicy(t *testing.T) {
  80. repo := &fakeCNProbeAccountRepo{account: &Account{
  81. ID: 2, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Status: StatusActive,
  82. Credentials: map[string]any{
  83. "account_mode": "payg",
  84. "api_key": "sk-test",
  85. "base_url": "https://relay.attacker.example",
  86. },
  87. }}
  88. upstream := &recordingHTTPUpstream{}
  89. svc := NewCNProviderBalanceService(repo, nil, upstream, cnProbeAllowlistConfig("api.deepseek.com"))
  90. _, err := svc.QueryBalance(context.Background(), 2)
  91. require.Error(t, err)
  92. require.Contains(t, err.Error(), "CN_BALANCE_URL_REJECTED")
  93. require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target")
  94. }
  95. // 白名单包含官方主机的正常路径:URL 校验通过后才发出上游请求(此处允许到达
  96. // httpUpstream 层即视为通过校验,不发真实网络)。
  97. func TestCNProviderBalanceService_OfficialHostPassesValidation(t *testing.T) {
  98. repo := &fakeCNProbeAccountRepo{account: &Account{
  99. ID: 3, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Status: StatusActive,
  100. Credentials: map[string]any{
  101. "account_mode": "payg",
  102. "api_key": "sk-test",
  103. },
  104. }}
  105. upstream := &recordingHTTPUpstream{}
  106. svc := NewCNProviderBalanceService(repo, nil, upstream, cnProbeAllowlistConfig("api.deepseek.com"))
  107. _, _ = svc.QueryBalance(context.Background(), 3)
  108. require.Equal(t, 1, upstream.calls, "official host must pass URL policy and reach the upstream layer")
  109. }