Merge pull request #7673 from william-drakemond/fix/public-order-verify-hardening

fix(payment): 为匿名订单查询接口 /payment/public/orders/verify 增加 IP 限流
This commit is contained in:
Wesley Liddick
2026-10-02 14:31:17 +08:00
committed by GitHub
3 changed files with 106 additions and 2 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ func registerRoutes(
routes.RegisterModelPlazaRoutes(v1, h, optionalJWTAuth, settingService, panelRateLimiter)
routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter)
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter, redisClient)
handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService)
}
+21 -1
View File
@@ -1,12 +1,25 @@
package routes
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
ratelimit "github.com/Wei-Shaw/sub2api/internal/middleware"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// publicOrderVerifyRateLimit caps anonymous legacy out_trade_no lookups per
// client IP. The payment result page polls at most a handful of times per
// order, so this leaves ample headroom for real users while making
// out_trade_no enumeration impractical.
const (
publicOrderVerifyRateLimit = 20
publicOrderVerifyRateLimitWindow = time.Minute
)
// RegisterPaymentRoutes registers all payment-related routes:
@@ -21,6 +34,7 @@ func RegisterPaymentRoutes(
auditLog middleware.AuditLogMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
redisClient *redis.Client,
) {
// --- User-facing payment endpoints (authenticated) ---
authenticated := v1.Group("/payment")
@@ -50,9 +64,15 @@ func RegisterPaymentRoutes(
// Signed resume-token recovery is the preferred public lookup path.
// The legacy anonymous out_trade_no verify endpoint remains available as a
// persisted-state compatibility path for staggered upgrades.
// The anonymous verify endpoint is IP rate-limited to prevent out_trade_no
// enumeration. It fails open on Redis errors so an outage never blocks a
// user who is mid-payment from seeing their result.
publicRateLimiter := ratelimit.NewRateLimiter(redisClient)
public := v1.Group("/payment/public")
{
public.POST("/orders/verify", paymentHandler.VerifyOrderPublic)
public.POST("/orders/verify",
publicRateLimiter.Limit("payment-public-order-verify", publicOrderVerifyRateLimit, publicOrderVerifyRateLimitWindow),
paymentHandler.VerifyOrderPublic)
public.POST("/orders/resolve", paymentHandler.ResolveOrderPublicByResumeToken)
}
@@ -0,0 +1,84 @@
package routes
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func newPaymentRoutesTestRouter(redisClient *redis.Client) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
v1 := router.Group("/api/v1")
noop := func(c *gin.Context) { c.Next() }
RegisterPaymentRoutes(
v1,
&handler.PaymentHandler{},
&handler.PaymentWebhookHandler{},
&admin.PaymentHandler{},
servermiddleware.JWTAuthMiddleware(noop),
servermiddleware.AdminAuthMiddleware(noop),
servermiddleware.AuditLogMiddleware(noop),
nil,
nil,
redisClient,
)
return router
}
func postPublicOrderVerify(router *gin.Engine, remoteAddr string) *httptest.ResponseRecorder {
// Empty body fails binding in the handler, so no service is touched and a
// non-429 response proves the request passed the limiter.
req := httptest.NewRequest(http.MethodPost, "/api/v1/payment/public/orders/verify", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = remoteAddr
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func TestPublicOrderVerifyRateLimitedPerIP(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
router := newPaymentRoutesTestRouter(rdb)
for i := 1; i <= publicOrderVerifyRateLimit; i++ {
w := postPublicOrderVerify(router, "198.51.100.20:1234")
require.Equal(t, http.StatusBadRequest, w.Code, "request %d should reach the handler", i)
}
w := postPublicOrderVerify(router, "198.51.100.20:1234")
require.Equal(t, http.StatusTooManyRequests, w.Code)
require.Contains(t, w.Body.String(), "rate limit exceeded")
// A different client IP has its own budget.
w = postPublicOrderVerify(router, "198.51.100.21:1234")
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestPublicOrderVerifyRateLimitFailsOpenWhenRedisUnavailable(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:1",
DialTimeout: 50 * time.Millisecond,
ReadTimeout: 50 * time.Millisecond,
WriteTimeout: 50 * time.Millisecond,
})
t.Cleanup(func() { _ = rdb.Close() })
router := newPaymentRoutesTestRouter(rdb)
w := postPublicOrderVerify(router, "203.0.113.30:1234")
require.Equal(t, http.StatusBadRequest, w.Code, "users mid-payment must not be blocked by a Redis outage")
}