fix(wiki): block per-file symlink escape and stop sourcePath clobbering

This commit is contained in:
matevip 2026-05-31 07:57:57 +08:00
parent f3a335f4f6
commit 4ffe7026d2
3 changed files with 42 additions and 1 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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.