fix(files): isolate generated preview content with sandbox policy

This commit is contained in:
mateaix 2026-09-14 00:05:01 +08:00
parent 0498530f3a
commit 7e45a26755
2 changed files with 38 additions and 11 deletions

View File

@ -60,16 +60,14 @@ public class GeneratedFileController {
boolean isImage = mime != null && mime.startsWith("image/");
boolean isHtml = mime != null && mime.toLowerCase().startsWith("text/html");
String disposition = (isImage || isHtml) ? "inline" : "attachment";
if (isHtml) {
// The bytes are model/tool-generated HTML served from the app's
// own origin. A strict CSP neutralises XSS: scripts, plugins and
// framing are forbidden, only inline styles + images/fonts load.
// This makes an on-demand "open the article" preview safe.
headers.add("Content-Security-Policy",
"default-src 'none'; img-src * data:; style-src 'unsafe-inline'; "
+ "font-src * data:; media-src *; base-uri 'none'; form-action 'none'");
headers.add("X-Content-Type-Options", "nosniff");
}
// Every generated document is untrusted, including SVG served
// inline as an image. Isolate document origins and active content;
// retain static styles/media and explicit downloads for previews.
headers.add("Content-Security-Policy",
"sandbox allow-downloads; default-src 'none'; img-src * data:; "
+ "style-src 'unsafe-inline'; font-src * data:; media-src *; "
+ "base-uri 'none'; form-action 'none'");
headers.add("X-Content-Type-Options", "nosniff");
headers.add(HttpHeaders.CONTENT_DISPOSITION,
disposition + "; filename=\"" + sanitizeAscii(entry.filename())
+ "\"; filename*=UTF-8''" + encodedName);

View File

@ -2,6 +2,8 @@ package vip.mate.tool.document;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.TestingAuthenticationToken;
@ -12,7 +14,7 @@ import vip.mate.workspace.core.service.WorkspaceService;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -53,6 +55,33 @@ class GeneratedFileControllerTest {
assertEquals(200, response.getStatusCode().value());
}
@ParameterizedTest
@CsvSource({"image/svg+xml,inline", "text/html,inline", "image/png,inline", "text/plain,attachment"})
void generatedContentHasSandboxPolicyWithoutChangingBytesOrDisposition(String mime, String disposition,
@TempDir Path dir) {
GeneratedFileCache cache = new GeneratedFileCache(dir);
byte[] payload = "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>window.generatedScriptRan=true</script></svg>"
.getBytes(StandardCharsets.UTF_8);
String id = cache.put(payload, "report", mime, new GeneratedFileCache.Owner(20L, 30L, "conv"));
AuthService authService = mock(AuthService.class);
WorkspaceService workspaceService = mock(WorkspaceService.class);
when(authService.findByUsername("alice")).thenReturn(user(30L, "user"));
when(workspaceService.hasPermissionCached(20L, 30L, "viewer")).thenReturn(true);
var response = new GeneratedFileController(cache, authService, workspaceService)
.download(id, 20L, new TestingAuthenticationToken("alice", "pw"));
assertEquals(200, response.getStatusCode().value());
assertArrayEquals(payload, (byte[]) response.getBody());
assertTrue(response.getHeaders().getFirst("Content-Disposition").startsWith(disposition + ";"));
assertEquals("nosniff", response.getHeaders().getFirst("X-Content-Type-Options"));
String policy = response.getHeaders().getFirst("Content-Security-Policy");
assertNotNull(policy);
assertTrue(policy.contains("sandbox allow-downloads;"));
assertTrue(policy.contains("default-src 'none';"));
assertTrue(policy.contains("form-action 'none'"));
assertFalse(policy.contains("allow-scripts"));
assertFalse(policy.contains("allow-same-origin"));
}
private static UserEntity user(Long id, String role) {
UserEntity user = new UserEntity();
user.setId(id);