From 47066c609180cd4d577c0902cc86a5060388afd1 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Sun, 13 Sep 2026 23:43:00 +0800 Subject: [PATCH] fix(files): reject symlinks when reloading generated downloads --- .../tool/document/GeneratedFileCache.java | 15 ++++++++-- .../GeneratedFileCachePersistenceTest.java | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index 320c082f..3be55b83 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -506,12 +506,21 @@ public class GeneratedFileCache { Path bin = storageDir.resolve(id).normalize(); Path meta = storageDir.resolve(id + META_SUFFIX).normalize(); // Containment guard — id is already validated, this is defence in depth. - if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin) || !Files.isRegularFile(meta)) { + if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin, LinkOption.NOFOLLOW_LINKS) + || !Files.isRegularFile(meta, LinkOption.NOFOLLOW_LINKS)) { return null; } try { - Metadata parsed = parseMeta(Files.readString(meta), id); - byte[] bytes = Files.readAllBytes(bin); + // Refuse leaf symlinks again at open, including replacement after the + // regular-file check. Parent-directory ownership is a separate boundary. + Metadata parsed; + try (var input = Files.newInputStream(meta, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + parsed = parseMeta(new String(input.readAllBytes(), StandardCharsets.UTF_8), id); + } + byte[] bytes; + try (var input = Files.newInputStream(bin, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + bytes = input.readAllBytes(); + } return new Entry(bytes, parsed.filename(), parsed.mimeType(), parsed.expireAt(), parsed.workspaceId(), parsed.ownerUserId(), parsed.conversationId()); } catch (Exception e) { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java index cd7167e7..8e51832b 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java @@ -6,6 +6,8 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.nio.file.Files; +import java.io.IOException; import static org.junit.jupiter.api.Assertions.*; @@ -18,6 +20,32 @@ import static org.junit.jupiter.api.Assertions.*; */ class GeneratedFileCachePersistenceTest { + @Test + void coldDownloadRejectsSymbolicLinkToExternalContent(@TempDir Path dir) throws IOException { + Path storage = Files.createDirectory(dir.resolve("cache")); + var cache = new GeneratedFileCache(storage); + String id = cache.put("original".getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain"); + Path external = Files.writeString(dir.resolve("private.txt"), "outside content"); + Files.delete(storage.resolve(id)); + Files.createSymbolicLink(storage.resolve(id), external); + + assertTrue(new GeneratedFileCache(storage).get(id).isEmpty()); + assertEquals("outside content", Files.readString(external)); + } + + @Test + void coldDownloadRejectsSymbolicLinkToExternalMetadata(@TempDir Path dir) throws IOException { + Path storage = Files.createDirectory(dir.resolve("cache")); + var cache = new GeneratedFileCache(storage); + String id = cache.put("original".getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain"); + Path metadata = storage.resolve(id + ".meta"); + Path external = Files.move(metadata, dir.resolve("outside.meta")); + Files.createSymbolicLink(metadata, external); + + assertTrue(new GeneratedFileCache(storage).get(id).isEmpty()); + assertTrue(Files.isRegularFile(external)); + } + @Test void callerCannotChangeRegisteredVersionThroughInputBytes(@TempDir Path dir) { var cache = new GeneratedFileCache(dir);