Преглед изворни кода

feat(backend): gate request bodies with go-playground/validator

Add a generic BindAndValidate helper in web/middleware that wraps gin's
content-aware binder with an explicit validator.Struct call and emits a
structured `entity.Msg{Obj: ValidationPayload{Issues...}}` on failure so
the frontend can map each issue to an i18n key.

Tag the user-facing fields on model.Inbound, model.Node, and
entity.AllSetting with the range/enum constraints they were previously
relying on hand-rolled CheckValid logic (or nothing) to enforce, and
wire the helper into the inbound/node/settings controllers that bind
those structs directly. Promotes validator/v10 from indirect to direct
require, plus six unit tests covering valid payloads, range violations,
enum violations, malformed JSON, in-place binding, and JSON-only strict
mode.

This is PR1 of a planned end-to-end Zod rollout — controllers using
local form structs (custom_geo, setEnable, fallbacks, client) keep
their existing handling and will be migrated as their schemas firm up.
MHSanaei пре 12 часа
родитељ
комит
7fda988fb2

+ 8 - 8
database/model/model.go

@@ -53,14 +53,14 @@ type Inbound struct {
 	Remark               string               `json:"remark" form:"remark"`                                                                            // Human-readable remark
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                           // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                    // Expiration timestamp
-	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
+	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
 	LastTrafficResetTime int64                `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`                               // Last traffic reset timestamp
 	ClientStats          []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`                        // Client traffic statistics
 
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
-	Port           int      `json:"port" form:"port"`
-	Protocol       Protocol `json:"protocol" form:"protocol"`
+	Port           int      `json:"port" form:"port" validate:"gte=1,lte=65535"`
+	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria hysteria2 http mixed tunnel"`
 	Settings       string   `json:"settings" form:"settings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
@@ -247,13 +247,13 @@ type Setting struct {
 // status fields below.
 type Node struct {
 	Id                  int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
-	Name                string `json:"name" form:"name" gorm:"uniqueIndex"`
+	Name                string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
 	Remark              string `json:"remark" form:"remark"`
-	Scheme              string `json:"scheme" form:"scheme"`
-	Address             string `json:"address" form:"address"`
-	Port                int    `json:"port" form:"port"`
+	Scheme              string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
+	Address             string `json:"address" form:"address" validate:"required"`
+	Port                int    `json:"port" form:"port" validate:"gte=1,lte=65535"`
 	BasePath            string `json:"basePath" form:"basePath"`
-	ApiToken            string `json:"apiToken" form:"apiToken"`
+	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required"`
 	Enable              bool   `json:"enable" form:"enable" gorm:"default:true"`
 	AllowPrivateAddress bool   `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
 

+ 1 - 1
go.mod

@@ -7,6 +7,7 @@ require (
 	github.com/gin-contrib/sessions v1.1.0
 	github.com/gin-gonic/gin v1.12.0
 	github.com/go-ldap/ldap/v3 v3.4.13
+	github.com/go-playground/validator/v10 v10.30.2
 	github.com/goccy/go-json v0.10.6
 	github.com/goccy/go-yaml v1.19.2
 	github.com/google/uuid v1.6.0
@@ -48,7 +49,6 @@ require (
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
-	github.com/go-playground/validator/v10 v10.30.2 // indirect
 	github.com/google/btree v1.1.3 // indirect
 	github.com/gorilla/context v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect

+ 4 - 7
web/controller/inbound.go

@@ -8,6 +8,7 @@ import (
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 	"github.com/mhsanaei/3x-ui/v3/web/session"
 	"github.com/mhsanaei/3x-ui/v3/web/websocket"
@@ -129,10 +130,8 @@ func (a *InboundController) getInbound(c *gin.Context) {
 
 // addInbound creates a new inbound configuration.
 func (a *InboundController) addInbound(c *gin.Context) {
-	inbound := &model.Inbound{}
-	err := c.ShouldBind(inbound)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
+	inbound, ok := middleware.BindAndValidate[model.Inbound](c)
+	if !ok {
 		return
 	}
 	user := session.GetLoginUser(c)
@@ -200,9 +199,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 	inbound := &model.Inbound{
 		Id: id,
 	}
-	err = c.ShouldBind(inbound)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
+	if !middleware.BindAndValidateInto(c, inbound) {
 		return
 	}
 	// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound

+ 5 - 6
web/controller/node.go

@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
@@ -61,9 +62,8 @@ func (a *NodeController) get(c *gin.Context) {
 }
 
 func (a *NodeController) add(c *gin.Context) {
-	n := &model.Node{}
-	if err := c.ShouldBind(n); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
+	n, ok := middleware.BindAndValidate[model.Node](c)
+	if !ok {
 		return
 	}
 	if err := a.nodeService.Create(n); err != nil {
@@ -79,9 +79,8 @@ func (a *NodeController) update(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "get"), err)
 		return
 	}
-	n := &model.Node{}
-	if err := c.ShouldBind(n); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
+	n, ok := middleware.BindAndValidate[model.Node](c)
+	if !ok {
 		return
 	}
 	if err := a.nodeService.Update(id, n); err != nil {

+ 4 - 5
web/controller/setting.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 	"github.com/mhsanaei/3x-ui/v3/web/session"
 
@@ -74,14 +75,12 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
 
 // updateSetting updates all settings with the provided data.
 func (a *SettingController) updateSetting(c *gin.Context) {
-	allSetting := &entity.AllSetting{}
-	err := c.ShouldBind(allSetting)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+	allSetting, ok := middleware.BindAndValidate[entity.AllSetting](c)
+	if !ok {
 		return
 	}
 	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
-	err = a.settingService.UpdateAllSetting(allSetting)
+	err := a.settingService.UpdateAllSetting(allSetting)
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
 			err = bumpErr

+ 22 - 22
web/entity/entity.go

@@ -21,21 +21,21 @@ type Msg struct {
 // AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
 type AllSetting struct {
 	// Web server settings
-	WebListen         string `json:"webListen" form:"webListen"`                 // Web server listen IP address
-	WebDomain         string `json:"webDomain" form:"webDomain"`                 // Web server domain for domain validation
-	WebPort           int    `json:"webPort" form:"webPort"`                     // Web server port number
-	WebCertFile       string `json:"webCertFile" form:"webCertFile"`             // Path to SSL certificate file for web server
-	WebKeyFile        string `json:"webKeyFile" form:"webKeyFile"`               // Path to SSL private key file for web server
-	WebBasePath       string `json:"webBasePath" form:"webBasePath"`             // Base path for web panel URLs
-	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge"`         // Session maximum age in minutes
-	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
+	WebListen         string `json:"webListen" form:"webListen"`                                       // Web server listen IP address
+	WebDomain         string `json:"webDomain" form:"webDomain"`                                       // Web server domain for domain validation
+	WebPort           int    `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`                // Web server port number
+	WebCertFile       string `json:"webCertFile" form:"webCertFile"`                                   // Path to SSL certificate file for web server
+	WebKeyFile        string `json:"webKeyFile" form:"webKeyFile"`                                     // Path to SSL private key file for web server
+	WebBasePath       string `json:"webBasePath" form:"webBasePath"`                                   // Base path for web panel URLs
+	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=0,lte=525600"`   // Session maximum age in minutes (cap at one year)
+	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`                       // Trusted reverse proxy IPs/CIDRs for forwarded headers
 
 	// UI settings
-	PageSize    int    `json:"pageSize" form:"pageSize"`       // Number of items per page in lists
-	ExpireDiff  int    `json:"expireDiff" form:"expireDiff"`   // Expiration warning threshold in days
-	TrafficDiff int    `json:"trafficDiff" form:"trafficDiff"` // Traffic warning threshold percentage
-	RemarkModel string `json:"remarkModel" form:"remarkModel"` // Remark model pattern for inbounds
-	Datepicker  string `json:"datepicker" form:"datepicker"`   // Date picker format
+	PageSize    int    `json:"pageSize" form:"pageSize" validate:"gte=1,lte=1000"`     // Number of items per page in lists
+	ExpireDiff  int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`          // Expiration warning threshold in days
+	TrafficDiff int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`// Traffic warning threshold percentage
+	RemarkModel string `json:"remarkModel" form:"remarkModel"`                         // Remark model pattern for inbounds
+	Datepicker  string `json:"datepicker" form:"datepicker"`                           // Date picker format
 
 	// Telegram bot settings
 	TgBotEnable      bool   `json:"tgBotEnable" form:"tgBotEnable"`           // Enable Telegram bot notifications
@@ -45,9 +45,9 @@ type AllSetting struct {
 	TgBotChatId      string `json:"tgBotChatId" form:"tgBotChatId"`           // Telegram chat ID for notifications
 	TgRunTime        string `json:"tgRunTime" form:"tgRunTime"`               // Cron schedule for Telegram notifications
 	TgBotBackup      bool   `json:"tgBotBackup" form:"tgBotBackup"`           // Enable database backup via Telegram
-	TgBotLoginNotify bool   `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications
-	TgCpu            int    `json:"tgCpu" form:"tgCpu"`                       // CPU usage threshold for alerts
-	TgLang           string `json:"tgLang" form:"tgLang"`                     // Telegram bot language
+	TgBotLoginNotify bool   `json:"tgBotLoginNotify" form:"tgBotLoginNotify"`             // Send login notifications
+	TgCpu            int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`          // CPU usage threshold for alerts (percent)
+	TgLang           string `json:"tgLang" form:"tgLang"`                                 // Telegram bot language
 
 	// Security settings
 	TimeLocation    string `json:"timeLocation" form:"timeLocation"`       // Time zone location
@@ -64,12 +64,12 @@ type AllSetting struct {
 	SubEnableRouting            bool   `json:"subEnableRouting" form:"subEnableRouting"`                       // Enable routing for subscription
 	SubRoutingRules             string `json:"subRoutingRules" form:"subRoutingRules"`                         // Subscription global routing rules (Only for Happ)
 	SubListen                   string `json:"subListen" form:"subListen"`                                     // Subscription server listen IP
-	SubPort                     int    `json:"subPort" form:"subPort"`                                         // Subscription server port
+	SubPort                     int    `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`              // Subscription server port
 	SubPath                     string `json:"subPath" form:"subPath"`                                         // Base path for subscription URLs
 	SubDomain                   string `json:"subDomain" form:"subDomain"`                                     // Domain for subscription server validation
 	SubCertFile                 string `json:"subCertFile" form:"subCertFile"`                                 // SSL certificate file for subscription server
 	SubKeyFile                  string `json:"subKeyFile" form:"subKeyFile"`                                   // SSL private key file for subscription server
-	SubUpdates                  int    `json:"subUpdates" form:"subUpdates"`                                   // Subscription update interval in minutes
+	SubUpdates                  int    `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`       // Subscription update interval in minutes
 	ExternalTrafficInformEnable bool   `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting
 	ExternalTrafficInformURI    string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`       // URI for external traffic reporting
 	RestartXrayOnClientDisable  bool   `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`   // Restart Xray when clients are auto-disabled by expiry/traffic limit
@@ -90,7 +90,7 @@ type AllSetting struct {
 	// LDAP settings
 	LdapEnable     bool   `json:"ldapEnable" form:"ldapEnable"`
 	LdapHost       string `json:"ldapHost" form:"ldapHost"`
-	LdapPort       int    `json:"ldapPort" form:"ldapPort"`
+	LdapPort       int    `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
 	LdapUseTLS     bool   `json:"ldapUseTLS" form:"ldapUseTLS"`
 	LdapBindDN     string `json:"ldapBindDN" form:"ldapBindDN"`
 	LdapPassword   string `json:"ldapPassword" form:"ldapPassword"`
@@ -106,9 +106,9 @@ type AllSetting struct {
 	LdapInboundTags       string `json:"ldapInboundTags" form:"ldapInboundTags"`
 	LdapAutoCreate        bool   `json:"ldapAutoCreate" form:"ldapAutoCreate"`
 	LdapAutoDelete        bool   `json:"ldapAutoDelete" form:"ldapAutoDelete"`
-	LdapDefaultTotalGB    int    `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
-	LdapDefaultExpiryDays int    `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
-	LdapDefaultLimitIP    int    `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
+	LdapDefaultTotalGB    int    `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
+	LdapDefaultExpiryDays int    `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
+	LdapDefaultLimitIP    int    `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
 	// JSON subscription routing rules
 }
 

+ 111 - 0
web/middleware/validate.go

@@ -0,0 +1,111 @@
+package middleware
+
+import (
+	"errors"
+	"net/http"
+	"reflect"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+	"github.com/gin-gonic/gin/binding"
+	"github.com/go-playground/validator/v10"
+
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+)
+
+var validate = validator.New(validator.WithRequiredStructEnabled())
+
+func BindAndValidate[T any](c *gin.Context) (*T, bool) {
+	var dst T
+	if err := c.ShouldBind(&dst); err != nil {
+		writeBindFailure(c, err)
+		return nil, false
+	}
+	if err := validate.Struct(&dst); err != nil {
+		writeBindFailure(c, err)
+		return nil, false
+	}
+	return &dst, true
+}
+
+func BindAndValidateInto(c *gin.Context, dst any) bool {
+	if err := c.ShouldBind(dst); err != nil {
+		writeBindFailure(c, err)
+		return false
+	}
+	if err := validate.Struct(dst); err != nil {
+		writeBindFailure(c, err)
+		return false
+	}
+	return true
+}
+
+func BindJSONAndValidate[T any](c *gin.Context) (*T, bool) {
+	var dst T
+	if err := c.ShouldBindWith(&dst, binding.JSON); err != nil {
+		writeBindFailure(c, err)
+		return nil, false
+	}
+	if err := validate.Struct(&dst); err != nil {
+		writeBindFailure(c, err)
+		return nil, false
+	}
+	return &dst, true
+}
+
+func BindJSONAndValidateInto(c *gin.Context, dst any) bool {
+	if err := c.ShouldBindWith(dst, binding.JSON); err != nil {
+		writeBindFailure(c, err)
+		return false
+	}
+	if err := validate.Struct(dst); err != nil {
+		writeBindFailure(c, err)
+		return false
+	}
+	return true
+}
+
+type FieldIssue struct {
+	Field   string `json:"field"`
+	Rule    string `json:"rule"`
+	Param   string `json:"param,omitempty"`
+	Message string `json:"message"`
+}
+
+type ValidationPayload struct {
+	Issues  []FieldIssue `json:"issues"`
+	Message string       `json:"message"`
+}
+
+func writeBindFailure(c *gin.Context, err error) {
+	payload := ValidationPayload{Issues: []FieldIssue{}, Message: err.Error()}
+
+	var ve validator.ValidationErrors
+	if errors.As(err, &ve) {
+		payload.Issues = make([]FieldIssue, 0, len(ve))
+		for _, fe := range ve {
+			payload.Issues = append(payload.Issues, FieldIssue{
+				Field:   fe.Field(),
+				Rule:    fe.Tag(),
+				Param:   fe.Param(),
+				Message: fe.Error(),
+			})
+		}
+	}
+
+	c.AbortWithStatusJSON(http.StatusOK, entity.Msg{
+		Success: false,
+		Msg:     "request body failed validation",
+		Obj:     payload,
+	})
+}
+
+func init() {
+	validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
+		name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
+		if name == "-" || name == "" {
+			return fld.Name
+		}
+		return name
+	})
+}

+ 207 - 0
web/middleware/validate_test.go

@@ -0,0 +1,207 @@
+package middleware
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+)
+
+type sampleBody struct {
+	Port     int    `json:"port" form:"port" validate:"gte=1,lte=65535"`
+	Protocol string `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan"`
+	Tag      string `json:"tag" form:"tag"`
+}
+
+func newRouter(handler gin.HandlerFunc) *gin.Engine {
+	gin.SetMode(gin.TestMode)
+	r := gin.New()
+	r.POST("/submit", handler)
+	return r
+}
+
+func decodeMsg(t *testing.T, body string) entity.Msg {
+	t.Helper()
+	var msg entity.Msg
+	if err := json.Unmarshal([]byte(body), &msg); err != nil {
+		t.Fatalf("decode msg: %v (body=%q)", err, body)
+	}
+	return msg
+}
+
+func TestBindAndValidate_ValidPayloadPassesThrough(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		got, ok := BindAndValidate[sampleBody](c)
+		if !ok {
+			t.Fatalf("expected ok=true, got false (body should be valid)")
+		}
+		if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" {
+			t.Fatalf("decoded payload mismatch: %+v", got)
+		}
+		c.JSON(http.StatusOK, entity.Msg{Success: true, Msg: "ok"})
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
+	}
+	if msg := decodeMsg(t, rec.Body.String()); !msg.Success {
+		t.Fatalf("expected Success=true; got %+v", msg)
+	}
+}
+
+func TestBindAndValidate_PortOutOfRangeIsRejected(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false on invalid port; got true")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":70000,"protocol":"vless"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	msg := decodeMsg(t, rec.Body.String())
+	if msg.Success {
+		t.Fatalf("expected Success=false; got %+v", msg)
+	}
+	payload, err := payloadFromObj(msg.Obj)
+	if err != nil {
+		t.Fatalf("payload extraction: %v", err)
+	}
+	found := false
+	for _, issue := range payload.Issues {
+		if issue.Field == "port" && issue.Rule == "lte" {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Fatalf("expected an Issue for field=port rule=lte; got %+v", payload.Issues)
+	}
+}
+
+func TestBindAndValidate_ProtocolEnumIsRejected(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false on invalid protocol; got true")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":443,"protocol":"unknown"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	msg := decodeMsg(t, rec.Body.String())
+	payload, err := payloadFromObj(msg.Obj)
+	if err != nil {
+		t.Fatalf("payload extraction: %v", err)
+	}
+	found := false
+	for _, issue := range payload.Issues {
+		if issue.Field == "protocol" && issue.Rule == "oneof" {
+			found = true
+		}
+	}
+	if !found {
+		t.Fatalf("expected an Issue for field=protocol rule=oneof; got %+v", payload.Issues)
+	}
+}
+
+func TestBindAndValidate_MalformedJSONReturnsMessageButNoIssues(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false on malformed JSON; got true")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	msg := decodeMsg(t, rec.Body.String())
+	if msg.Success {
+		t.Fatal("expected Success=false on malformed JSON")
+	}
+	payload, err := payloadFromObj(msg.Obj)
+	if err != nil {
+		t.Fatalf("payload extraction: %v", err)
+	}
+	if len(payload.Issues) != 0 {
+		t.Fatalf("expected empty Issues for parse error; got %+v", payload.Issues)
+	}
+	if payload.Message == "" {
+		t.Fatal("expected non-empty Message describing the parse error")
+	}
+}
+
+func TestBindAndValidateInto_PreservesPrePopulatedFields(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		dst := &sampleBody{Tag: "preset"}
+		if !BindAndValidateInto(c, dst) {
+			t.Fatal("expected ok=true; got false")
+		}
+		if dst.Tag != "inbound-443" {
+			t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag)
+		}
+		if dst.Port != 443 {
+			t.Fatalf("expected Port=443; got %d", dst.Port)
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":443,"protocol":"trojan","tag":"inbound-443"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+	}
+}
+
+func TestBindJSONAndValidate_RejectsFormEncodedBody(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindJSONAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false for form-encoded request to a JSON-only endpoint")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader("port=443&protocol=vless"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	r.ServeHTTP(rec, req)
+
+	if msg := decodeMsg(t, rec.Body.String()); msg.Success {
+		t.Fatalf("expected Success=false; got %+v", msg)
+	}
+}
+
+func payloadFromObj(obj any) (ValidationPayload, error) {
+	raw, err := json.Marshal(obj)
+	if err != nil {
+		return ValidationPayload{}, err
+	}
+	var payload ValidationPayload
+	if err := json.Unmarshal(raw, &payload); err != nil {
+		return ValidationPayload{}, err
+	}
+	return payload, nil
+}