mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
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.
630 lines
19 KiB
Go
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,
|
|
}
|