feat(wiki): unified source-path validation with symlink resolution and allowed roots

This commit is contained in:
matevip 2026-05-31 07:54:48 +08:00
parent eb63ce4865
commit 16a7aadbcd
5 changed files with 181 additions and 1 deletions

View File

@ -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<String> allowedSourceRoots = new java.util.ArrayList<>();
/**
* Wiki LLM 重试最大尝试次数含首次
* <p>

View File

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

View File

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

View File

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

View File

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