mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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 */
|
||||
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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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