diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java index 7cab1213..f746c630 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java @@ -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); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java index 89b326fc..21b7b851 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java @@ -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 = "" + .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);