From 16a7aadbcdd41ecbf2e58ff996b4293151b4e318 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 31 May 2026 07:54:48 +0800 Subject: [PATCH] feat(wiki): unified source-path validation with symlink resolution and allowed roots --- .../java/vip/mate/wiki/WikiProperties.java | 9 ++ .../mate/wiki/controller/WikiController.java | 9 ++ .../service/WikiDirectoryScanService.java | 10 ++- .../wiki/service/WikiSourcePathValidator.java | 86 +++++++++++++++++++ .../service/WikiSourcePathValidatorTest.java | 68 +++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourcePathValidator.java create mode 100644 mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourcePathValidatorTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java index 4ad3115f..941bf79c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java @@ -71,6 +71,15 @@ public class WikiProperties { /** 扫描时跳过大于此大小的文件(字节),默认 50MB */ 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 allowedSourceRoots = new java.util.ArrayList<>(); + /** * Wiki LLM 重试最大尝试次数(含首次)。 *

diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 7e0cf1ed..060bb2f9 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -21,6 +21,7 @@ import vip.mate.wiki.model.WikiPageTypeProfileEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; import vip.mate.wiki.profile.WikiPageTypeProfileService; import vip.mate.wiki.service.WikiDirectoryScanService; +import vip.mate.wiki.service.WikiSourcePathValidator; import vip.mate.wiki.service.WikiKnowledgeBaseService; import vip.mate.wiki.service.WikiLintJobService; import vip.mate.wiki.service.WikiPageService; @@ -59,6 +60,7 @@ public class WikiController { private final WikiProgressBus progressBus; private final AuditEventService auditEventService; private final WikiPageTypeProfileService pageTypeProfileService; + private final WikiSourcePathValidator pathValidator; private final ObjectMapper objectMapper; // ==================== Knowledge Base ==================== @@ -274,6 +276,13 @@ public class WikiController { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(id, workspaceId); 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); return R.ok(); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java index 46640b44..c5865800 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java @@ -29,6 +29,7 @@ public class WikiDirectoryScanService { private final WikiKnowledgeBaseService kbService; private final WikiRawMaterialService rawService; private final WikiProperties properties; + private final WikiSourcePathValidator pathValidator; private static final Set SUPPORTED_EXTENSIONS = Set.of( "txt", "md", "csv", "pdf", "docx", "doc", @@ -61,7 +62,14 @@ public class WikiDirectoryScanService { * 扫描指定目录,为每个支持的文件创建原始材料 */ 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)) { return new ScanResult(0, 0, 0, List.of("Directory does not exist: " + dir)); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourcePathValidator.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourcePathValidator.java new file mode 100644 index 00000000..2a6aefea --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiSourcePathValidator.java @@ -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). + * + *

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 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; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourcePathValidatorTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourcePathValidatorTest.java new file mode 100644 index 00000000..a9dde248 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourcePathValidatorTest.java @@ -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 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())); + } +}