mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
feat(wiki): unified source-path validation with symlink resolution and allowed roots
This commit is contained in:
parent
eb63ce4865
commit
16a7aadbcd
@ -71,6 +71,15 @@ public class WikiProperties {
|
|||||||
/** 扫描时跳过大于此大小的文件(字节),默认 50MB */
|
/** 扫描时跳过大于此大小的文件(字节),默认 50MB */
|
||||||
private long maxScanFileSize = 50 * 1024 * 1024;
|
private long maxScanFileSize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed root directories for KB source directories. When non-empty, a
|
||||||
|
* configured source directory must resolve (after symlink resolution) to a
|
||||||
|
* path inside one of these roots, blocking arbitrary directory reads. Empty
|
||||||
|
* (the default) disables the containment check — suitable for desktop /
|
||||||
|
* single-tenant; server operators should set this.
|
||||||
|
*/
|
||||||
|
private java.util.List<String> allowedSourceRoots = new java.util.ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wiki LLM 重试最大尝试次数(含首次)。
|
* Wiki LLM 重试最大尝试次数(含首次)。
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import vip.mate.wiki.model.WikiPageTypeProfileEntity;
|
|||||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||||
import vip.mate.wiki.profile.WikiPageTypeProfileService;
|
import vip.mate.wiki.profile.WikiPageTypeProfileService;
|
||||||
import vip.mate.wiki.service.WikiDirectoryScanService;
|
import vip.mate.wiki.service.WikiDirectoryScanService;
|
||||||
|
import vip.mate.wiki.service.WikiSourcePathValidator;
|
||||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||||
import vip.mate.wiki.service.WikiLintJobService;
|
import vip.mate.wiki.service.WikiLintJobService;
|
||||||
import vip.mate.wiki.service.WikiPageService;
|
import vip.mate.wiki.service.WikiPageService;
|
||||||
@ -59,6 +60,7 @@ public class WikiController {
|
|||||||
private final WikiProgressBus progressBus;
|
private final WikiProgressBus progressBus;
|
||||||
private final AuditEventService auditEventService;
|
private final AuditEventService auditEventService;
|
||||||
private final WikiPageTypeProfileService pageTypeProfileService;
|
private final WikiPageTypeProfileService pageTypeProfileService;
|
||||||
|
private final WikiSourcePathValidator pathValidator;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
// ==================== Knowledge Base ====================
|
// ==================== Knowledge Base ====================
|
||||||
@ -274,6 +276,13 @@ public class WikiController {
|
|||||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||||
verifyKBWorkspace(id, workspaceId);
|
verifyKBWorkspace(id, workspaceId);
|
||||||
String path = body.get("path");
|
String path = body.get("path");
|
||||||
|
if (path != null && !path.isBlank()) {
|
||||||
|
try {
|
||||||
|
pathValidator.validateDirectory(path);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return R.fail(400, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
kbService.updateSourceDirectory(id, path);
|
kbService.updateSourceDirectory(id, path);
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ public class WikiDirectoryScanService {
|
|||||||
private final WikiKnowledgeBaseService kbService;
|
private final WikiKnowledgeBaseService kbService;
|
||||||
private final WikiRawMaterialService rawService;
|
private final WikiRawMaterialService rawService;
|
||||||
private final WikiProperties properties;
|
private final WikiProperties properties;
|
||||||
|
private final WikiSourcePathValidator pathValidator;
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(
|
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(
|
||||||
"txt", "md", "csv", "pdf", "docx", "doc",
|
"txt", "md", "csv", "pdf", "docx", "doc",
|
||||||
@ -61,7 +62,14 @@ public class WikiDirectoryScanService {
|
|||||||
* 扫描指定目录,为每个支持的文件创建原始材料
|
* 扫描指定目录,为每个支持的文件创建原始材料
|
||||||
*/
|
*/
|
||||||
public ScanResult scanDirectory(Long kbId, String directoryPath) {
|
public ScanResult scanDirectory(Long kbId, String directoryPath) {
|
||||||
Path dir = Paths.get(directoryPath).toAbsolutePath().normalize();
|
Path dir;
|
||||||
|
try {
|
||||||
|
// Canonicalize (resolving symlinks) and enforce allowed-roots so a
|
||||||
|
// scan cannot read outside the authorized area.
|
||||||
|
dir = pathValidator.validateDirectory(directoryPath);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return new ScanResult(0, 0, 0, List.of(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
if (!Files.exists(dir)) {
|
if (!Files.exists(dir)) {
|
||||||
return new ScanResult(0, 0, 0, List.of("Directory does not exist: " + dir));
|
return new ScanResult(0, 0, 0, List.of("Directory does not exist: " + dir));
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
package vip.mate.wiki.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.wiki.WikiProperties;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single point of truth for validating a KB source directory path, shared by
|
||||||
|
* the manual directory scan and the source-directory config endpoint (and the
|
||||||
|
* future filesystem watcher).
|
||||||
|
*
|
||||||
|
* <p>The raw path is canonicalized with {@code toRealPath()} (resolving
|
||||||
|
* symlinks) when it exists, so a symlink cannot escape the allowed area. When
|
||||||
|
* {@code mate.wiki.allowed-source-roots} is configured, the resolved path must
|
||||||
|
* lie within one of those roots; when it is empty the path is only
|
||||||
|
* canonicalized (opt-in enforcement — existing single-tenant / desktop setups
|
||||||
|
* keep working, server operators can lock it down).
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class WikiSourcePathValidator {
|
||||||
|
|
||||||
|
private final WikiProperties properties;
|
||||||
|
|
||||||
|
public WikiSourcePathValidator(WikiProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize and authorize a source directory path.
|
||||||
|
*
|
||||||
|
* @return the resolved absolute path
|
||||||
|
* @throws IllegalArgumentException when blank or outside the allowed roots
|
||||||
|
*/
|
||||||
|
public Path validateDirectory(String rawPath) {
|
||||||
|
if (rawPath == null || rawPath.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Source directory path is required");
|
||||||
|
}
|
||||||
|
Path resolved = canonicalize(Paths.get(rawPath));
|
||||||
|
List<String> roots = properties.getAllowedSourceRoots();
|
||||||
|
if (roots == null || roots.isEmpty()) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
for (String root : roots) {
|
||||||
|
if (root == null || root.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Path rootPath = canonicalize(Paths.get(root));
|
||||||
|
if (resolved.startsWith(rootPath)) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Path is outside the allowed source roots: " + resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a path passes validation, without throwing. */
|
||||||
|
public boolean isAllowed(String rawPath) {
|
||||||
|
try {
|
||||||
|
validateDirectory(rawPath);
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path canonicalize(Path path) {
|
||||||
|
Path abs = path.toAbsolutePath().normalize();
|
||||||
|
if (Files.exists(abs)) {
|
||||||
|
try {
|
||||||
|
return abs.toRealPath();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("[WikiPath] toRealPath failed for {}: {}", abs, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package vip.mate.wiki.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.wiki.WikiProperties;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link WikiSourcePathValidator}: empty roots allow anything
|
||||||
|
* (opt-in), configured roots enforce containment, and symlinks are resolved so
|
||||||
|
* they cannot escape an allowed root.
|
||||||
|
*/
|
||||||
|
class WikiSourcePathValidatorTest {
|
||||||
|
|
||||||
|
private WikiSourcePathValidator validator(List<String> roots) {
|
||||||
|
WikiProperties props = new WikiProperties();
|
||||||
|
props.setAllowedSourceRoots(roots);
|
||||||
|
return new WikiSourcePathValidator(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankPath_rejected() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> validator(List.of()).validateDirectory(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyRoots_allowAnyPath(@TempDir Path tmp) throws IOException {
|
||||||
|
Path resolved = validator(List.of()).validateDirectory(tmp.toString());
|
||||||
|
assertEquals(tmp.toRealPath(), resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insideAllowedRoot_isAccepted(@TempDir Path root) throws IOException {
|
||||||
|
Path sub = Files.createDirectory(root.resolve("kb-source"));
|
||||||
|
WikiSourcePathValidator v = validator(List.of(root.toString()));
|
||||||
|
assertTrue(v.isAllowed(sub.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void outsideAllowedRoot_isRejected(@TempDir Path root, @TempDir Path other) {
|
||||||
|
WikiSourcePathValidator v = validator(List.of(root.toString()));
|
||||||
|
assertFalse(v.isAllowed(other.toString()));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> v.validateDirectory(other.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void symlinkEscapingRoot_isRejected(@TempDir Path root, @TempDir Path secret) throws IOException {
|
||||||
|
// A symlink inside the allowed root that points outside must be rejected
|
||||||
|
// because validation resolves the real path first.
|
||||||
|
Path link = root.resolve("escape");
|
||||||
|
try {
|
||||||
|
Files.createSymbolicLink(link, secret);
|
||||||
|
} catch (UnsupportedOperationException | IOException e) {
|
||||||
|
return; // filesystem without symlink support — skip
|
||||||
|
}
|
||||||
|
WikiSourcePathValidator v = validator(List.of(root.toString()));
|
||||||
|
assertFalse(v.isAllowed(link.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user