mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
fix(wiki): close IDOR in WikiRelationController & WikiEntityController (cross-KB id binding)
Every endpoint now binds its independent id param to an authorized KB: rawId/chunkId resolve-then-workspace-check, pageId is asserted to belong to the path kbId, and slugs stay kbId-scoped. Adds unit tests for same-KB/cross-KB/unknown cases.
This commit is contained in:
parent
04197d7ba9
commit
2f46619e5b
@ -5,14 +5,19 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.wiki.dto.WikiEntityGraphView;
|
||||
import vip.mate.wiki.dto.WikiEntityView;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.service.WikiEntityExtractionService;
|
||||
import vip.mate.wiki.service.WikiEntityGraphService;
|
||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||
import vip.mate.wiki.service.WikiProcessingService;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -30,27 +35,37 @@ public class WikiEntityController {
|
||||
|
||||
private final WikiEntityGraphService graphService;
|
||||
private final WikiEntityExtractionService extractionService;
|
||||
private final WikiKnowledgeBaseService kbService;
|
||||
|
||||
/** List entities in a KB, optionally filtered by type, ranked by salience. */
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/kb/{kbId}/entities")
|
||||
public List<WikiEntityView> listEntities(@PathVariable Long kbId,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(defaultValue = "100") int limit) {
|
||||
@RequestParam(defaultValue = "100") int limit,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
return graphService.listEntities(kbId, type, limit);
|
||||
}
|
||||
|
||||
/** Whole-KB entity graph: top entities by salience plus the edges among them. */
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/kb/{kbId}/entity-graph")
|
||||
public WikiEntityGraphView kbEntityGraph(@PathVariable Long kbId,
|
||||
@RequestParam(defaultValue = "150") int limit) {
|
||||
@RequestParam(defaultValue = "150") int limit,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
return graphService.graph(kbId, limit);
|
||||
}
|
||||
|
||||
/** Ego-graph around a single entity: neighbors, edges, and mentioning pages. */
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/kb/{kbId}/entities/{entityId}/graph")
|
||||
public WikiEntityGraphView entityGraph(@PathVariable Long kbId,
|
||||
@PathVariable Long entityId,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
return graphService.ego(kbId, entityId, limit);
|
||||
}
|
||||
|
||||
@ -60,9 +75,12 @@ public class WikiEntityController {
|
||||
*
|
||||
* @param force when true, re-extract chunks that already have mentions
|
||||
*/
|
||||
@RequireWorkspaceRole("member")
|
||||
@PostMapping("/kb/{kbId}/entities/extract")
|
||||
public Map<String, Object> extract(@PathVariable Long kbId,
|
||||
@RequestParam(defaultValue = "false") boolean force) {
|
||||
@RequestParam(defaultValue = "false") boolean force,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
WikiProcessingService.WIKI_EXECUTOR.submit(() -> {
|
||||
try {
|
||||
int count = extractionService.extractForKb(kbId, force);
|
||||
@ -73,4 +91,17 @@ public class WikiEntityController {
|
||||
});
|
||||
return Map.of("status", "started", "kbId", kbId);
|
||||
}
|
||||
|
||||
// ==================== Workspace Verification ====================
|
||||
|
||||
private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) {
|
||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||
if (kb == null) {
|
||||
throw new MateClawException(404, "Knowledge base not found");
|
||||
}
|
||||
long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||
if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) {
|
||||
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,14 +6,20 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.wiki.dto.*;
|
||||
import vip.mate.wiki.job.WikiProcessingJobService;
|
||||
import vip.mate.wiki.job.event.WikiJobCreatedEvent;
|
||||
import vip.mate.wiki.repository.WikiProcessingJobMapper;
|
||||
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
|
||||
import vip.mate.wiki.model.WikiChunkEntity;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||
import vip.mate.wiki.repository.WikiChunkMapper;
|
||||
import vip.mate.wiki.repository.WikiPageCitationMapper;
|
||||
import vip.mate.wiki.service.*;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@ -37,52 +43,88 @@ public class WikiRelationController {
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WikiEmbeddingService embeddingService;
|
||||
private final WikiKnowledgeBaseService kbService;
|
||||
private final WikiRawMaterialService rawService;
|
||||
private final WikiChunkMapper chunkMapper;
|
||||
|
||||
// ==================== RFC-029: Relations ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/kb/{kbId}/pages/{slug}/related")
|
||||
public List<RelatedPageResult> relatedPages(
|
||||
@PathVariable Long kbId,
|
||||
@PathVariable String slug,
|
||||
@RequestParam(defaultValue = "5") int topK) {
|
||||
@RequestParam(defaultValue = "5") int topK,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
return relationService.relatedPages(kbId, slug, Math.min(topK, 20));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/kb/{kbId}/pages/{slugA}/relation/{slugB}")
|
||||
public RelationExplanation explainRelation(
|
||||
@PathVariable Long kbId,
|
||||
@PathVariable String slugA,
|
||||
@PathVariable String slugB) {
|
||||
@PathVariable String slugB,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
return relationService.explain(kbId, slugA, slugB);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/raw/{rawId}/pages")
|
||||
public List<WikiPageLite> pagesByRawId(@PathVariable Long rawId) {
|
||||
public List<WikiPageLite> pagesByRawId(
|
||||
@PathVariable Long rawId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyRawWorkspace(rawId, workspaceId);
|
||||
return relationService.pagesByRawId(rawId);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/chunks/{chunkId}/pages")
|
||||
public List<WikiPageLite> pagesByChunkId(@PathVariable Long chunkId) {
|
||||
public List<WikiPageLite> pagesByChunkId(
|
||||
@PathVariable Long chunkId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyChunkWorkspace(chunkId, workspaceId);
|
||||
return relationService.pagesByChunkId(chunkId);
|
||||
}
|
||||
|
||||
// ==================== RFC-029: Citations ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/kb/{kbId}/pages/{pageId}/citations")
|
||||
public List<PageCitationWithRaw> pageCitations(
|
||||
@PathVariable Long kbId,
|
||||
@PathVariable Long pageId) {
|
||||
@PathVariable Long pageId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
// Guard against a partial IDOR: kbId is workspace-checked above, but
|
||||
// pageId is an independent path variable that could point at a page in
|
||||
// another KB. Require the resolved page to actually belong to this kbId
|
||||
// (same pattern as the getJobs(rawId) cross-KB filter).
|
||||
WikiPageEntity page = pageService.getById(pageId);
|
||||
if (page == null || !kbId.equals(page.getKbId())) {
|
||||
return List.of();
|
||||
}
|
||||
return citationMapper.listWithRawByPageId(pageId);
|
||||
}
|
||||
|
||||
// ==================== RFC-030: Jobs ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/kb/{kbId}/jobs")
|
||||
public List<WikiProcessingJobEntity> getJobs(
|
||||
@PathVariable Long kbId,
|
||||
@RequestParam(required = false) Long rawId) {
|
||||
@RequestParam(required = false) Long rawId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
if (rawId != null) {
|
||||
return jobMapper.findLatestByRawId(rawId)
|
||||
// Guard against a partial IDOR: kbId is workspace-checked
|
||||
// above, but rawId is an independent query param that could
|
||||
// point at another KB's material. Require the resolved job
|
||||
// to actually belong to this kbId.
|
||||
.filter(j -> kbId.equals(j.getKbId()))
|
||||
.map(List::of).orElse(List.of());
|
||||
}
|
||||
return jobMapper.listQueued(kbId, 20);
|
||||
@ -90,8 +132,12 @@ public class WikiRelationController {
|
||||
|
||||
// ==================== RFC-030/033: KB Stats ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@GetMapping("/kb/{kbId}/stats")
|
||||
public Map<String, Object> kbStats(@PathVariable Long kbId) {
|
||||
public Map<String, Object> kbStats(
|
||||
@PathVariable Long kbId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
int pageCount = pageService.countByKbId(kbId);
|
||||
// Count enriched pages (those containing [[wikilinks]])
|
||||
long enrichedCount = pageService.listByKbIdWithContent(kbId).stream()
|
||||
@ -128,8 +174,13 @@ public class WikiRelationController {
|
||||
|
||||
// ==================== RFC-031: Enrichment & Repair ====================
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@PostMapping("/kb/{kbId}/pages/{slug}/enrich")
|
||||
public Map<String, Object> enrichPage(@PathVariable Long kbId, @PathVariable String slug) {
|
||||
public Map<String, Object> enrichPage(
|
||||
@PathVariable Long kbId,
|
||||
@PathVariable String slug,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||
if (page == null) return Map.of("error", "Page not found: " + slug);
|
||||
|
||||
@ -146,8 +197,13 @@ public class WikiRelationController {
|
||||
return Map.of("jobId", job.getId());
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@PostMapping("/kb/{kbId}/pages/{slug}/repair")
|
||||
public Map<String, Object> repairPage(@PathVariable Long kbId, @PathVariable String slug) {
|
||||
public Map<String, Object> repairPage(
|
||||
@PathVariable Long kbId,
|
||||
@PathVariable String slug,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||
if (page == null) return Map.of("error", "Page not found: " + slug);
|
||||
|
||||
@ -166,13 +222,54 @@ public class WikiRelationController {
|
||||
|
||||
// ==================== RFC-032: Search preview ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@PostMapping("/kb/{kbId}/search-preview")
|
||||
public List<PageSearchResult> searchPreview(
|
||||
@PathVariable Long kbId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyKBWorkspace(kbId, workspaceId);
|
||||
String query = (String) body.getOrDefault("query", "");
|
||||
String mode = (String) body.getOrDefault("mode", "hybrid");
|
||||
int topK = body.containsKey("topK") ? ((Number) body.get("topK")).intValue() : 5;
|
||||
return hybridRetriever.search(kbId, query, mode, Math.min(topK, 20));
|
||||
}
|
||||
|
||||
// ==================== Workspace Verification ====================
|
||||
|
||||
private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) {
|
||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||
if (kb == null) {
|
||||
throw new MateClawException(404, "Knowledge base not found");
|
||||
}
|
||||
long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||
if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) {
|
||||
throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the owning KB of a raw material and check it belongs to the
|
||||
* caller's workspace. Raw materials don't carry workspaceId directly;
|
||||
* they reference a KB which does.
|
||||
*/
|
||||
private void verifyRawWorkspace(Long rawId, Long headerWorkspaceId) {
|
||||
WikiRawMaterialEntity raw = rawService.getById(rawId);
|
||||
if (raw == null || raw.getKbId() == null) {
|
||||
throw new MateClawException(404, "Raw material not found");
|
||||
}
|
||||
verifyKBWorkspace(raw.getKbId(), headerWorkspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the owning KB of a chunk and check it belongs to the caller's
|
||||
* workspace. Like raw materials, chunks reference a KB, not a workspace.
|
||||
*/
|
||||
private void verifyChunkWorkspace(Long chunkId, Long headerWorkspaceId) {
|
||||
WikiChunkEntity chunk = chunkMapper.selectById(chunkId);
|
||||
if (chunk == null || chunk.getKbId() == null) {
|
||||
throw new MateClawException(404, "Chunk not found");
|
||||
}
|
||||
verifyKBWorkspace(chunk.getKbId(), headerWorkspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
package vip.mate.wiki.controller;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.service.WikiEntityExtractionService;
|
||||
import vip.mate.wiki.service.WikiEntityGraphService;
|
||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Verifies the IDOR guard added for ISSUE #438 on
|
||||
* {@link WikiEntityController}: entity-graph endpoints must reject requests
|
||||
* whose target KB belongs to a different workspace than the caller's
|
||||
* {@code X-Workspace-Id} header.
|
||||
*/
|
||||
class WikiEntityControllerIdorTest {
|
||||
|
||||
private WikiKnowledgeBaseService kbService;
|
||||
private WikiEntityController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
kbService = mock(WikiKnowledgeBaseService.class);
|
||||
controller = new WikiEntityController(
|
||||
mock(WikiEntityGraphService.class),
|
||||
mock(WikiEntityExtractionService.class),
|
||||
kbService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("listEntities on another workspace's KB → 403")
|
||||
void listEntitiesCrossWorkspaceRejected() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||
|
||||
assertThatThrownBy(() -> controller.listEntities(10L, null, 100, 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("kbEntityGraph on another workspace's KB → 403")
|
||||
void kbEntityGraphCrossWorkspaceRejected() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||
|
||||
assertThatThrownBy(() -> controller.kbEntityGraph(10L, 150, 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("entityGraph (ego) on another workspace's KB → 403")
|
||||
void entityEgoGraphCrossWorkspaceRejected() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||
|
||||
assertThatThrownBy(() -> controller.entityGraph(10L, 77L, 50, 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extract (member-level write) on another workspace's KB → 403")
|
||||
void extractCrossWorkspaceRejected() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||
|
||||
assertThatThrownBy(() -> controller.extract(10L, false, 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown kbId → 404")
|
||||
void unknownKbReturns404() {
|
||||
when(kbService.getById(999L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.listEntities(999L, null, 100, 1L))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.hasMessageContaining("not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("KB in the caller's workspace passes the guard (no MateClawException)")
|
||||
void sameWorkspaceAllowed() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||
|
||||
// Guard passed → no exception. (graphService is stubbed to return an
|
||||
// empty list so the method returns normally.)
|
||||
assertThatCode(() -> controller.listEntities(10L, null, 100, 1L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
|
||||
private static WikiKnowledgeBaseEntity kb(long id, long workspaceId) {
|
||||
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
|
||||
entity.setId(id);
|
||||
entity.setWorkspaceId(workspaceId);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,299 @@
|
||||
package vip.mate.wiki.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.wiki.dto.PageSearchResult;
|
||||
import vip.mate.wiki.job.WikiProcessingJobService;
|
||||
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
|
||||
import vip.mate.wiki.model.WikiChunkEntity;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||
import vip.mate.wiki.repository.WikiChunkMapper;
|
||||
import vip.mate.wiki.repository.WikiPageCitationMapper;
|
||||
import vip.mate.wiki.repository.WikiProcessingJobMapper;
|
||||
import vip.mate.wiki.service.HybridRetriever;
|
||||
import vip.mate.wiki.service.WikiEmbeddingService;
|
||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||
import vip.mate.wiki.service.WikiPageService;
|
||||
import vip.mate.wiki.service.WikiRawMaterialService;
|
||||
import vip.mate.wiki.service.WikiRelationService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Verifies the IDOR guard added for ISSUE #438: {@link WikiRelationController}
|
||||
* endpoints must reject requests whose target KB belongs to a different
|
||||
* workspace than the caller's {@code X-Workspace-Id} header.
|
||||
*
|
||||
* <p>These are unit-level checks of the {@code verifyKBWorkspace} /
|
||||
* {@code verifyRawWorkspace} / {@code verifyChunkWorkspace} helpers — the
|
||||
* same cross-check pattern that closed the WebChat approval IDOR (#415).
|
||||
* The {@code @RequireWorkspaceRole} annotation layer is validated separately
|
||||
* via the interceptor; here we assert the resource-ownership guard.
|
||||
*/
|
||||
class WikiRelationControllerIdorTest {
|
||||
|
||||
private WikiKnowledgeBaseService kbService;
|
||||
private WikiRawMaterialService rawService;
|
||||
private WikiChunkMapper chunkMapper;
|
||||
private WikiProcessingJobMapper jobMapper;
|
||||
private HybridRetriever hybridRetriever;
|
||||
private WikiPageService pageService;
|
||||
private WikiPageCitationMapper citationMapper;
|
||||
private WikiRelationController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
kbService = mock(WikiKnowledgeBaseService.class);
|
||||
rawService = mock(WikiRawMaterialService.class);
|
||||
chunkMapper = mock(WikiChunkMapper.class);
|
||||
jobMapper = mock(WikiProcessingJobMapper.class);
|
||||
hybridRetriever = mock(HybridRetriever.class);
|
||||
pageService = mock(WikiPageService.class);
|
||||
citationMapper = mock(WikiPageCitationMapper.class);
|
||||
when(hybridRetriever.search(anyLong(), anyString(), anyString(), anyInt()))
|
||||
.thenReturn(List.<PageSearchResult>of());
|
||||
controller = new WikiRelationController(
|
||||
mock(WikiRelationService.class),
|
||||
mock(WikiProcessingJobService.class),
|
||||
jobMapper,
|
||||
pageService,
|
||||
citationMapper,
|
||||
hybridRetriever,
|
||||
mock(ApplicationEventPublisher.class),
|
||||
new ObjectMapper(),
|
||||
mock(WikiEmbeddingService.class),
|
||||
kbService,
|
||||
rawService,
|
||||
chunkMapper);
|
||||
}
|
||||
|
||||
// ---------------- kbId endpoints ----------------
|
||||
|
||||
@Test
|
||||
@DisplayName("search-preview in the caller's workspace succeeds")
|
||||
void searchPreviewSameWorkspaceAllowed() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||
|
||||
assertThatCode(() ->
|
||||
controller.searchPreview(10L, Map.of("query", "x"), 1L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("search-preview on another workspace's KB → 403")
|
||||
void searchPreviewCrossWorkspaceRejected() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB belongs to ws 2
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
controller.searchPreview(10L, Map.of("query", "x"), 1L))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.hasMessageContaining("工作区");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stats on another workspace's KB → 403")
|
||||
void statsCrossWorkspaceRejected() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||
|
||||
assertThatThrownBy(() -> controller.kbStats(10L, 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enrich (member-level write) on another workspace's KB → 403")
|
||||
void enrichCrossWorkspaceRejected() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||
|
||||
assertThatThrownBy(() -> controller.enrichPage(10L, "some-slug", 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("repair on another workspace's KB → 403")
|
||||
void repairCrossWorkspaceRejected() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L));
|
||||
|
||||
assertThatThrownBy(() -> controller.repairPage(10L, "some-slug", 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown kbId → 404 (does not leak existence via workspace mismatch)")
|
||||
void unknownKbReturns404() {
|
||||
when(kbService.getById(999L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.kbStats(999L, 1L))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.hasMessageContaining("not found");
|
||||
}
|
||||
|
||||
// ---------------- getJobs rawId cross-KB filter ----------------
|
||||
|
||||
@Test
|
||||
@DisplayName("getJobs: rawId belonging to the same KB is returned")
|
||||
void getJobsRawIdSameKbReturned() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||
WikiProcessingJobEntity job = new WikiProcessingJobEntity();
|
||||
job.setId(1L);
|
||||
job.setKbId(10L); // same KB as the path → allowed
|
||||
when(jobMapper.findLatestByRawId(50L)).thenReturn(Optional.of(job));
|
||||
|
||||
List<?> result = controller.getJobs(10L, 50L, 1L);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getJobs: rawId pointing at another KB's job is filtered out")
|
||||
void getJobsRawIdCrossKbFiltered() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); // caller's KB
|
||||
WikiProcessingJobEntity foreignJob = new WikiProcessingJobEntity();
|
||||
foreignJob.setId(2L);
|
||||
foreignJob.setKbId(99L); // job belongs to a different KB → dropped
|
||||
when(jobMapper.findLatestByRawId(50L)).thenReturn(Optional.of(foreignJob));
|
||||
|
||||
List<?> result = controller.getJobs(10L, 50L, 1L);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null X-Workspace-Id header falls back to default ws=1")
|
||||
void missingHeaderFallsBackToDefaultWorkspace() {
|
||||
// KB in default workspace (id=1), no header → should pass the guard.
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||
|
||||
assertThatCode(() ->
|
||||
controller.searchPreview(10L, Map.of("query", "x"), null))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("KB with null workspaceId is not rejected (legacy / shared KBs)")
|
||||
void nullWorkspaceKbAllowed() {
|
||||
WikiKnowledgeBaseEntity legacy = new WikiKnowledgeBaseEntity();
|
||||
legacy.setId(10L);
|
||||
legacy.setWorkspaceId(null); // pre-workspace KB
|
||||
when(kbService.getById(10L)).thenReturn(legacy);
|
||||
|
||||
assertThatCode(() ->
|
||||
controller.searchPreview(10L, Map.of("query", "x"), 99L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ---------------- pageCitations cross-KB filter (review on #439) ----------------
|
||||
|
||||
@Test
|
||||
@DisplayName("pageCitations: pageId belonging to the same KB is returned")
|
||||
void pageCitationsSameKbReturned() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||
WikiPageEntity page = new WikiPageEntity();
|
||||
page.setId(77L);
|
||||
page.setKbId(10L); // same KB as the path → allowed
|
||||
when(pageService.getById(77L)).thenReturn(page);
|
||||
when(citationMapper.listWithRawByPageId(77L)).thenReturn(List.of());
|
||||
|
||||
var result = controller.pageCitations(10L, 77L, 1L);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pageCitations: pageId pointing at another KB is filtered out")
|
||||
void pageCitationsCrossKbFiltered() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 1L)); // caller's KB
|
||||
WikiPageEntity foreignPage = new WikiPageEntity();
|
||||
foreignPage.setId(88L);
|
||||
foreignPage.setKbId(99L); // page belongs to a different KB → dropped
|
||||
when(pageService.getById(88L)).thenReturn(foreignPage);
|
||||
|
||||
var result = controller.pageCitations(10L, 88L, 1L);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pageCitations: unknown pageId → empty list")
|
||||
void pageCitationsUnknownPageReturnsEmpty() {
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 1L));
|
||||
when(pageService.getById(999L)).thenReturn(null);
|
||||
|
||||
var result = controller.pageCitations(10L, 999L, 1L);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ---------------- rawId / chunkId endpoints (resolve owning KB) ----------------
|
||||
|
||||
@Test
|
||||
@DisplayName("pagesByRawId resolves KB and rejects cross-workspace")
|
||||
void pagesByRawIdCrossWorkspaceRejected() {
|
||||
WikiRawMaterialEntity raw = new WikiRawMaterialEntity();
|
||||
raw.setId(50L);
|
||||
raw.setKbId(10L);
|
||||
when(rawService.getById(50L)).thenReturn(raw);
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB in ws 2
|
||||
|
||||
assertThatThrownBy(() -> controller.pagesByRawId(50L, 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pagesByChunkId resolves KB and rejects cross-workspace")
|
||||
void pagesByChunkIdCrossWorkspaceRejected() {
|
||||
WikiChunkEntity chunk = new WikiChunkEntity();
|
||||
chunk.setId(60L);
|
||||
chunk.setKbId(10L);
|
||||
when(chunkMapper.selectById(60L)).thenReturn(chunk);
|
||||
when(kbService.getById(10L)).thenReturn(kb(10L, 2L)); // KB in ws 2
|
||||
|
||||
assertThatThrownBy(() -> controller.pagesByChunkId(60L, 1L))
|
||||
.isInstanceOf(MateClawException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown rawId → 404")
|
||||
void unknownRawReturns404() {
|
||||
when(rawService.getById(999L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.pagesByRawId(999L, 1L))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.hasMessageContaining("not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown chunkId → 404")
|
||||
void unknownChunkReturns404() {
|
||||
when(chunkMapper.selectById(999L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> controller.pagesByChunkId(999L, 1L))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.hasMessageContaining("not found");
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
|
||||
private static WikiKnowledgeBaseEntity kb(long id, long workspaceId) {
|
||||
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
|
||||
entity.setId(id);
|
||||
entity.setWorkspaceId(workspaceId);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user