fix(payment): rate-limit anonymous public order verify endpoint

POST /api/v1/payment/public/orders/verify is unauthenticated and still used
by PaymentResultView as a fallback, so keep it but cap it at 20 req/min per
client IP (fail-open on Redis errors) to make out_trade_no enumeration
impractical without affecting users mid-payment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
William Drake
2026-09-28 12:03:17 -04:00
co-authored by Claude Opus 5.5
parent 9a62841fd1
commit cf19f30d26
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")
}