Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
105 commits
Select commit Hold shift + click to select a range
d1ba579
feat: add native TypeSafe Jev System One support
aofee Sep 20, 2026
b13200d
fix(gateway): forward received chat stream usage
wucm667 Sep 19, 2026
ece1ecc
fix(gateway): normalize streamed Anthropic usage
wucm667 Sep 21, 2026
0416c9e
fix(gateway): avoid ambiguous late-cache input subtraction
wucm667 Sep 23, 2026
13fcfc0
fix(gateway): resolve composite routes for websocket aliases
wucm667 Sep 19, 2026
e878ab7
fix(gateway): constrain account model route ownership
wucm667 Sep 21, 2026
453b796
fix(gateway): enforce composite model ownership in legacy scheduling
wucm667 Sep 23, 2026
e2c35cf
fix(account): prevent whitelist model mapping conflicts
wucm667 Sep 23, 2026
b31b435
fix(gateway): Claude Code 限制分组的 OpenAI 兼容入口改走降级分组,不再直接 403
feeeei Sep 24, 2026
0da18e4
feat(admin): inline quick-adjust stepper for account priority
william-drakemond Sep 27, 2026
58d6050
fix(admin): keep disabled priority stepper hidden until hover
william-drakemond Sep 27, 2026
9a62841
chore: sync VERSION to 0.2.9 [skip ci]
github-actions[bot] Sep 28, 2026
bd48b2b
fix(antigravity): keep compat streams alive before first content
wucm667 Sep 28, 2026
aa3a3ea
feat(dashboard): toggle recent usage between tokens and spending
wucm667 Sep 28, 2026
cf19f30
fix(payment): rate-limit anonymous public order verify endpoint
william-drakemond Sep 28, 2026
d359196
fix(antigravity): sanitize upstream error body before returning to cl…
william-drakemond Sep 28, 2026
b1ce3c6
fix(email): atomic verify-code attempts and hashed single-use reset t…
william-drakemond Sep 28, 2026
2840cde
feat(frontend): hide unsupported clients for Claude Code-only groups
PMExtra Sep 23, 2026
6a69dd0
feat: add risk control user allowlist
PMExtra Sep 24, 2026
6073704
fix: omit Codex model catalog for OpenAI groups
PMExtra Sep 23, 2026
43f88c9
fix(billing): reserve in-flight balance to prevent concurrent overdraft
william-drakemond Sep 28, 2026
52c123e
fix(billing): hold inflight reservation until billing lands; price es…
william-drakemond Sep 28, 2026
652a7d7
fix(billing): estimate unpriced aliases via billing fallback models; …
william-drakemond Sep 28, 2026
8490a81
feat: support Claude Sonnet 5.5
StarryKira Sep 28, 2026
b886fb8
fix(billing): read account-level mappings from scheduler snapshot onl…
william-drakemond Sep 28, 2026
475c083
style: gofmt
william-drakemond Sep 28, 2026
2cc459e
style: satisfy Sonnet 5.5 lint checks
StarryKira Sep 28, 2026
6a8f921
feat(claude): expose sanitized native reset credit status
korkin25 Sep 24, 2026
f8984b8
feat(accounts): show Claude reset credit status on demand
korkin25 Sep 24, 2026
48970f6
fix(claude): explicitly close status HTTP response
korkin25 Sep 24, 2026
421da3f
feat(accounts): align Claude reset credit query with Codex reset coun…
william-drakemond Sep 28, 2026
35232c3
fix(accounts): address review of Claude reset credit count
william-drakemond Sep 28, 2026
860782e
fix(claude): harden reset credit projection and cell state (review R2)
william-drakemond Sep 29, 2026
27a9421
fix(anthropic): rewrite tool names in one body pass
wucm667 Sep 29, 2026
dd6cb41
Merge pull request #7683 from StarryKira/codex/sonnet-5-5-upstream
Wei-Shaw Sep 29, 2026
1dca86f
Merge pull request #7701 from wucm667/fix/issue-7699-tool-rewrite
Wei-Shaw Sep 29, 2026
b35a611
Merge pull request #7646 from wucm667/feat/issue-7642-dashboard-spend…
Wei-Shaw Sep 29, 2026
883a53f
Merge pull request #7643 from wucm667/fix/issue-7637-antigravity-comp…
Wei-Shaw Sep 29, 2026
f50b99b
Merge pull request #7542 from wucm667/fix/model-whitelist-mapping-con…
Wei-Shaw Sep 29, 2026
ccb027b
Merge pull request #7378 from wucm667/fix/issue-7368-composite-ws-rou…
Wei-Shaw Sep 29, 2026
1f955af
Merge pull request #7380 from wucm667/fix/issue-7377-forward-stream-u…
Wei-Shaw Sep 29, 2026
052e08e
Merge pull request #7684 from william-drakemond/feat/claude-reset-cre…
Wei-Shaw Sep 29, 2026
41dcfec
Merge pull request #7680 from PMExtra/fix/openai-model-catalog
Wei-Shaw Sep 29, 2026
fe9dadf
Merge pull request #7679 from PMExtra/feat/risk-control-allowlist
Wei-Shaw Sep 29, 2026
2f3fed2
Merge pull request #7678 from PMExtra/feat/claude-code-only-client-tabs
Wei-Shaw Sep 29, 2026
a60a295
chore: sync VERSION to 0.2.10 [skip ci]
github-actions[bot] Sep 29, 2026
196b477
feat(claude): redeem native limit resets with server-selected grant
william-drakemond Sep 29, 2026
1872d44
feat(accounts): add confirmed Claude reset button next to reset count
william-drakemond Sep 29, 2026
dc2efc6
fix(claude): tighten reset redeem outcome handling (review R1)
william-drakemond Sep 29, 2026
514ad0a
fix(accounts): readable window labels in Claude reset confirm (review…
william-drakemond Sep 29, 2026
9688571
feat(models): support GPT-6.1 Sol
lyy0709 Sep 29, 2026
c91bb61
feat(codex): recognize current subscription plans
lyy0709 Sep 29, 2026
e6d191a
fix(openai): support Astra Ultrafast capabilities and pricing
lyy0709 Sep 29, 2026
b5efbe3
feat(codex): support remote model catalogs in client config
lyy0709 Sep 29, 2026
a0f41f9
Merge pull request #7579 from feeeei/fix/claude-code-only-fallback-op…
Wei-Shaw Sep 30, 2026
9ecb340
feat(api-key): 增加 API Key 创建数量与频率限制
Wei-Shaw Sep 30, 2026
017bbcb
chore(api-key): 创建频率默认值调整为每小时 60 次
Wei-Shaw Sep 30, 2026
b0cfff9
Merge pull request #7752 from Wei-Shaw/fix/api-key-create-limits
Wei-Shaw Sep 30, 2026
4f0c079
Merge pull request #7681 from william-drakemond/fix/balance-inflight-…
Wei-Shaw Sep 30, 2026
48b7109
Merge pull request #7736 from lyy0709/codex/remote-codex-model-catalog
Wei-Shaw Sep 30, 2026
327c322
Merge pull request #7730 from lyy0709/codex/gpt61-sol-codex-plans
Wei-Shaw Sep 30, 2026
96f4c11
Merge pull request #7726 from william-drakemond/feat/claude-reset-redeem
Wei-Shaw Sep 30, 2026
42bc7f6
chore: sync VERSION to 0.2.11 [skip ci]
github-actions[bot] Sep 30, 2026
2e3c2cf
fix(i18n): correct the custom error codes warning text
william-drakemond Sep 30, 2026
0094074
fix(deps): upgrade axios to 1.20.0 to pass security audit
william-drakemond Sep 30, 2026
fdce5b8
修复 Grok CLI 版本门槛并对齐官方无界面请求头
lyen1688 Oct 1, 2026
ad05eda
依据正常交互式 CLI 抓包修正 Grok 身份头
lyen1688 Oct 1, 2026
5315858
升级 Axios 至 1.20.0 修复前端安全审计
lyen1688 Oct 1, 2026
d6adebd
Merge pull request #7780 from lyen1688/fix/grok-cli-identity-1-0-46
Wei-Shaw Oct 1, 2026
1b1039f
feat(payment): 充值赠送阶梯(按金额档位额外赠送到账额度)
feeeei Oct 1, 2026
1aa34d4
feat(payment): 充值优惠阶梯支持折扣模式,快捷金额角标改为促销价签
feeeei Oct 1, 2026
0cecf3c
feat(keys): support sorting API keys by group name
william-drakemond Oct 1, 2026
7225538
merge: sync Jev support with latest upstream main
aofee Oct 2, 2026
bb97ab3
fix(typesafe): align System One validation with upstream schema
aofee Oct 2, 2026
2d8a386
fix(typesafe): 补齐平台迁移、审计覆盖、端点隔离与错误策略
Wei-Shaw Oct 2, 2026
9eb394e
fix(typesafe): 堵住校验解析差异与审计盲区,收紧模型列表口径
Wei-Shaw Oct 2, 2026
fa49647
test(model): 错误透传平台列表守卫纳入 typesafe
Wei-Shaw Oct 2, 2026
34fe845
Merge pull request #7425 from aofee/codex/jev-system-one
Wei-Shaw Oct 2, 2026
5637f46
Merge pull request #7802 from feeeei/feat/recharge-promotion-tiers
Wei-Shaw Oct 2, 2026
f3dc083
Merge pull request #7673 from william-drakemond/fix/public-order-veri…
Wei-Shaw Oct 2, 2026
7190130
Merge pull request #7803 from william-drakemond/feat/api-key-group-sort
Wei-Shaw Oct 2, 2026
43c4c88
Merge pull request #7674 from william-drakemond/fix/antigravity-sanit…
Wei-Shaw Oct 2, 2026
2529371
Merge pull request #7676 from william-drakemond/fix/email-code-token-…
Wei-Shaw Oct 2, 2026
46f5f5e
Merge pull request #7773 from william-drakemond/fix/custom-error-code…
Wei-Shaw Oct 2, 2026
5106065
Merge pull request #7630 from william-drakemond/feat/account-priority…
Wei-Shaw Oct 2, 2026
458b92a
chore: sync VERSION to 0.2.12 [skip ci]
github-actions[bot] Oct 2, 2026
66a9e2e
fix(billing): allow upstream billing probe for TypeSafe API-key accounts
kingsleydon Oct 2, 2026
8b9788e
test(billing): drop added comments
kingsleydon Oct 2, 2026
d9d4333
docs: add security policy with private vulnerability reporting
Wei-Shaw Oct 2, 2026
99efaa4
docs(security): add dev@sub2api.org as fallback reporting email
Wei-Shaw Oct 2, 2026
ae501cc
Merge pull request #7814 from Wei-Shaw/docs/security-policy
Wei-Shaw Oct 2, 2026
c2d5bbd
fix(billing): settle usage when the API key is deleted before billing…
Wei-Shaw Oct 2, 2026
432a6a4
chore(billing): simplify comment
Wei-Shaw Oct 2, 2026
f5aca74
Merge pull request #7816 from Wei-Shaw/fix/usage-billing-deleted-key
Wei-Shaw Oct 2, 2026
3040209
Merge pull request #7813 from kingsleydon/fix/typesafe-upstream-billi…
Wei-Shaw Oct 2, 2026
b8dece9
chore: sync VERSION to 0.2.13 [skip ci]
github-actions[bot] Oct 2, 2026
bbba01d
fix: enable API-key discovery for remote Codex catalogs
ZeroClover Oct 3, 2026
d1aac6b
fix(payment): close EasyPay forged-callback sign reuse (fixes #7881)
honestTai Oct 6, 2026
33d9e72
Merge pull request #7851 from ZeroClover/fix/remote-model-catalog-dis…
Wei-Shaw Oct 7, 2026
d97ccc9
Merge pull request #7888 from honestTai/fix/easypay-callback-forgery
Wei-Shaw Oct 7, 2026
6e31561
fix(setup): stop shipping guessable admin credentials on fresh instal…
Wei-Shaw Oct 7, 2026
0f9d460
fix(deps): resolve frontend audit failures (vue, source-map-js, xlsx …
Wei-Shaw Oct 7, 2026
0363b8c
Merge pull request #7893 from Wei-Shaw/fix/issue-7850-admin-bootstrap…
Wei-Shaw Oct 7, 2026
bbf8af1
chore: record the already integrated official v0.2.13 ancestry
Oct 7, 2026
b13708e
Merge official Sub2API v0.2.14
Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .downstream/upstream-base
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.2.13
v0.2.14
24 changes: 24 additions & 0 deletions .downstream/upstream-review-v0.2.14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Official v0.2.14 integration

- Official tag: `v0.2.14`, commit `0363b8cdba8cec3e2ba4b2dbd49c4481143fa55d`.
- Downstream starting commit: `7132011d7a4275e7d6407c92db4ec205a1c14ad1`.
- The already integrated v0.2.13 ancestry is recorded by an ours merge of
`b8dece9000c68815a5b867ca5a1e6f236e173905`; this marker changes no files.
- The three conflicts are the frontend package, lockfile, and audit exceptions.
Vue uses `^3.5.43`; upstream DOMPurify/source-map-js constraints and downstream
constraints are retained. XLSX remains the patched SheetJS CDN 0.20.3 artifact,
with no renewed XLSX exceptions. The final lockfile installs frozen.
- Security fixes and remote Codex API-key model discovery follow the official tag.
Existing administrator credentials are skipped, and existing payment reconcile
paths remain. File catalogs, the 1 MiB fallback, auth/WS and source-bound capacity
contracts remain. Runtime configuration and UI styling are unchanged.
- VERSION metadata is synchronized to 0.2.14; the upstream release tag itself
retains the older fallback value. No unreleased upstream feature is imported.
- Historical SQL, migration 274 and Ent schemas are unchanged. No new database
migration is introduced. Retired acceptance/plugin/resource/rollback paths
remain retired. The current interfaces are in [DOWNSTREAM](../DOWNSTREAM.md).

Validation is the affected setup/EasyPay/return URL cases and mounted Codex
configuration scenarios, plus the normal required PR checks. Source/package
checks do not prove deployment or live model/payment effects. Production status
remains owned by the operator workspace's production pointer.
644 changes: 118 additions & 526 deletions .downstream/upstream-risk.json

Large diffs are not rendered by default.

18 changes: 7 additions & 11 deletions DOWNSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This fork maintains the `codexrip` patch set over official Sub2API releases.
## Source baseline

- Integrated official baseline: the release recorded in `.downstream/upstream-base`.
- Retained compatibility decisions: [upstream review](.downstream/upstream-review-v0.2.13.md).
- Retained compatibility decisions: [upstream review](.downstream/upstream-review-v0.2.14.md).
- The operator workspace `docs/sub2api.md` owns the production version and image digest. A source merge or Release does not establish deployment completion.

## Settings and account operations
Expand Down Expand Up @@ -145,13 +145,9 @@ Scheduled discovery may identify newer official releases. It cannot deploy
production. Each integration records its selected official commit, conflicts and
retained downstream contracts before a new immutable release is published.

`.downstream/upstream-risk.json` is the recorded integration snapshot, not a live
worktree conflict report. Its current classifier inputs are upstream `v0.2.11`
through `v0.2.13` and downstream commit
`291d25e7f8a8c82d45072a1eee742c21ba3b8d21`. `initial_conflict_files` supplements
the classifier output with paths reviewed during the merge, including files
resolved automatically; it is historical review evidence rather than a list of
remaining unmerged paths. The final `merge_conflicts: []` records the resolved
merge. The risk gate recomputes from the tags and downstream commit in that
snapshot, rather than the current PR head, so later-deleted paths remain in its
classification; `review_required` still requires the `upstream-reviewed` label.
`.downstream/upstream-risk.json` records the v0.2.13-to-v0.2.14 integration against
downstream commit `7132011d7a4275e7d6407c92db4ec205a1c14ad1`. The risk gate
recomputes those exact inputs. `initial_conflict_files` lists the three dependency
conflicts resolved during this merge; `merge_conflicts: []` records that none
remain. A `review_required` candidate needs a real completed review and the
`upstream-reviewed` label before promotion.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,10 @@ JWT_SECRET=your_jwt_secret_here
TOTP_ENCRYPTION_KEY=your_totp_key_here

# Optional: Admin account
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# Leave empty to auto-generate a random email (login username) and password, shown in logs on first startup.
# Avoid guessable values such as admin@example.com: they are brute-force targets.
ADMIN_EMAIL=
ADMIN_PASSWORD=

# Optional: Custom port
SERVER_PORT=8080
Expand Down Expand Up @@ -404,9 +406,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api

Open `http://YOUR_SERVER_IP:8080` in your browser.

If admin password was auto-generated, find it in logs:
If the admin email (login username) or password was auto-generated, find them in logs:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
```

#### Upgrade
Expand Down
10 changes: 6 additions & 4 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,10 @@ JWT_SECRET=your_jwt_secret_here
TOTP_ENCRYPTION_KEY=your_totp_key_here

# 可选:管理员账号
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# 留空则首次启动时自动生成随机邮箱(登录用户名)和密码,并输出到日志。
# 请勿使用 admin@example.com 这类可被猜中的值,它们是暴力破解的目标。
ADMIN_EMAIL=
ADMIN_PASSWORD=

# 可选:自定义端口
SERVER_PORT=8080
Expand Down Expand Up @@ -442,9 +444,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api

在浏览器中打开 `http://你的服务器IP:8080`

如果管理员密码是自动生成的,在日志中查找:
如果管理员邮箱(登录用户名)或密码是自动生成的,在日志中查找:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
```

#### 升级
Expand Down
10 changes: 6 additions & 4 deletions README_JA.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,10 @@ JWT_SECRET=your_jwt_secret_here
TOTP_ENCRYPTION_KEY=your_totp_key_here

# オプション: 管理者アカウント
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# 空欄の場合、初回起動時にランダムなメールアドレス(ログインユーザー名)とパスワードが自動生成され、ログに出力されます。
# admin@example.com のような推測されやすい値は総当たり攻撃の標的になるため避けてください。
ADMIN_EMAIL=
ADMIN_PASSWORD=

# オプション: カスタムポート
SERVER_PORT=8080
Expand Down Expand Up @@ -405,9 +407,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api

ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。

管理者パスワードが自動生成された場合は、ログで確認できます:
管理者メールアドレス(ログインユーザー名)またはパスワードが自動生成された場合は、ログで確認できます:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
```

#### アップグレード
Expand Down
2 changes: 1 addition & 1 deletion backend/cmd/server/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.13
0.2.14
5 changes: 3 additions & 2 deletions backend/internal/integration/e2e_user_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,11 @@ func loginTestUser(t *testing.T) string {
t.Helper()

// 先尝试用管理员账户登录
adminEmail := getEnv("ADMIN_EMAIL", "admin@sub2api.local")
// 管理员邮箱不再有固定默认值(未设置时安装阶段随机生成),需同时提供邮箱与密码。
adminEmail := getEnv("ADMIN_EMAIL", "")
adminPassword := getEnv("ADMIN_PASSWORD", "")

if adminPassword == "" {
if adminEmail == "" || adminPassword == "" {
// 尝试用测试用户
adminEmail = testUserEmail
adminPassword = testUserPassword
Expand Down
26 changes: 26 additions & 0 deletions backend/internal/payment/provider/easypay.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,9 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
// url.ParseQuery already decodes values — no additional decode needed.
params := make(map[string]string)
for k := range values {
if !easyPayNotifyAllowedParams[k] {
return nil, fmt.Errorf("unexpected notify param: %s", k)
}
params[k] = values.Get(k)
}
sign := params["sign"]
Expand Down Expand Up @@ -601,3 +604,26 @@ func easyPaySign(params map[string]string, pkey string) string {
func easyPayVerifySign(params map[string]string, pkey string, sign string) bool {
return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign))
}

// easyPayNotifyAllowedParams is the exact parameter set a genuine EasyPay
// (彩虹易支付-compatible) async notification carries. Order-creation-only
// fields (notify_url, return_url, cid, device, clientip, ...) must never
// appear in a callback: because the sign base string concatenates values
// unescaped, a signed order URL whose return_url embeds e.g.
// "trade_status=TRADE_SUCCESS" could otherwise be replayed as a forged
// payment-success notification (issue #7881). Rejecting unknown keys closes
// the whole smuggling class; genuinely paid orders rejected by an exotic
// upstream variant are still recovered by the upstream QueryOrder reconcile
// path.
var easyPayNotifyAllowedParams = map[string]bool{
"pid": true,
"trade_no": true,
"out_trade_no": true,
"type": true,
"name": true,
"money": true,
"trade_status": true,
"param": true,
"sign": true,
"sign_type": true,
}
172 changes: 172 additions & 0 deletions backend/internal/payment/provider/easypay_notify_security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package provider

// Regression tests for the EasyPay forged-callback vulnerability
// (Wei-Shaw/sub2api issue #7875).
//
// Attack recap: the order-creation signature (visible to the payer in the
// submit.php popup URL) signs return_url among other fields, and the sign
// base string concatenates values unescaped. A client-supplied return_url
// ending in "&trade_status=TRADE_SUCCESS" therefore produced a signature
// that was byte-identical to a payment-success notification's signature
// once the callback decoded return_url partially and promoted the smuggled
// pair to a top-level parameter. Merchant-key rotation could not help
// because the attacker never needed the key.
//
// Fixes covered here:
// 1. service.CanonicalizeReturnURL drops client-supplied query parameters
// (see payment_resume_service_test.go).
// 2. VerifyNotification rejects any parameter outside the genuine async
// notify set, so smuggled order-creation fields (return_url et al.)
// fail closed even if a signed value smuggles a fake pair.

import (
"context"
"net/url"
"testing"

"github.com/Wei-Shaw/sub2api/internal/payment"
)

// easyPayPoCProvider returns a provider with a fixed test credential set.
func easyPayPoCProvider() *EasyPay {
return &EasyPay{config: map[string]string{
"pid": "1000",
"pkey": "MERCHANT_SECRET_KEY",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://site.example.com/api/v1/payment/webhook/easypay",
"returnUrl": "https://site.example.com/payment/result",
}}
}

// TestEasyPayNotifyRejectsForgedSignReuseCallback is the exact PoC payload
// that was accepted before the fix: the order's own submit.php signature
// replayed with trade_status smuggled out of the return_url value.
func TestEasyPayNotifyRejectsForgedSignReuseCallback(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()

// Order creation (popup mode): return_url carries the smuggled pair as
// its last sorted inner query key, exactly as buildPaymentReturnURL
// would have produced before CanonicalizeReturnURL stripped user query.
returnURL := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success&trade_status=TRADE_SUCCESS"
createParams := map[string]string{
"pid": "1000",
"type": "alipay",
"out_trade_no": "ORDER123",
"notify_url": e.config["notifyUrl"],
"return_url": returnURL,
"name": "balance recharge",
"money": "650.00",
}
sign := easyPaySign(createParams, e.config["pkey"])

// Forged callback: return_url encoded only up to status=success, then a
// raw &trade_status=TRADE_SUCCESS promotes it to a top-level parameter.
prefix := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success"
cb := url.Values{}
cb.Set("pid", "1000")
cb.Set("type", "alipay")
cb.Set("out_trade_no", "ORDER123")
cb.Set("notify_url", e.config["notifyUrl"])
cb.Set("name", "balance recharge")
cb.Set("money", "650.00")
cb.Set("return_url", prefix)
rawCallback := cb.Encode() + "&trade_status=TRADE_SUCCESS" + "&sign=" + sign + "&sign_type=MD5"

if _, err := e.VerifyNotification(context.Background(), rawCallback, nil); err == nil {
t.Fatal("forged sign-reuse callback must be rejected")
}
}

// TestEasyPayNotifyRejectsOrderURLReplay covers the milder variant where the
// attacker replays the complete signed pay URL unchanged: return_url itself
// is not a legitimate notify parameter and must also be rejected.
func TestEasyPayNotifyRejectsOrderURLReplay(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()

createParams := map[string]string{
"pid": "1000",
"type": "alipay",
"out_trade_no": "ORDER123",
"notify_url": e.config["notifyUrl"],
"return_url": "https://site.example.com/payment/result",
"name": "balance recharge",
"money": "650.00",
}
createParams["sign"] = easyPaySign(createParams, e.config["pkey"])
createParams["sign_type"] = signTypeMD5
q := url.Values{}
for k, v := range createParams {
q.Set(k, v)
}

if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil {
t.Fatal("replayed order URL must be rejected")
}
}

// TestEasyPayNotifyAcceptsGenuineCallback locks in the legitimate notify
// contract: the canonical parameter set with a valid signature succeeds.
func TestEasyPayNotifyAcceptsGenuineCallback(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()

params := map[string]string{
"pid": "1000",
"trade_no": "2026100622001400000001",
"out_trade_no": "ORDER123",
"type": "alipay",
"name": "balance recharge",
"money": "650.00",
"trade_status": tradeStatusSuccess,
}
sign := easyPaySign(params, e.config["pkey"])
params["sign"] = sign
params["sign_type"] = signTypeMD5
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}

n, err := e.VerifyNotification(context.Background(), q.Encode(), nil)
if err != nil {
t.Fatalf("genuine callback rejected: %v", err)
}
if n.Status != payment.ProviderStatusSuccess {
t.Fatalf("status = %v, want success", n.Status)
}
if n.OrderID != "ORDER123" || n.TradeNo != "2026100622001400000001" || n.Amount != 650.00 {
t.Fatalf("unexpected notification: %+v", n)
}
}

// TestEasyPayNotifyRejectsUnknownParam ensures any parameter outside the
// canonical notify set fails closed, including empty-valued ones that the
// signer itself would skip.
func TestEasyPayNotifyRejectsUnknownParam(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()

params := map[string]string{
"pid": "1000",
"trade_no": "T1",
"out_trade_no": "ORDER123",
"type": "alipay",
"name": "balance recharge",
"money": "650.00",
"trade_status": tradeStatusSuccess,
}
sign := easyPaySign(params, e.config["pkey"])
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
q.Set("sign", sign)
q.Set("sign_type", signTypeMD5)
q.Set("device", "") // empty value: invisible to the signer, still rejected

if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil {
t.Fatal("callback with unknown param must be rejected")
}
}
7 changes: 7 additions & 0 deletions backend/internal/service/payment_resume_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, e
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https")
}
parsed.Fragment = ""
// Client-supplied query parameters are dropped: buildPaymentReturnURL adds
// its own signed set, and user-controlled pairs such as
// "trade_status=TRADE_SUCCESS" inside the signed return_url value can be
// smuggled into EasyPay-style sign base strings (values are concatenated
// unescaped), letting an order-creation signature be replayed as a forged
// payment-success callback. See issue #7881.
parsed.RawQuery = ""
if parsed.Path == "" {
parsed.Path = "/"
}
Expand Down
Loading
Loading