fix(security): 修复可视化辅助读取接口越权

This commit is contained in:
xuwei-fit2cloud
2026-09-30 12:31:22 +08:00
parent f627ffe5fe
commit d45565eb99
5 changed files with 223 additions and 1 deletions

View File

@@ -5,6 +5,11 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import io.dataease.api.dataset.union.DatasetGroupInfoDTO;
import io.dataease.api.permissions.auth.api.InteractiveAuthApi;
import io.dataease.api.permissions.auth.dto.BusiPerCheckDTO;
import io.dataease.constant.AuthEnum;
import io.dataease.constant.BusiResourceEnum;
import io.dataease.result.ResultCode;
import io.dataease.api.report.bo.DatasetPermissionTemplate;
import io.dataease.api.template.dto.TemplateManageFileDTO;
import io.dataease.api.template.dto.VisualizationTemplateExtendDataDTO;
@@ -238,6 +243,40 @@ public class DataVisualizationServer implements DataVisualizationApi {
return null;
}
@Override
public VisualizationNameVO findResourceName(DataVisualizationBaseRequest request) {
DataVisualizationInfo resource = requireVisualizationPermission(request.getId(), AuthEnum.READ);
// Always use the published resource identity, never the caller-selected snapshot.
return new VisualizationNameVO(resource.getId(), resource.getName());
}
private DataVisualizationInfo requireVisualizationPermission(Long id, AuthEnum permission) {
if (id == null || id <= 0 || V3UserUtil.getUid() == null || V3UserUtil.getLink() != null) {
denyVisualizationRead();
}
DataVisualizationInfo resource = dataVisualizationInfoRepository.findById(id).orElse(null);
if (resource == null || Boolean.TRUE.equals(resource.getDeleteFlag())
|| !("dashboard".equals(resource.getType()) || "dataV".equals(resource.getType()))) {
denyVisualizationRead();
}
// Resolve the permission category from stored metadata, not request.busiFlag.
InteractiveAuthApi auth = CommonBeanFactory.getBean(InteractiveAuthApi.class);
if (auth != null) {
auth.checkAuth(new BusiPerCheckDTO(id, "dashboard".equals(resource.getType())
? BusiResourceEnum.PANEL : BusiResourceEnum.SCREEN, permission));
} else if (!Objects.equals(V3UserUtil.getUid(), 1L)
|| CommonBeanFactory.getBean("substituleLoginServer") == null
|| CommonBeanFactory.getBean("loginServer") != null) {
// Only the actual single-user community implementation may omit this service.
denyVisualizationRead();
}
return resource;
}
private void denyVisualizationRead() {
DEException.throwException(ResultCode.PERMISSION_NO_ACCESS.code(), ResultCode.PERMISSION_NO_ACCESS.message());
}
@Override
public DataVisualizationVO findNameById(DataVisualizationBaseRequest request) {
return coreVisualizationManage.findDvInfo(request.getId(), request.getBusiFlag(), request.getResourceTable());
@@ -975,6 +1014,8 @@ public class DataVisualizationServer implements DataVisualizationApi {
@Override
public List<VisualizationViewTableDTO> detailList(Long dvId) {
// This editor-only endpoint exposes draft charts and dataset field metadata.
requireVisualizationPermission(dvId, AuthEnum.MANAGE);
QSnapshotCoreChartView snapshotCoreChartView = QSnapshotCoreChartView.snapshotCoreChartView;
QCoreDatasetTableField coreDatasetTableField = QCoreDatasetTableField.coreDatasetTableField;

View File

@@ -11,6 +11,7 @@ import io.dataease.api.visualization.request.VisualizationWorkbranchQueryRequest
import io.dataease.api.visualization.vo.DataVisualizationVO;
import io.dataease.api.visualization.vo.VisualizationExport2AppVO;
import io.dataease.api.visualization.vo.VisualizationResourceVO;
import io.dataease.api.visualization.vo.VisualizationNameVO;
import io.dataease.auth.DeApiPath;
import io.dataease.auth.DePermit;
import io.dataease.model.BusiNodeRequest;
@@ -42,8 +43,13 @@ public interface DataVisualizationApi {
DataVisualizationVO findById(@RequestBody DataVisualizationBaseRequest request);
@PostMapping("/findNameById")
@DePermit(value = {"#p0.id+':read'"}, busiFlag = "dashboard|dataV")
@Operation(summary = "查询可视化资源名称", hidden = true)
DataVisualizationVO findNameById(@RequestBody DataVisualizationBaseRequest request);
VisualizationNameVO findResourceName(@RequestBody DataVisualizationBaseRequest request);
/** Internal name resolution for audit logs; not an HTTP endpoint. */
@Hidden
DataVisualizationVO findNameById(DataVisualizationBaseRequest request);
@GetMapping("/findCopyResource/{dvId}/{busiFlag}")
@@ -149,6 +155,7 @@ public interface DataVisualizationApi {
@GetMapping("/viewDetailList/{dvId}")
@DePermit(value = {"#p0+':manage'"}, busiFlag = "dashboard|dataV")
@Operation(summary = "仪表板视图明细数据")
List<VisualizationViewTableDTO> detailList(@PathVariable("dvId") Long dvId);

View File

@@ -0,0 +1,14 @@
package io.dataease.api.visualization.vo;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class VisualizationNameVO {
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String name;
}

View File

@@ -0,0 +1,28 @@
# 可视化辅助读取权限回归(TAPD 1076998)
`POST /dataVisualization/findNameById` 保留 HTTP URL,但只返回主表的 `id`、`name`,忽略请求中的 `resourceTable`。该接口要求真实资源的读取权限。用于操作日志的 Java `findNameById` 方法保留原签名,但不再映射为 HTTP 入口。
`GET /dataVisualization/viewDetailList/{dvId}` 服务于编辑器外部参数配置,返回草稿图表和字段元数据,因此要求目标资源的 **MANAGE** 权限。两者均拒绝匿名和公共分享身份,使用数据库中的资源类型进行授权,不信任请求中的 `busiFlag`。组织、继承授权及管理员代理语义沿用现有 `InteractiveAuthApi`。只有实际单用户社区版可缺省该权限服务,企业版服务缺失时拒绝访问。
## 定向回归
仓库根目录使用 Java 21;先安装本次 SDK,再编译后端。测试使用后端解析的依赖类路径(包含 Mockito),不依赖已跳过的 Surefire:
```sh
mvn -q -pl sdk/api/api-base -am install -Dmaven.test.skip=true
mvn -q -f core/core-backend/pom.xml compile dependency:build-classpath -Pstandalone -Dmaven.test.skip=true -Dmdep.outputFile=/tmp/visualization-read-classpath
mkdir -p /tmp/visualization-read-test
visual_test_cp="core/core-backend/target/classes:sdk/common/target/classes:sdk/api/api-base/target/classes:sdk/api/api-permissions/target/classes:$(cat /tmp/visualization-read-classpath)"
javac -proc:none -cp "$visual_test_cp" -d /tmp/visualization-read-test tests/visualization-read-security/Regression.java
java -cp "/tmp/visualization-read-test:$visual_test_cp" Regression
```
25 项断言调用实际服务方法,模拟资源仓库和权限提供方,验证最小响应、草稿参数忽略、实际资源类型解析、READ/MANAGE 区分、删除/不存在/零/空 ID、匿名与分享拒绝、权限服务缺失时拒绝、社区版兼容及 HTTP 映射。权限提供方被模拟,不将这些断言视为真实跨组织授权验证。
## 本地 HTTP 验收
在隔离开发环境创建无角色临时账号,对仪表板和大屏分别执行:无权限时两个接口均拒绝;仅授予 READ 后名称接口只返回 id/name,字段接口拒绝;伪造 busiFlag 与 snapshot 不扩大响应;授予 MANAGE 后字段接口成功。用产品授权 API 修改临时账号权限,最后删除临时账号及授权。
同时检查普通预览/分享继续使用原 `findById` 链路、编辑器外部参数配置可用、操作日志仍能解析资源名称。分布式部署必须更新承载权限注解的 api-base 依赖和核心服务。未覆盖的部署模式、跨组织管理员代理、完整分享/嵌入页面回归需单独验收;本修复不修改 XPack 实现及数据库结构。
本次已执行上述仪表板/大屏 HTTP 权限矩阵,另外验证匿名请求和直连本地后端的无权名称请求均被拒绝。临时账号已删除;未改动测试目标画布、草稿及字段。SDK 安装、后端 package 与 25 项独立断言通过;Maven package 跳过 Surefire,不将构建结果视为测试执行。

View File

@@ -0,0 +1,132 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.querydsl.core.types.Expression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import io.dataease.api.permissions.auth.api.InteractiveAuthApi;
import io.dataease.api.permissions.auth.dto.BusiPerCheckDTO;
import io.dataease.api.visualization.DataVisualizationApi;
import io.dataease.api.visualization.dto.VisualizationViewTableDTO;
import io.dataease.api.visualization.request.DataVisualizationBaseRequest;
import io.dataease.auth.DePermit;
import io.dataease.constant.AuthEnum;
import io.dataease.constant.BusiResourceEnum;
import io.dataease.dao.auto.entity.DataVisualizationInfo;
import io.dataease.exception.DEException;
import io.dataease.permission.model.LinkIdentity;
import io.dataease.permission.util.V3UserUtil;
import io.dataease.utils.CommonBeanFactory;
import io.dataease.visualization.dao.auto.mapper.DataVisualizationInfoRepository;
import io.dataease.visualization.server.DataVisualizationServer;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.*;
import static org.mockito.Mockito.*;
public class Regression {
static int passed;
static void check(boolean value, String name) {
if (!value) throw new AssertionError(name);
passed++;
System.out.println("PASS " + name);
}
static void denied(Runnable work, String name) {
try { work.run(); } catch (DEException expected) { check(true, name); return; }
throw new AssertionError(name);
}
static void set(Object target, String name, Object value) throws Exception {
var field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
static GenericApplicationContext context(Object auth, boolean community, boolean enterprise) {
var context = new GenericApplicationContext();
if (auth != null) context.getBeanFactory().registerSingleton("auth", auth);
if (community) context.getBeanFactory().registerSingleton("substituleLoginServer", new Object());
if (enterprise) context.getBeanFactory().registerSingleton("loginServer", new Object());
context.refresh();
new CommonBeanFactory().setApplicationContext(context);
return context;
}
public static void main(String[] args) throws Exception {
var server = new DataVisualizationServer();
var repository = mock(DataVisualizationInfoRepository.class);
set(server, "dataVisualizationInfoRepository", repository);
set(server, "snapshotDataVisualizationInfoRepository", mock(io.dataease.visualization.dao.auto.mapper.SnapshotDataVisualizationInfoRepository.class));
var resource = new DataVisualizationInfo();
resource.setId(123L); resource.setName("published name"); resource.setType("dashboard");
resource.setComponentData("secret draft/layout must never be serialized");
when(repository.findById(123L)).thenReturn(Optional.of(resource));
when(repository.findById(999L)).thenReturn(Optional.empty());
var auth = mock(InteractiveAuthApi.class);
try (var context = context(auth, false, true)) {
V3UserUtil.setUid(10L);
var request = new DataVisualizationBaseRequest(123L, "dataV");
request.setResourceTable("snapshot");
var name = server.findResourceName(request);
var json = new ObjectMapper().readTree(new ObjectMapper().writeValueAsString(name));
check(json.size() == 2 && json.get("id").asText().equals("123")
&& json.get("name").asText().equals("published name"), "name response contains only id and published name");
verify(auth).checkAuth(new BusiPerCheckDTO(123L, BusiResourceEnum.PANEL, AuthEnum.READ));
check(true, "stored dashboard type overrides forged screen category");
resource.setType("dataV"); server.findResourceName(request);
verify(auth).checkAuth(new BusiPerCheckDTO(123L, BusiResourceEnum.SCREEN, AuthEnum.READ));
check(true, "screen uses screen read authorization");
doAnswer(call -> { DEException.throwException("denied"); return null; }).when(auth).checkAuth(any());
denied(() -> server.findResourceName(request), "unauthorized name read rejected");
denied(() -> server.detailList(123L), "unauthorized metadata rejected before query");
reset(auth);
doAnswer(call -> { if (((BusiPerCheckDTO) call.getArgument(0)).getAuthEnum() == AuthEnum.MANAGE)
DEException.throwException("read only"); return null; }).when(auth).checkAuth(any());
server.findResourceName(request);
denied(() -> server.detailList(123L), "read-only user cannot read draft field metadata");
reset(auth);
var factory = mock(JPAQueryFactory.class);
@SuppressWarnings("unchecked") JPAQuery<VisualizationViewTableDTO> query = mock(JPAQuery.class, RETURNS_SELF);
when(factory.select(org.mockito.ArgumentMatchers.<Expression<VisualizationViewTableDTO>>any())).thenReturn(query);
when(query.fetch()).thenReturn(List.of()); set(server, "queryFactory", factory);
check(server.detailList(123L).isEmpty(), "authorized editor reaches metadata query");
verify(auth).checkAuth(new BusiPerCheckDTO(123L, BusiResourceEnum.SCREEN, AuthEnum.MANAGE));
check(true, "editor metadata requires actual resource manage permission");
clearInvocations(auth, factory);
resource.setDeleteFlag(true);
denied(() -> server.findResourceName(request), "deleted visualization denied");
resource.setDeleteFlag(false);
denied(() -> server.detailList(999L), "missing visualization denied");
denied(() -> server.detailList(0L), "zero id cannot bypass permission provider");
denied(() -> server.detailList(null), "null id denied");
V3UserUtil.clear();
denied(() -> server.findResourceName(request), "anonymous name request denied");
denied(() -> server.detailList(123L), "anonymous metadata request denied");
V3UserUtil.setLink(new LinkIdentity(55L, 10L, 123L, 1L));
denied(() -> server.findResourceName(request), "share identity cannot use internal name endpoint");
denied(() -> server.detailList(123L), "share identity cannot access editor metadata");
verifyNoInteractions(auth, factory);
check(true, "invalid and public-link requests stop before authorization and field query");
} finally { V3UserUtil.clear(); }
try (var context = context(null, false, true)) {
V3UserUtil.setUid(1L);
denied(() -> server.detailList(123L), "missing enterprise authorization service fails closed");
}
try (var context = context(null, true, false)) {
V3UserUtil.setUid(1L);
check(server.findResourceName(new DataVisualizationBaseRequest(123L, "dataV")) != null,
"actual single-user community mode remains available");
V3UserUtil.setUid(10L);
denied(() -> server.detailList(123L), "community fallback never permits arbitrary user");
} finally { V3UserUtil.clear(); }
var internalManage = mock(io.dataease.visualization.manage.CoreVisualizationManage.class);
var internalResult = new io.dataease.api.visualization.vo.DataVisualizationVO();
internalResult.setName("audit name");
when(internalManage.findDvInfo(123L, "dashboard", "core")).thenReturn(internalResult);
set(server, "coreVisualizationManage", internalManage);
check(server.findNameById(new DataVisualizationBaseRequest(123L, "dashboard")) == internalResult,
"internal audit Java contract preserved without HTTP exposure");
var nameMethod = DataVisualizationApi.class.getMethod("findResourceName", DataVisualizationBaseRequest.class);
check(nameMethod.getAnnotation(PostMapping.class).value()[0].equals("/findNameById"), "name HTTP URL preserved");
check(nameMethod.getAnnotation(DePermit.class).value()[0].contains(":read"), "gateway read permission declared");
check(DataVisualizationApi.class.getMethod("detailList", Long.class).getAnnotation(DePermit.class).value()[0].contains(":manage"), "gateway editor permission declared");
check(DataVisualizationApi.class.getMethod("findNameById", DataVisualizationBaseRequest.class).getAnnotation(PostMapping.class) == null,
"internal audit method is no longer mapped to HTTP");
System.out.println("RESULT PASS=" + passed);
}
}