fix: harden platform form and response boundaries
This commit is contained in:
@@ -58,10 +58,20 @@ func listPage[T any](ctx *gin.Context) {
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
|
||||
}
|
||||
|
||||
var keywordExcludedColumns = map[string]bool{
|
||||
"identity": true, "status": true, "password_hash": true,
|
||||
"longitude": true, "latitude": true, "payload": true,
|
||||
"before_data": true, "after_data": true,
|
||||
var keywordSafeColumns = map[string]bool{
|
||||
"code": true, "name": true, "username": true, "display_name": true,
|
||||
"role_code": true, "delivery_code": true, "work_status": true,
|
||||
"credential_type": true, "device_no": true, "model": true,
|
||||
"online_status": true, "rule_code": true, "action": true,
|
||||
"event_code": true, "title": true, "result": true,
|
||||
"product_code": true, "value": true, "order_no": true,
|
||||
"channel": true, "settlement_no": true, "subject_type": true,
|
||||
"content_type": true, "publish_status": true, "template_code": true,
|
||||
"ticket_no": true, "category": true, "priority": true,
|
||||
"platform_role_code": true, "data_scope": true, "menu_code": true,
|
||||
"path": true, "report_code": true, "report_type": true,
|
||||
"stat_period": true, "dimension": true, "metric_code": true,
|
||||
"scope_type": true, "business_type": true, "resource_type": true,
|
||||
}
|
||||
|
||||
func applyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
|
||||
@@ -90,17 +100,26 @@ func keywordColumns(model any) []string {
|
||||
columns := make([]string, 0)
|
||||
for index := 0; index < modelType.NumField(); index++ {
|
||||
field := modelType.Field(index)
|
||||
if field.Anonymous || field.Type.Kind() != reflect.String {
|
||||
if field.Anonymous || field.Type.Kind() != reflect.String || strings.Contains(field.Tag.Get("gorm"), "type:jsonb") {
|
||||
continue
|
||||
}
|
||||
column := gormColumn(field.Tag.Get("gorm"))
|
||||
if column != "" && !keywordExcludedColumns[column] {
|
||||
if keywordSafeColumns[column] && !isSensitiveKeywordColumn(model, column) {
|
||||
columns = append(columns, column)
|
||||
}
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func isSensitiveKeywordColumn(model any, column string) bool {
|
||||
switch model.(type) {
|
||||
case *models.UserAccount, *models.StaffAccount:
|
||||
return column == "name"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gormColumn(tag string) string {
|
||||
for _, part := range strings.Split(tag, ";") {
|
||||
if strings.HasPrefix(part, "column:") {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -210,7 +211,7 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
|
||||
|
||||
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","quantity":2}`))
|
||||
values, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{
|
||||
values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{
|
||||
{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true},
|
||||
{Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true},
|
||||
})
|
||||
@@ -224,12 +225,55 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid
|
||||
|
||||
for _, body := range []string{`{}`, `{"ec_order_id":1}`, `{"quantity":2}`} {
|
||||
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(body))
|
||||
if _, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil {
|
||||
if _, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil {
|
||||
t.Fatalf("payload %s was accepted", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) {
|
||||
t.Run("safety rule", func(t *testing.T) {
|
||||
ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":{"max":10},"action":"close-valve","gray_scope":["north"]}`))
|
||||
values, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action", "gray_scope"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["threshold"] != `{"max":10}` || values["gray_scope"] != `["north"]` {
|
||||
t.Fatalf("jsonb values were not normalized to strings: %#v", values)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("order item", func(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)).
|
||||
WithArgs("order-a", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)).
|
||||
WithArgs("product-a", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
|
||||
|
||||
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","product_snapshot":{"name":"液化气"},"quantity":2,"sale_amount":500}`))
|
||||
values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, []ResourceRelation{
|
||||
{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true},
|
||||
{Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["product_snapshot"] != `{"name":"液化气"}` {
|
||||
t.Fatalf("product snapshot was not normalized to a string: %#v", values)
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
})
|
||||
|
||||
t.Run("invalid json string", func(t *testing.T) {
|
||||
ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve"}`))
|
||||
if _, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action"}, nil); err == nil {
|
||||
t.Fatal("invalid JSON string was accepted for a string/jsonb field")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "gas_basic" WHERE identity = $1 ORDER BY "gas_basic"."id" LIMIT $2`)).
|
||||
@@ -274,6 +318,26 @@ func TestListGasAccountAppliesKeywordToCountAndRows(t *testing.T) {
|
||||
assertMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestKeywordColumnsUseSafeTextAllowlist(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model any
|
||||
want []string
|
||||
}{
|
||||
{"safety rule excludes jsonb", &models.SafRule{}, []string{"rule_code", "action"}},
|
||||
{"gas basic excludes sensitive fields", &models.GasBasic{}, []string{"code", "name"}},
|
||||
{"user address has no searchable safe text", &models.UserAddress{}, []string{}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := keywordColumns(test.model)
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("keywordColumns(%T) = %#v, want %#v", test.model, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" ORDER BY sort_no asc, id asc`)).
|
||||
@@ -304,24 +368,30 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatedResourceResponseMasksSensitiveFieldsAndKeepsIdentity(t *testing.T) {
|
||||
func TestCreatedResourceResponseUsesSafeAllowlist(t *testing.T) {
|
||||
response := maskCreatedSensitiveFields(map[string]any{
|
||||
"identity": "user-a",
|
||||
"phone": "13800138000",
|
||||
"real_name": "张三",
|
||||
"credential_no": "CERT-123456",
|
||||
"longitude": "120.123456",
|
||||
"latitude": "30.456789",
|
||||
"identity": "address-a",
|
||||
"user_account_identity": "user-a",
|
||||
"status": "draft",
|
||||
"version": float64(1),
|
||||
"phone": "13800138000",
|
||||
"real_name": "张三",
|
||||
"credential_no": "CERT-123456",
|
||||
"address": "敏感详细地址",
|
||||
"principal": "负责人",
|
||||
"credit_code": "CREDIT-123",
|
||||
"longitude": "120.123456",
|
||||
"latitude": "30.456789",
|
||||
})
|
||||
encoded, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(encoded)
|
||||
if !strings.Contains(body, `"identity":"user-a"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) {
|
||||
t.Fatalf("created response omitted identity or masked phone: %s", body)
|
||||
if !strings.Contains(body, `"identity":"address-a"`) || !strings.Contains(body, `"user_account_identity":"user-a"`) || !strings.Contains(body, `"status":"draft"`) {
|
||||
t.Fatalf("created response omitted safe public fields: %s", body)
|
||||
}
|
||||
for _, forbidden := range []string{`"phone":`, `"real_name":`, `"credential_no":`, `"longitude":`, `"latitude":`, "张三", "CERT-123456", "120.123456", "30.456789"} {
|
||||
for _, forbidden := range []string{`"phone"`, `"phone_masked"`, `"real_name"`, `"credential_no"`, `"address"`, `"principal"`, `"credit_code"`, `"longitude"`, `"latitude"`, "敏感详细地址", "负责人", "CREDIT-123", "120.123456", "30.456789"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("created response exposed sensitive field %s: %s", forbidden, body)
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func getResource(ctx *gin.Context, model any) {
|
||||
}
|
||||
|
||||
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
|
||||
values, err := prepareResourceValues(ctx, allowedFields, relations)
|
||||
values, err := prepareResourceValues(ctx, model, allowedFields, relations)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
@@ -161,41 +161,30 @@ func respondCreatedResource(ctx *gin.Context, value any) {
|
||||
func maskCreatedSensitiveFields(value any) any {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
if phone, ok := data["phone"].(string); ok {
|
||||
data["phone_masked"] = maskPhone(phone)
|
||||
delete(data, "phone")
|
||||
}
|
||||
if realName, ok := data["real_name"].(string); ok {
|
||||
data["real_name_masked"] = maskSecret(realName)
|
||||
delete(data, "real_name")
|
||||
}
|
||||
if credentialNo, ok := data["credential_no"].(string); ok {
|
||||
data["credential_no_masked"] = maskSecret(credentialNo)
|
||||
delete(data, "credential_no")
|
||||
}
|
||||
delete(data, "longitude")
|
||||
delete(data, "latitude")
|
||||
safe := make(map[string]any)
|
||||
for key, item := range data {
|
||||
data[key] = maskCreatedSensitiveFields(item)
|
||||
if isCreatedResponseField(key) {
|
||||
safe[key] = maskCreatedSensitiveFields(item)
|
||||
}
|
||||
}
|
||||
return safe
|
||||
case []any:
|
||||
for index := range data {
|
||||
data[index] = maskCreatedSensitiveFields(data[index])
|
||||
safe := make([]any, len(data))
|
||||
for index, item := range data {
|
||||
safe[index] = maskCreatedSensitiveFields(item)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func maskSecret(value string) string {
|
||||
characters := []rune(value)
|
||||
if len(characters) <= 1 {
|
||||
return "*"
|
||||
func isCreatedResponseField(key string) bool {
|
||||
switch key {
|
||||
case "identity", "status", "version", "created_at", "updated_at":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(key, "_identity")
|
||||
}
|
||||
visible := 1
|
||||
if len(characters) > 4 {
|
||||
visible = 4
|
||||
}
|
||||
return strings.Repeat("*", len(characters)-visible) + string(characters[len(characters)-visible:])
|
||||
}
|
||||
|
||||
func protectPreciseLocation(ctx *gin.Context, model, value any) any {
|
||||
@@ -237,7 +226,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio
|
||||
return
|
||||
}
|
||||
values, err := resolveResourceRelations(input, allowedFields, relations, false)
|
||||
if err != nil {
|
||||
if err != nil || normalizeStringJSONBFields(model, values) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -248,18 +237,48 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio
|
||||
updateAllowedByIdentity(ctx, model, values, append(allowedFields, relationColumns(relations)...))
|
||||
}
|
||||
|
||||
func prepareResourceValues(ctx *gin.Context, allowedFields []string, relations []ResourceRelation) (map[string]any, error) {
|
||||
func prepareResourceValues(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) (map[string]any, error) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil || len(input) == 0 {
|
||||
return nil, errors.New("invalid resource payload")
|
||||
}
|
||||
values, err := resolveResourceRelations(input, allowedFields, relations, true)
|
||||
if err != nil || len(values) == 0 {
|
||||
if err != nil || normalizeStringJSONBFields(model, values) != nil || len(values) == 0 {
|
||||
return nil, errors.New("invalid resource payload")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func normalizeStringJSONBFields(model any, values map[string]any) error {
|
||||
modelType := reflect.TypeOf(model)
|
||||
for modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
for index := 0; index < modelType.NumField(); index++ {
|
||||
field := modelType.Field(index)
|
||||
if field.Type.Kind() != reflect.String || !strings.Contains(field.Tag.Get("gorm"), "type:jsonb") {
|
||||
continue
|
||||
}
|
||||
column := gormColumn(field.Tag.Get("gorm"))
|
||||
value, exists := values[column]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
if !json.Valid([]byte(text)) {
|
||||
return errors.New("invalid jsonb string")
|
||||
}
|
||||
continue
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil || !json.Valid(encoded) {
|
||||
return errors.New("invalid jsonb value")
|
||||
}
|
||||
values[column] = string(encoded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
|
||||
values := filterFields(input, allowedFields)
|
||||
for _, relation := range relations {
|
||||
|
||||
Reference in New Issue
Block a user