mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-17 12:54:40 +08:00
fix(files): authorize ownership before loading generated content
This commit is contained in:
parent
7e45a26755
commit
ea1a28399c
@ -279,39 +279,35 @@ public class GeneratedFileCache {
|
|||||||
* JVM restarts; expired entries are removed as a side-effect.
|
* JVM restarts; expired entries are removed as a side-effect.
|
||||||
*/
|
*/
|
||||||
public Optional<Entry> get(String id) {
|
public Optional<Entry> get(String id) {
|
||||||
|
return Optional.ofNullable(getAuthorized(id, owner -> true).entry());
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AccessStatus { FOUND, FORBIDDEN, MISSING }
|
||||||
|
public record AccessResult(AccessStatus status, @Nullable Entry entry) { }
|
||||||
|
|
||||||
|
/** Authorize ownership metadata before reading or caching a cold content body. */
|
||||||
|
public AccessResult getAuthorized(String id, java.util.function.Predicate<Owner> authorized) {
|
||||||
|
Objects.requireNonNull(authorized, "authorized");
|
||||||
if (id == null || !ID_RE.matcher(id).matches()) {
|
if (id == null || !ID_RE.matcher(id).matches()) {
|
||||||
return Optional.empty();
|
return new AccessResult(AccessStatus.MISSING, null);
|
||||||
}
|
}
|
||||||
Entry entry = entries.get(id);
|
Entry cached = entries.get(id);
|
||||||
if (entry == null) {
|
if (cached != null) {
|
||||||
entry = loadFromDisk(id);
|
if (cached.expired()) {
|
||||||
if (entry != null) {
|
evict(id);
|
||||||
entries.put(id, entry);
|
return new AccessResult(AccessStatus.MISSING, null);
|
||||||
}
|
}
|
||||||
|
return authorized.test(new Owner(cached.workspaceId(), cached.ownerUserId(), cached.conversationId()))
|
||||||
|
? new AccessResult(AccessStatus.FOUND, cached) : new AccessResult(AccessStatus.FORBIDDEN, null);
|
||||||
}
|
}
|
||||||
if (entry == null) {
|
AccessResult loaded = loadAuthorizedFromDisk(id, authorized);
|
||||||
return Optional.empty();
|
if (loaded.entry() != null) entries.put(id, loaded.entry());
|
||||||
}
|
return loaded;
|
||||||
if (entry.expired()) {
|
|
||||||
evict(id);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return Optional.of(entry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Entry> getForWorkspace(String id, @Nullable Long workspaceId) {
|
public Optional<Entry> getForWorkspace(String id, @Nullable Long workspaceId) {
|
||||||
Optional<Entry> entry = get(id);
|
return Optional.ofNullable(getAuthorized(id, owner -> owner.workspaceId() == null
|
||||||
if (entry.isEmpty()) {
|
|| Objects.equals(owner.workspaceId(), workspaceId)).entry());
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
Long ownerWorkspaceId = entry.get().workspaceId();
|
|
||||||
if (ownerWorkspaceId == null) {
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
if (workspaceId == null || !ownerWorkspaceId.equals(workspaceId)) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -503,29 +499,53 @@ public class GeneratedFileCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Entry loadFromDisk(String id) {
|
private Entry loadFromDisk(String id) {
|
||||||
|
return loadAuthorizedFromDisk(id, owner -> true).entry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AccessResult loadAuthorizedFromDisk(String id, java.util.function.Predicate<Owner> authorized) {
|
||||||
|
AccessResult missing = new AccessResult(AccessStatus.MISSING, null);
|
||||||
Path bin = storageDir.resolve(id).normalize();
|
Path bin = storageDir.resolve(id).normalize();
|
||||||
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
|
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
|
||||||
// Containment guard — id is already validated, this is defence in depth.
|
// NOFOLLOW also applies at open; parent-directory ownership is separate.
|
||||||
if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin, LinkOption.NOFOLLOW_LINKS)
|
if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin, LinkOption.NOFOLLOW_LINKS)
|
||||||
|| !Files.isRegularFile(meta, LinkOption.NOFOLLOW_LINKS)) {
|
|| !Files.isRegularFile(meta, LinkOption.NOFOLLOW_LINKS)) return missing;
|
||||||
return null;
|
Metadata before;
|
||||||
|
try {
|
||||||
|
before = readDownloadMetadata(meta, id);
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
log.debug("Could not read generated file metadata id={}: {}", id, e.toString());
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
if (before.expireAt() <= System.currentTimeMillis()) {
|
||||||
|
evict(id);
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
// Keep permission-provider errors distinct from unavailable storage.
|
||||||
|
if (!authorized.test(new Owner(before.workspaceId(), before.ownerUserId(), before.conversationId()))) {
|
||||||
|
return new AccessResult(AccessStatus.FORBIDDEN, null);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Refuse leaf symlinks again at open, including replacement after the
|
// Authorization can take time. Refuse observed metadata replacement
|
||||||
// regular-file check. Parent-directory ownership is a separate boundary.
|
// before opening the body and again before making it available.
|
||||||
Metadata parsed;
|
if (!before.equals(readDownloadMetadata(meta, id))) return missing;
|
||||||
try (var input = Files.newInputStream(meta, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
|
||||||
parsed = parseMeta(new String(input.readAllBytes(), StandardCharsets.UTF_8), id);
|
|
||||||
}
|
|
||||||
byte[] bytes;
|
byte[] bytes;
|
||||||
try (var input = Files.newInputStream(bin, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
try (var input = Files.newInputStream(bin, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
bytes = input.readAllBytes();
|
bytes = input.readAllBytes();
|
||||||
}
|
}
|
||||||
return new Entry(bytes, parsed.filename(), parsed.mimeType(), parsed.expireAt(),
|
if (!before.equals(readDownloadMetadata(meta, id))
|
||||||
parsed.workspaceId(), parsed.ownerUserId(), parsed.conversationId());
|
|| before.expireAt() <= System.currentTimeMillis()) return missing;
|
||||||
} catch (Exception e) {
|
Entry entry = new Entry(bytes, before.filename(), before.mimeType(), before.expireAt(),
|
||||||
log.warn("Could not load generated file id={}: {}", id, e.toString());
|
before.workspaceId(), before.ownerUserId(), before.conversationId());
|
||||||
return null;
|
return new AccessResult(AccessStatus.FOUND, entry);
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
log.debug("Could not load generated file id={}: {}", id, e.toString());
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Metadata readDownloadMetadata(Path meta, String id) throws IOException {
|
||||||
|
try (var input = Files.newInputStream(meta, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
return parseMeta(new String(input.readAllBytes(), StandardCharsets.UTF_8), id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,39 +45,41 @@ public class GeneratedFileController {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
|
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
|
||||||
}
|
}
|
||||||
return cache.get(id)
|
var access = cache.getAuthorized(id, owner -> canDownload(owner.workspaceId(), workspaceId, user));
|
||||||
.filter(entry -> canDownload(entry, workspaceId, user))
|
if (access.status() == GeneratedFileCache.AccessStatus.FORBIDDEN) {
|
||||||
.<ResponseEntity<?>>map(entry -> {
|
return ResponseEntity.status(403).body(Map.of("error", "Workspace permission denied"));
|
||||||
String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8)
|
}
|
||||||
.replace("+", "%20");
|
if (access.status() == GeneratedFileCache.AccessStatus.MISSING) {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
return ResponseEntity.status(404).body(Map.of("error", "File not found or expired"));
|
||||||
String mime = entry.mimeType() == null || entry.mimeType().isBlank()
|
}
|
||||||
? "application/octet-stream"
|
var entry = access.entry();
|
||||||
: entry.mimeType();
|
String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8)
|
||||||
headers.setContentType(MediaType.parseMediaType(mime));
|
.replace("+", "%20");
|
||||||
// RFC 5987 filename* lets non-ASCII names round-trip in browsers.
|
HttpHeaders headers = new HttpHeaders();
|
||||||
// Images and HTML previews render inline; everything else downloads.
|
String mime = entry.mimeType() == null || entry.mimeType().isBlank()
|
||||||
boolean isImage = mime != null && mime.startsWith("image/");
|
? "application/octet-stream"
|
||||||
boolean isHtml = mime != null && mime.toLowerCase().startsWith("text/html");
|
: entry.mimeType();
|
||||||
String disposition = (isImage || isHtml) ? "inline" : "attachment";
|
headers.setContentType(MediaType.parseMediaType(mime));
|
||||||
// Every generated document is untrusted, including SVG served
|
// RFC 5987 filename* lets non-ASCII names round-trip in browsers.
|
||||||
// inline as an image. Isolate document origins and active content;
|
// Images and HTML previews render inline; everything else downloads.
|
||||||
// retain static styles/media and explicit downloads for previews.
|
boolean isImage = mime != null && mime.startsWith("image/");
|
||||||
headers.add("Content-Security-Policy",
|
boolean isHtml = mime != null && mime.toLowerCase().startsWith("text/html");
|
||||||
"sandbox allow-downloads; default-src 'none'; img-src * data:; "
|
String disposition = (isImage || isHtml) ? "inline" : "attachment";
|
||||||
+ "style-src 'unsafe-inline'; font-src * data:; media-src *; "
|
// Every generated document is untrusted, including SVG served
|
||||||
+ "base-uri 'none'; form-action 'none'");
|
// inline as an image. Isolate document origins and active content;
|
||||||
headers.add("X-Content-Type-Options", "nosniff");
|
// retain static styles/media and explicit downloads for previews.
|
||||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
headers.add("Content-Security-Policy",
|
||||||
disposition + "; filename=\"" + sanitizeAscii(entry.filename())
|
"sandbox allow-downloads; default-src 'none'; img-src * data:; "
|
||||||
+ "\"; filename*=UTF-8''" + encodedName);
|
+ "style-src 'unsafe-inline'; font-src * data:; media-src *; "
|
||||||
byte[] content = entry.bytes();
|
+ "base-uri 'none'; form-action 'none'");
|
||||||
headers.setContentLength(content.length);
|
headers.add("X-Content-Type-Options", "nosniff");
|
||||||
return ResponseEntity.ok().headers(headers).body(content);
|
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
})
|
disposition + "; filename=\"" + sanitizeAscii(entry.filename())
|
||||||
.orElseGet(() -> cache.get(id).isPresent()
|
+ "\"; filename*=UTF-8''" + encodedName);
|
||||||
? ResponseEntity.status(403).body(Map.of("error", "Workspace permission denied"))
|
byte[] content = entry.bytes();
|
||||||
: ResponseEntity.status(404).body(Map.of("error", "File not found or expired")));
|
headers.setContentLength(content.length);
|
||||||
|
return ResponseEntity.ok().headers(headers).body(content);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserEntity resolveUser(Authentication authentication) {
|
private UserEntity resolveUser(Authentication authentication) {
|
||||||
@ -87,8 +89,7 @@ public class GeneratedFileController {
|
|||||||
return authService.findByUsername(authentication.getName());
|
return authService.findByUsername(authentication.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean canDownload(GeneratedFileCache.Entry entry, Long currentWorkspaceId, UserEntity user) {
|
private boolean canDownload(Long ownerWorkspaceId, Long currentWorkspaceId, UserEntity user) {
|
||||||
Long ownerWorkspaceId = entry.workspaceId();
|
|
||||||
if (ownerWorkspaceId == null) {
|
if (ownerWorkspaceId == null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,16 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
*/
|
*/
|
||||||
class GeneratedFileCachePersistenceTest {
|
class GeneratedFileCachePersistenceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forbiddenWorkspaceLookupDoesNotPopulateColdCache(@TempDir Path dir) {
|
||||||
|
String id = new GeneratedFileCache(dir).put("body".getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain",
|
||||||
|
new GeneratedFileCache.Owner(20L, 30L, "conv"));
|
||||||
|
GeneratedFileCache cold = new GeneratedFileCache(dir);
|
||||||
|
assertTrue(cold.getForWorkspace(id, 40L).isEmpty());
|
||||||
|
assertTrue(((java.util.Map<?, ?>) org.springframework.test.util.ReflectionTestUtils.getField(cold, "entries")).isEmpty());
|
||||||
|
assertTrue(cold.getForWorkspace(id, 20L).isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void coldDownloadRejectsSymbolicLinkToExternalContent(@TempDir Path dir) throws IOException {
|
void coldDownloadRejectsSymbolicLinkToExternalContent(@TempDir Path dir) throws IOException {
|
||||||
Path storage = Files.createDirectory(dir.resolve("cache"));
|
Path storage = Files.createDirectory(dir.resolve("cache"));
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import org.junit.jupiter.api.DisplayName;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.CsvSource;
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import java.util.Map;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||||
@ -13,6 +16,7 @@ import vip.mate.workspace.core.service.WorkspaceService;
|
|||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
@ -82,6 +86,62 @@ class GeneratedFileControllerTest {
|
|||||||
assertFalse(policy.contains("allow-same-origin"));
|
assertFalse(policy.contains("allow-same-origin"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = {false, true})
|
||||||
|
void coldDownloadAuthorizesBeforePopulatingContentCache(boolean allowed, @TempDir Path dir) {
|
||||||
|
String id = new GeneratedFileCache(dir).put("body".getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain",
|
||||||
|
new GeneratedFileCache.Owner(20L, 30L, "conv"));
|
||||||
|
GeneratedFileCache cold = new GeneratedFileCache(dir);
|
||||||
|
Map<?, ?> entries = (Map<?, ?>) ReflectionTestUtils.getField(cold, "entries");
|
||||||
|
AuthService authService = mock(AuthService.class);
|
||||||
|
WorkspaceService workspaceService = mock(WorkspaceService.class);
|
||||||
|
when(authService.findByUsername("alice")).thenReturn(user(30L, "user"));
|
||||||
|
when(workspaceService.hasPermissionCached(20L, 30L, "viewer")).thenAnswer(call -> {
|
||||||
|
assertTrue(entries.isEmpty(), "content must not be loaded into cache before authorization");
|
||||||
|
return allowed;
|
||||||
|
});
|
||||||
|
var response = new GeneratedFileController(cold, authService, workspaceService)
|
||||||
|
.download(id, 20L, new TestingAuthenticationToken("alice", "pw"));
|
||||||
|
assertEquals(allowed ? 200 : 403, response.getStatusCode().value());
|
||||||
|
assertEquals(allowed, entries.containsKey(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coldDownloadRejectsOwnershipReplacementDuringPermissionCheck(@TempDir Path dir) {
|
||||||
|
String id = new GeneratedFileCache(dir).put("body".getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain",
|
||||||
|
new GeneratedFileCache.Owner(20L, 30L, "conv"));
|
||||||
|
GeneratedFileCache cold = new GeneratedFileCache(dir);
|
||||||
|
AuthService authService = mock(AuthService.class);
|
||||||
|
WorkspaceService workspaceService = mock(WorkspaceService.class);
|
||||||
|
when(authService.findByUsername("alice")).thenReturn(user(30L, "user"));
|
||||||
|
when(workspaceService.hasPermissionCached(20L, 30L, "viewer")).thenAnswer(call -> {
|
||||||
|
Path meta = dir.resolve(id + ".meta");
|
||||||
|
String[] fields = Files.readString(meta).split("\t", -1);
|
||||||
|
fields[3] = "40";
|
||||||
|
Files.writeString(meta, String.join("\t", fields));
|
||||||
|
Files.writeString(dir.resolve(id), "other workspace body");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
var response = new GeneratedFileController(cold, authService, workspaceService)
|
||||||
|
.download(id, 20L, new TestingAuthenticationToken("alice", "pw"));
|
||||||
|
assertEquals(404, response.getStatusCode().value());
|
||||||
|
assertTrue(((Map<?, ?>) ReflectionTestUtils.getField(cold, "entries")).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyUnscopedColdFileRemainsAccessibleToAuthenticatedCaller(@TempDir Path dir) {
|
||||||
|
String id = new GeneratedFileCache(dir).put("legacy".getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain");
|
||||||
|
GeneratedFileCache cold = new GeneratedFileCache(dir);
|
||||||
|
AuthService authService = mock(AuthService.class);
|
||||||
|
WorkspaceService workspaceService = mock(WorkspaceService.class);
|
||||||
|
when(authService.findByUsername("alice")).thenReturn(user(30L, "user"));
|
||||||
|
var controller = new GeneratedFileController(cold, authService, workspaceService);
|
||||||
|
assertEquals(401, controller.download(id, null, null).getStatusCode().value());
|
||||||
|
assertTrue(((Map<?, ?>) ReflectionTestUtils.getField(cold, "entries")).isEmpty());
|
||||||
|
assertEquals(200, controller.download(id, null, new TestingAuthenticationToken("alice", "pw")).getStatusCode().value());
|
||||||
|
org.mockito.Mockito.verifyNoInteractions(workspaceService);
|
||||||
|
}
|
||||||
|
|
||||||
private static UserEntity user(Long id, String role) {
|
private static UserEntity user(Long id, String role) {
|
||||||
UserEntity user = new UserEntity();
|
UserEntity user = new UserEntity();
|
||||||
user.setId(id);
|
user.setId(id);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user