Files
sub2api/backend/internal/payment/provider/easypay.go
T
honest.tai d1aac6b989 fix(payment): close EasyPay forged-callback sign reuse (fixes #7881)
An order-creation signature (visible to the payer in popup submit.php
URLs) could be replayed as a payment-success notification, with no
merchant key required. Two compounding defects:

- CanonicalizeReturnURL kept client-supplied query parameters, so a
  return_url ending in '&trade_status=TRADE_SUCCESS' entered the signed
  order string verbatim;
- the sign base string concatenates values unescaped, so a callback that
  decodes return_url only up to the smuggled pair re-sorts to a
  byte-identical sign base and passes verification.

Fixes:

- CanonicalizeReturnURL now drops client query parameters entirely; the
  server builds its own signed set and the client query has no
  legitimate use;
- VerifyNotification rejects any parameter outside the genuine async
  notify set (pid/trade_no/out_trade_no/type/name/money/trade_status/
  param/sign/sign_type), failing closed against the whole smuggling
  class regardless of the injection point.

Regression tests cover the PoC payload, full order-URL replay, genuine
callback acceptance, unknown-parameter rejection (including empty
values), and the return_url smuggling strip. Genuinely paid orders that
an exotic upstream variant might reject are still recovered through the
existing upstream QueryOrder reconcile path.
2026-10-06 23:14:56 +08:00

630 lines
19 KiB
Go

// Package provider contains concrete payment provider implementations.
package provider
import (
"context"
"crypto/hmac"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
// EasyPay constants.
const (
easypayCodeSuccess = 1
easypayStatusPaid = 1
easypayHTTPTimeout = 10 * time.Second
maxEasypayResponseSize = 1 << 20 // 1MB
maxEasypayErrorSummary = 512
tradeStatusSuccess = "TRADE_SUCCESS"
signTypeMD5 = "MD5"
paymentModePopup = "popup"
deviceMobile = "mobile"
)
// EasyPay implements payment.Provider for the EasyPay aggregation platform.
type EasyPay struct {
instanceID string
config map[string]string
httpClient *http.Client
}
type easyPayCustomMethod struct {
Type string `json:"type"`
UpstreamType string `json:"upstreamType"`
DisplayName string `json:"displayName"`
}
// NewEasyPay creates a new EasyPay provider.
// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay
func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} {
if strings.TrimSpace(config[k]) == "" {
return nil, fmt.Errorf("easypay config missing required key: %s", k)
}
}
cfg := make(map[string]string, len(config))
for k, v := range config {
cfg[k] = v
}
cfg["apiBase"] = normalizeEasyPayAPIBase(cfg["apiBase"])
return &EasyPay{
instanceID: instanceID,
config: cfg,
httpClient: &http.Client{Timeout: easypayHTTPTimeout},
}, nil
}
func normalizeEasyPayAPIBase(apiBase string) string {
base := strings.TrimSpace(apiBase)
if base == "" {
return ""
}
if parsed, err := url.Parse(base); err == nil && parsed.Scheme != "" && parsed.Host != "" {
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.RawPath = ""
parsed.Path = trimEasyPayEndpointPath(parsed.Path)
return strings.TrimRight(parsed.String(), "/")
}
return strings.TrimRight(trimEasyPayEndpointPath(base), "/")
}
func trimEasyPayEndpointPath(path string) string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
lower := strings.ToLower(path)
for _, endpoint := range []string{"/submit.php", "/mapi.php", "/api.php"} {
if strings.HasSuffix(lower, endpoint) {
return strings.TrimRight(path[:len(path)-len(endpoint)], "/")
}
}
return path
}
func (e *EasyPay) apiBase() string {
if e == nil {
return ""
}
return normalizeEasyPayAPIBase(e.config["apiBase"])
}
func (e *EasyPay) Name() string { return "EasyPay" }
func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay }
func (e *EasyPay) SupportedTypes() []payment.PaymentType {
types := []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
for _, method := range e.customMethods() {
if method.Type != "" {
types = append(types, method.Type)
}
}
return types
}
func (e *EasyPay) MerchantIdentityMetadata() map[string]string {
if e == nil {
return nil
}
pid := strings.TrimSpace(e.config["pid"])
if pid == "" {
return nil
}
return map[string]string{"pid": pid}
}
func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
// Payment mode determined by instance config, not payment type.
// "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
mode := e.config["paymentMode"]
if mode == paymentModePopup {
return e.createRedirectPayment(req)
}
return e.createAPIPayment(ctx, req)
}
// createRedirectPayment builds a submit.php URL for browser redirect.
// No server-side API call — the user is redirected to EasyPay's hosted page.
// TradeNo is empty; it arrives via the notify callback after payment.
func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
paymentType := e.upstreamPaymentType(req.PaymentType)
params := map[string]string{
"pid": e.config["pid"], "type": paymentType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount,
}
if cid := e.resolveCID(paymentType); cid != "" {
params["cid"] = cid
}
if req.IsMobile {
params["device"] = deviceMobile
}
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
payURL := e.apiBase() + "/submit.php?" + q.Encode()
return &payment.CreatePaymentResponse{PayURL: payURL}, nil
}
// createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior).
func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
paymentType := e.upstreamPaymentType(req.PaymentType)
params := map[string]string{
"pid": e.config["pid"], "type": paymentType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount, "clientip": req.ClientIP,
}
if cid := e.resolveCID(paymentType); cid != "" {
params["cid"] = cid
}
if req.IsMobile {
params["device"] = deviceMobile
}
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5
body, err := e.post(ctx, e.apiBase()+"/mapi.php", params)
if err != nil {
return nil, fmt.Errorf("easypay create: %w", err)
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TradeNo string `json:"trade_no"`
PayURL string `json:"payurl"`
PayURL2 string `json:"payurl2"` // H5 mobile payment URL
QRCode string `json:"qrcode"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse: %w", err)
}
if resp.Code != easypayCodeSuccess {
return nil, fmt.Errorf("easypay error: %s", resp.Msg)
}
payURL := resp.PayURL
if req.IsMobile && resp.PayURL2 != "" {
payURL = resp.PayURL2
}
base := e.apiBase()
return &payment.CreatePaymentResponse{
TradeNo: resp.TradeNo,
PayURL: resolveEasyPayReturnedRef(base, payURL),
QRCode: resolveEasyPayReturnedRef(base, resp.QRCode),
}, nil
}
// resolveEasyPayReturnedRef absolutizes a payurl/payurl2/qrcode reference that
// mapi.php returned, resolving it against the instance's configured apiBase.
//
// EasyPay-compatible upstreams disagree on this field: some answer with a full
// checkout URL, others with a site-root-relative path such as
// "/api/pay/toapp/<order>". createRedirectPayment already builds an absolute URL
// itself (apiBase + "/submit.php?..."), but the mapi.php branch stored whatever
// came back verbatim, and nothing downstream repairs it —
// sanitizeCreatePaymentResponseDetails only strips NUL bytes before the value is
// persisted to pay_url/qr_code. The frontend then feeds qr_code straight into
// QRCode.toCanvas (PaymentQRCodeView.renderQR), so a relative path becomes a QR
// whose payload is a bare path: WeChat renders it as text, and pay_url resolves
// against the gateway's own domain and 404s.
//
// That is precisely the outcome the Alipay provider already refuses to produce —
// "Setting it as QRCode would let the frontend render an unscannable image"
// (createPagePayTrade) — so EasyPay is brought in line with the same rule.
//
// Only references beginning with "/" are resolved. Anything carrying a scheme is
// already actionable and is returned untouched, which covers both absolute
// https:// checkout URLs and app deep links (weixin://, wxp://, alipays://). A
// reference without a leading slash is left alone as well: QR payloads are
// frequently opaque tokens rather than paths, and rewriting "OrderToken123" into
// "<apiBase>/OrderToken123" would break a payload that works today — strictly
// worse than the bug this fixes.
func resolveEasyPayReturnedRef(apiBase, ref string) string {
trimmed := strings.TrimSpace(ref)
if !strings.HasPrefix(trimmed, "/") {
return ref
}
base, err := url.Parse(strings.TrimSpace(apiBase))
if err != nil || base.Scheme == "" || base.Host == "" {
return ref
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme != "" {
return ref
}
return base.ResolveReference(parsed).String()
}
// resolveURLs returns (notifyURL, returnURL) preferring request values,
// falling back to instance config.
func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string) {
notifyURL := req.NotifyURL
if notifyURL == "" {
notifyURL = e.config["notifyUrl"]
}
returnURL := req.ReturnURL
if returnURL == "" {
returnURL = e.config["returnUrl"]
}
return notifyURL, returnURL
}
func (e *EasyPay) customMethods() []easyPayCustomMethod {
if e == nil {
return nil
}
raw := strings.TrimSpace(e.config["customMethods"])
if raw == "" {
return nil
}
var methods []easyPayCustomMethod
if err := json.Unmarshal([]byte(raw), &methods); err != nil {
return nil
}
result := make([]easyPayCustomMethod, 0, len(methods))
for _, method := range methods {
method.Type = strings.TrimSpace(method.Type)
method.UpstreamType = strings.TrimSpace(method.UpstreamType)
method.DisplayName = strings.TrimSpace(method.DisplayName)
if method.Type == "" || method.UpstreamType == "" {
continue
}
result = append(result, method)
}
return result
}
func (e *EasyPay) upstreamPaymentType(paymentType string) string {
paymentType = strings.TrimSpace(paymentType)
for _, method := range e.customMethods() {
if paymentType == method.Type {
return method.UpstreamType
}
}
return paymentType
}
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
params := map[string]string{
"act": "order", "pid": e.config["pid"],
"key": e.config["pkey"], "out_trade_no": tradeNo,
}
body, err := e.post(ctx, e.apiBase()+"/api.php", params)
if err != nil {
return nil, fmt.Errorf("easypay query: %w", err)
}
type easyPayQueryData struct {
TradeStatus *string `json:"trade_status"`
Status *int `json:"status"`
Money *string `json:"money"`
TradeNo *string `json:"trade_no"`
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TradeStatus *string `json:"trade_status"`
Status *int `json:"status"`
Money *string `json:"money"`
TradeNo *string `json:"trade_no"`
Data easyPayQueryData `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse query: %w", err)
}
status := payment.ProviderStatusPending
if resp.TradeStatus != nil {
if *resp.TradeStatus == tradeStatusSuccess {
status = payment.ProviderStatusPaid
}
} else if resp.Data.TradeStatus != nil {
if *resp.Data.TradeStatus == tradeStatusSuccess {
status = payment.ProviderStatusPaid
}
} else if resp.Status != nil {
if *resp.Status == easypayStatusPaid {
status = payment.ProviderStatusPaid
}
} else if resp.Data.Status != nil && *resp.Data.Status == easypayStatusPaid {
status = payment.ProviderStatusPaid
}
money := ""
if resp.Money != nil {
money = *resp.Money
} else if resp.Data.Money != nil {
money = *resp.Data.Money
}
responseTradeNo := tradeNo
if resp.TradeNo != nil {
if *resp.TradeNo != "" {
responseTradeNo = *resp.TradeNo
}
} else if resp.Data.TradeNo != nil && *resp.Data.TradeNo != "" {
responseTradeNo = *resp.Data.TradeNo
}
amount, _ := strconv.ParseFloat(money, 64)
return &payment.QueryOrderResponse{
TradeNo: responseTradeNo,
Status: status,
Amount: amount,
Metadata: e.MerchantIdentityMetadata(),
}, nil
}
func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
values, err := url.ParseQuery(rawBody)
if err != nil {
return nil, fmt.Errorf("parse notify: %w", err)
}
// 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"]
if sign == "" {
return nil, fmt.Errorf("missing sign")
}
if !easyPayVerifySign(params, e.config["pkey"], sign) {
return nil, fmt.Errorf("invalid signature")
}
status := payment.ProviderStatusFailed
if params["trade_status"] == tradeStatusSuccess {
status = payment.ProviderStatusSuccess
}
amount, _ := strconv.ParseFloat(params["money"], 64)
metadata := e.MerchantIdentityMetadata()
if pid := strings.TrimSpace(params["pid"]); pid != "" {
if metadata == nil {
metadata = map[string]string{}
}
metadata["pid"] = pid
}
return &payment.PaymentNotification{
TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
Amount: amount, Status: status, RawData: rawBody, Metadata: metadata,
}, nil
}
func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
attempts := e.refundAttempts(req)
if len(attempts) == 0 {
return nil, fmt.Errorf("easypay refund missing order identifier")
}
var firstErr error
for i, attempt := range attempts {
body, status, err := e.postRaw(ctx, e.apiBase()+"/api.php?act=refund", attempt.params)
if err != nil {
return nil, fmt.Errorf("easypay refund request: %w", err)
}
if err := parseEasyPayRefundResponse(status, body); err != nil {
if firstErr == nil {
firstErr = err
}
if i+1 < len(attempts) && isEasyPayRefundOrderNotFound(err) {
continue
}
return nil, err
}
return &payment.RefundResponse{RefundID: attempt.refundID, Status: payment.ProviderStatusSuccess}, nil
}
return nil, firstErr
}
type easyPayRefundAttempt struct {
params map[string]string
refundID string
}
func (e *EasyPay) refundAttempts(req payment.RefundRequest) []easyPayRefundAttempt {
base := map[string]string{
"pid": e.config["pid"], "key": e.config["pkey"], "money": req.Amount,
}
var attempts []easyPayRefundAttempt
if orderID := strings.TrimSpace(req.OrderID); orderID != "" {
params := cloneStringMap(base)
params["out_trade_no"] = orderID
attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: orderID})
}
if tradeNo := strings.TrimSpace(req.TradeNo); tradeNo != "" {
params := cloneStringMap(base)
params["trade_no"] = tradeNo
attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: tradeNo})
}
return attempts
}
func cloneStringMap(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func isEasyPayRefundOrderNotFound(err error) bool {
if err == nil {
return false
}
msg := err.Error()
lower := strings.ToLower(msg)
return strings.Contains(msg, "订单编号不存在") ||
strings.Contains(msg, "订单不存在") ||
strings.Contains(lower, "order not found") ||
strings.Contains(lower, "not exist")
}
func parseEasyPayRefundResponse(status int, body []byte) error {
summary := summarizeEasyPayResponse(body)
if status < http.StatusOK || status >= http.StatusMultipleChoices {
return fmt.Errorf("easypay refund HTTP %d: %s", status, summary)
}
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return fmt.Errorf("easypay refund empty response (HTTP %d): %s", status, summary)
}
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") ||
(strings.HasPrefix(lower, "<") && strings.Contains(lower, "html")) {
return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
}
var resp struct {
Code any `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
}
if !easyPayResponseCodeIsSuccess(resp.Code) {
msg := strings.TrimSpace(resp.Msg)
if msg == "" {
msg = summary
}
return fmt.Errorf("easypay refund failed (HTTP %d): %s", status, msg)
}
return nil
}
func easyPayResponseCodeIsSuccess(code any) bool {
switch v := code.(type) {
case float64:
return int(v) == easypayCodeSuccess
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
return err == nil && n == easypayCodeSuccess
default:
return false
}
}
func summarizeEasyPayResponse(body []byte) string {
summary := strings.Join(strings.Fields(string(body)), " ")
if summary == "" {
return "<empty>"
}
if len(summary) > maxEasypayErrorSummary {
truncated := summary[:maxEasypayErrorSummary]
for len(truncated) > 0 && !utf8.ValidString(truncated) {
truncated = truncated[:len(truncated)-1]
}
return truncated + "..."
}
return summary
}
func (e *EasyPay) resolveCID(paymentType string) string {
if strings.HasPrefix(paymentType, "alipay") {
if v := e.config["cidAlipay"]; v != "" {
return v
}
return e.config["cid"]
}
if v := e.config["cidWxpay"]; v != "" {
return v
}
return e.config["cid"]
}
func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
body, _, err := e.postRaw(ctx, endpoint, params)
return body, err
}
func (e *EasyPay) postRaw(ctx context.Context, endpoint string, params map[string]string) ([]byte, int, error) {
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := e.httpClient
if client == nil {
client = &http.Client{Timeout: easypayHTTPTimeout}
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxEasypayResponseSize))
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, nil
}
func easyPaySign(params map[string]string, pkey string) string {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "sign" || k == "sign_type" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var buf strings.Builder
for i, k := range keys {
if i > 0 {
_ = buf.WriteByte('&')
}
_, _ = buf.WriteString(k + "=" + params[k])
}
_, _ = buf.WriteString(pkey)
hash := md5.Sum([]byte(buf.String()))
return hex.EncodeToString(hash[:])
}
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,
}