mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #7673 from william-drakemond/fix/public-order-verify-hardening
fix(payment): 为匿名订单查询接口 /payment/public/orders/verify 增加 IP 限流
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user