fix(files): reject symlinks when reloading generated downloads

This commit is contained in:
mateaix 2026-09-13 23:43:00 +08:00
parent 52a2843bdf
commit 47066c6091
2 changed files with 40 additions and 3 deletions

View File

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

View File

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