Compare commits

...
10 Commits
Author SHA1 Message Date
Wesley Liddick 3040209f20 Merge pull request #7813 from kingsleydon/fix/typesafe-upstream-billing-probe
fix(billing): allow upstream billing probe for TypeSafe API-key accounts
2026-10-02 20:02:28 +08:00
Wesley Liddick f5aca74623 Merge pull request #7816 from Wei-Shaw/fix/usage-billing-deleted-key
fix(billing): settle usage when the API key is deleted before billing completes
2026-10-02 20:01:45 +08:00
shaw 432a6a461f chore(billing): simplify comment 2026-10-02 19:52:00 +08:00
shaw c2d5bbd93e fix(billing): settle usage when the API key is deleted before billing completes
When an API key with quota or rate limits was deleted while a request was
still in flight, the key counter update matched no rows and returned
ErrAPIKeyNotFound, rolling back the whole billing transaction including the
balance/subscription charge. Skip the key-scoped counters in that case and
keep settling the user and account side as usual.
2026-10-02 19:51:06 +08:00
Wesley Liddick ae501cc220 Merge pull request #7814 from Wei-Shaw/docs/security-policy
docs: add security policy with private vulnerability reporting
2026-10-02 19:06:07 +08:00
shaw 99efaa477f docs(security): add dev@sub2api.org as fallback reporting email 2026-10-02 19:04:36 +08:00
shaw d9d4333795 docs: add security policy with private vulnerability reporting
Closes #7812
2026-10-02 18:59:29 +08:00
KingsleyandClaude Opus 5.5 8b9788eeb4 test(billing): drop added comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:48:37 +00:00
KingsleyandClaude Opus 5.5 66a9e2e6bb fix(billing): allow upstream billing probe for TypeSafe API-key accounts
The create form shows the upstream billing auto-probe toggle for every
API-key platform and enables it by default. TypeSafe was added as an
API-key platform but not to IsUpstreamBillingProbeIdentity, so creating a
TypeSafe account with the default form state fails with
400 UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID ("account is not an API key
account").

TypeSafe accounts store credentials.api_key/base_url like every other
API-key platform, which is all the probe reads. Add TypeSafe to the probe
identity set, and add typesafe.ai to the official API domains so accounts
on the default https://api.typesafe.ai base URL record "unsupported"
without sending the key to a path that cannot exist, matching the other
official providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:45:53 +00:00
github-actions[bot] 458b92abd4 chore: sync VERSION to 0.2.12 [skip ci] 2026-10-02 06:45:18 +00:00
7 changed files with 249 additions and 4 deletions
+107
View File
@@ -0,0 +1,107 @@
# Security Policy
[English](#english) | [中文](#中文)
## English
### Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues, pull requests, or discussions.**
Report them privately through GitHub's private vulnerability reporting:
**[Report a vulnerability](https://github.com/Wei-Shaw/sub2api/security/advisories/new)**
(Repository → **Security** tab → **Report a vulnerability**.) Only the maintainers can see the report, and the advisory becomes the place to discuss, fix, and eventually publish the issue.
If you cannot use GitHub private vulnerability reporting, email **dev@sub2api.org** instead, with `[Security]` in the subject line.
A useful report includes:
- The affected component and version (release tag or commit)
- Impact: what an attacker can do and which privileges they need
- Minimal reproduction steps or a proof of concept
- Any non-default configuration required to trigger the issue
- A suggested fix, if you have one
### Supported Versions
Sub2API releases frequently, and fixes ship in a new release rather than being backported.
| Version | Supported |
| ------- | --------- |
| Latest release | ✅ |
| Older releases | ❌ |
Please confirm that the issue reproduces on the latest release or on `main` before reporting.
### Scope
In scope: the Sub2API backend, frontend, official Docker images, and deployment files in this repository.
Usually out of scope:
- Issues that require already-compromised admin credentials or server access
- Risks arising from explicitly weakening documented security settings (for example, disabling `security.url_allowlist`)
- Vulnerabilities in upstream AI providers or third-party dependencies with no Sub2API-specific impact (please report those upstream)
- Missing best-practice headers or scanner output without a demonstrated impact
### Disclosure Process
1. We acknowledge the report and assess its validity and severity.
2. We develop a fix privately in the advisory and may ask you to verify it.
3. We publish a release containing the fix, then publish the GitHub Security Advisory (with a CVE when applicable), crediting you unless you prefer to stay anonymous.
Please keep details private until the advisory is published. This is a volunteer-maintained project, so response times are best effort.
## 中文
### 报告漏洞
**请不要通过公开的 Issue、Pull Request 或 Discussion 报告安全漏洞。**
请使用 GitHub 私密漏洞报告功能提交:
**[提交漏洞报告](https://github.com/Wei-Shaw/sub2api/security/advisories/new)**
(仓库 → **Security** 标签页 → **Report a vulnerability**。)报告仅维护者可见,后续的讨论、修复与公告发布都在该安全公告中进行。
如果无法使用 GitHub 私密漏洞报告,也可以发送邮件至 **dev@sub2api.org**,邮件标题请注明 `[Security]`。
报告中建议包含:
- 受影响的组件与版本(Release tag 或 commit)
- 影响:攻击者能做什么、需要什么权限
- 最小复现步骤或 PoC
- 触发问题所需的非默认配置
- 修复建议(如有)
### 支持的版本
Sub2API 发版频繁,安全修复随新版本发布,不向旧版本回移。
| 版本 | 是否支持 |
| ---- | -------- |
| 最新 Release | ✅ |
| 更早的版本 | ❌ |
报告前请先确认问题在最新 Release 或 `main` 上仍可复现。
### 范围
范围内:本仓库中的 Sub2API 后端、前端、官方 Docker 镜像与部署文件。
通常不在范围内:
- 需要事先已掌握管理员凭据或服务器权限才能利用的问题
- 因主动关闭文档中的安全配置(如关闭 `security.url_allowlist`)而产生的风险
- 上游 AI 服务商或第三方依赖自身的漏洞,且对 Sub2API 无特定影响(请向上游报告)
- 仅缺少最佳实践响应头或扫描器输出,但未证明实际影响
### 披露流程
1. 我们确认收到报告,并评估其有效性与严重程度。
2. 我们在安全公告中私下开发修复,可能请你协助验证。
3. 我们先发布包含修复的版本,再公开 GitHub 安全公告(适用时申请 CVE),并致谢报告者(如你希望匿名则不署名)。
在公告发布前请勿公开漏洞细节。本项目由志愿者维护,响应时间尽力而为。
+1 -1
View File
@@ -1 +1 @@
0.2.11
0.2.12
@@ -187,16 +187,17 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t
result.BalanceOverdrafted = !sufficient
}
// Key 已不存在时跳过其自身的额度/限速计数,其余结算项不受影响。
if cmd.APIKeyQuotaCost > 0 {
exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost)
if err != nil {
if err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) {
return err
}
result.APIKeyQuotaExhausted = exhausted
}
if cmd.APIKeyRateLimitCost > 0 {
if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil {
if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) {
return err
}
}
@@ -162,6 +162,61 @@ func TestUsageBillingRepositoryApply_RequestFingerprintConflict(t *testing.T) {
require.ErrorIs(t, err, service.ErrUsageBillingRequestConflict)
}
func TestUsageBillingRepositoryApply_DeletedAPIKeyStillBillsBalance(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := NewUsageBillingRepository(client, integrationDB)
user := mustCreateUser(t, client, &service.User{
Email: fmt.Sprintf("usage-billing-deleted-key-user-%d@example.com", time.Now().UnixNano()),
PasswordHash: "hash",
Balance: 100,
})
apiKey := mustCreateApiKey(t, client, &service.APIKey{
UserID: user.ID,
Key: "sk-usage-billing-deleted-key-" + uuid.NewString(),
Name: "billing-deleted-key",
Quota: 50,
RateLimit5h: 50,
})
account := mustCreateAccount(t, client, &service.Account{
Name: "usage-billing-deleted-key-account-" + uuid.NewString(),
Type: service.AccountTypeAPIKey,
})
_, err := integrationDB.ExecContext(ctx, "UPDATE api_keys SET deleted_at = NOW() WHERE id = $1", apiKey.ID)
require.NoError(t, err)
requestID := uuid.NewString()
result, err := repo.Apply(ctx, &service.UsageBillingCommand{
RequestID: requestID,
APIKeyID: apiKey.ID,
UserID: user.ID,
AccountID: account.ID,
AccountType: service.AccountTypeAPIKey,
BalanceCost: 1.25,
APIKeyQuotaCost: 1.25,
APIKeyRateLimitCost: 1.25,
})
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Applied)
require.False(t, result.APIKeyQuotaExhausted)
var balance float64
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT balance FROM users WHERE id = $1", user.ID).Scan(&balance))
require.InDelta(t, 98.75, balance, 0.000001)
var quotaUsed, usage5h float64
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT quota_used, usage_5h FROM api_keys WHERE id = $1", apiKey.ID).Scan(&quotaUsed, &usage5h))
require.InDelta(t, 0, quotaUsed, 0.000001)
require.InDelta(t, 0, usage5h, 0.000001)
var dedupCount int
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM usage_billing_dedup WHERE request_id = $1 AND api_key_id = $2", requestID, apiKey.ID).Scan(&dedupCount))
require.Equal(t, 1, dedupCount)
}
func TestUsageBillingRepositoryApply_UpdatesAccountQuota(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
@@ -20,6 +20,8 @@ const (
captureBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance\s+\+ CASE WHEN \$1 > \$2 THEN \$1 - \$2 ELSE 0 END\s+- CASE WHEN \$2 > \$1 THEN \$2 - \$1 ELSE 0 END,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$3 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance`
releaseBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance \+ \$1,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance`
userExistsForBillingSQL = `(?s)SELECT 1\s+FROM users\s+WHERE id = \$1 AND deleted_at IS NULL`
apiKeyQuotaIncrementSQL = `(?s)UPDATE api_keys\s+SET quota_used = quota_used \+ \$1,.*WHERE id = \$2 AND deleted_at IS NULL\s+RETURNING`
apiKeyRateLimitIncrementSQL = `(?s)UPDATE api_keys SET\s+usage_5h = .*WHERE id = \$2 AND deleted_at IS NULL`
)
func TestDeductUsageBillingBalance_UsesSufficientBalanceGuard(t *testing.T) {
@@ -99,6 +101,66 @@ func TestApplyUsageBillingEffects_FlagsBalanceOverdraft(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
func TestApplyUsageBillingEffects_DeletedAPIKeyStillBillsBalance(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
mock.ExpectBegin()
tx, err := db.BeginTx(ctx, nil)
require.NoError(t, err)
mock.ExpectQuery(conditionalBalanceDeductSQL).
WithArgs(10.0, int64(42)).
WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(90.0))
mock.ExpectQuery(apiKeyQuotaIncrementSQL).
WithArgs(10.0, int64(7), service.StatusAPIKeyActive, service.StatusAPIKeyQuotaExhausted).
WillReturnError(sql.ErrNoRows)
mock.ExpectExec(apiKeyRateLimitIncrementSQL).
WithArgs(10.0, int64(7)).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
result := &service.UsageBillingApplyResult{Applied: true}
err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{
UserID: 42,
APIKeyID: 7,
BalanceCost: 10,
APIKeyQuotaCost: 10,
APIKeyRateLimitCost: 10,
}, result)
require.NoError(t, err)
require.NotNil(t, result.NewBalance)
require.InDelta(t, 90.0, *result.NewBalance, 0.000001)
require.False(t, result.APIKeyQuotaExhausted)
require.NoError(t, tx.Commit())
require.NoError(t, mock.ExpectationsWereMet())
}
func TestApplyUsageBillingEffects_APIKeyCounterErrorStillFails(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
mock.ExpectBegin()
tx, err := db.BeginTx(ctx, nil)
require.NoError(t, err)
mock.ExpectExec(apiKeyRateLimitIncrementSQL).
WithArgs(10.0, int64(7)).
WillReturnError(sql.ErrConnDone)
mock.ExpectRollback()
err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{
UserID: 42,
APIKeyID: 7,
APIKeyRateLimitCost: 10,
}, &service.UsageBillingApplyResult{Applied: true})
require.ErrorIs(t, err, sql.ErrConnDone)
require.NoError(t, tx.Rollback())
require.NoError(t, mock.ExpectationsWereMet())
}
func TestDeductUsageBillingBalance_ReturnsUserNotFoundWhenNoUserUpdated(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
@@ -1013,7 +1013,8 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool {
}
switch platform {
case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo,
PlatformTypeSafe:
return true
default:
return false
@@ -1052,6 +1053,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{
"bigmodel.cn",
"deepseek.com",
"opencode.ai",
"typesafe.ai",
}
func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool {
@@ -17,6 +17,7 @@ func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) {
for _, platform := range []string{
PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo,
PlatformTypeSafe,
} {
require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform)
require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform)
@@ -174,6 +175,8 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te
{PlatformDeepseek, "https://api.deepseek.com/anthropic"},
{PlatformOpenCodeGo, "https://opencode.ai/zen/go/v1"},
{PlatformOpenCodeGo, "https://opencode.ai/zen/go"},
{PlatformTypeSafe, "https://api.typesafe.ai"},
{PlatformTypeSafe, "https://api.typesafe.ai/v1"},
}
for i, tc := range cases {
account := &Account{
@@ -217,6 +220,7 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) {
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.deepseek.com/anthropic"))
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go/v1"))
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go"))
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.typesafe.ai"))
// 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1"))
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai"))
@@ -228,6 +232,7 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) {
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notmoonshot.cn/v1"))
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://moonshot.cn.evil.example/v1"))
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://kimi.example/v1"))
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://nottypesafe.ai"))
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notbigmodel.cn"))
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://deepseek.example.com"))
}
@@ -276,3 +281,16 @@ func TestUpstreamBillingProbeSetAccountEnabledAcceptsGrokAPIKey(t *testing.T) {
err := svc.SetAccountEnabled(context.Background(), grokOAuth.ID, true)
require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid)
}
func TestBuildAccountForCreateAcceptsTypeSafeAPIKeyWithProbeEnabled(t *testing.T) {
enabled := true
account, err := buildAccountForCreate(&CreateAccountInput{
Name: "typesafe",
Platform: PlatformTypeSafe,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "ts-key", "base_url": "https://api.typesafe.ai"},
ProbeEnabled: &enabled,
}, map[string]any{})
require.NoError(t, err)
require.Equal(t, true, account.Extra[UpstreamBillingProbeEnabledExtraKey])
}