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 fe475225..d381591d 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 @@ -133,6 +133,21 @@ public class WikiDirectoryScanService { for (Path file : files) { try { + // Per-file symlink guard: a symlinked file inside an allowed + // directory could point outside it (e.g. secret.md -> + // /etc/passwd). Resolve the real path and require it to stay + // within the validated scan root; skip escapes. + Path realFile; + try { + realFile = file.toRealPath(); + } catch (IOException e) { + realFile = file.toAbsolutePath().normalize(); + } + if (!realFile.startsWith(dir)) { + errors.add("Skipped symlink escaping the scan root: " + file.getFileName()); + skipped++; + continue; + } String absolutePath = file.toAbsolutePath().normalize().toString(); String fileName = file.getFileName().toString(); String ext = getExtension(fileName); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index 0f7595f5..087ff385 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -106,7 +106,10 @@ public class WikiRawMaterialService { // addText dedups internally by hash, so this reuses sameContent when // unchanged and inserts + triggers processing when the content differs. WikiRawMaterialEntity raw = addText(kbId, fileName, content); - if (raw != null) { + // Only stamp the path on a genuinely new raw. When the content matched + // an existing raw (possibly a different file with identical content), + // overwriting its sourcePath would corrupt that raw's provenance. + if (raw != null && sameContent == null) { updateSourcePath(raw.getId(), absolutePath); } return sameContent == null; diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java index 58dca587..86173ee0 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiSourceWatcherServiceE2ETest.java @@ -33,6 +33,8 @@ class WikiSourceWatcherServiceE2ETest { private WikiSourceWatcherService watcherService; @Autowired private WikiKnowledgeBaseService kbService; + @Autowired + private WikiDirectoryScanService scanService; private static final java.util.concurrent.atomic.AtomicLong SEQ = new java.util.concurrent.atomic.AtomicLong(System.nanoTime()); @@ -64,6 +66,27 @@ class WikiSourceWatcherServiceE2ETest { assertEquals(1, afterEdit, "a modified file must be re-ingested"); } + @Test + void symlinkFileEscapingScanRoot_isNotIngested(@TempDir Path sourceDir, @TempDir Path outside) + throws java.io.IOException { + Files.writeString(sourceDir.resolve("real.md"), "# Real\n\nlocal content"); + Path secret = Files.writeString(outside.resolve("secret.md"), "TOP SECRET OUTSIDE"); + Path link = sourceDir.resolve("leak.md"); + try { + Files.createSymbolicLink(link, secret); + } catch (UnsupportedOperationException | java.io.IOException e) { + return; // filesystem without symlink support — skip + } + + long kb = SEQ.incrementAndGet(); + WikiDirectoryScanService.ScanResult result = scanService.scanDirectory(kb, sourceDir.toString()); + + // Only the real file is ingested; the symlink escaping the root is skipped. + assertEquals(1, result.added(), "symlinked file pointing outside the root must not be ingested"); + assertTrue(result.skipped() >= 1 || !result.errors().isEmpty(), + "the escaping symlink should be reported as skipped"); + } + @Test void kbsWithoutSourceDirectory_areSkipped() { // A KB with no source directory must not cause errors in the cycle.