diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index c7ac7748e9..c0dde5c426 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -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) } diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index ecda25f538..26d0fcd669 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -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) } diff --git a/backend/internal/server/routes/payment_public_rate_limit_test.go b/backend/internal/server/routes/payment_public_rate_limit_test.go new file mode 100644 index 0000000000..3f8c86eab3 --- /dev/null +++ b/backend/internal/server/routes/payment_public_rate_limit_test.go @@ -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") +}