From 7a414a37f007e1e92ae799dd3c0ca4d9ab93e8fb Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:18:38 +0100 Subject: [PATCH] fix(profile-pictures): declare schema ownership and serve avatars as png with nosniff --- .../controller/api/ProfilePictureController.java | 6 +++++- .../api/ProfilePictureControllerTest.java | 16 ++++++++++++++++ .../saas/config/SaasSchemaOwnership.java | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ProfilePictureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ProfilePictureController.java index 87f359d470..948ecdc10f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ProfilePictureController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ProfilePictureController.java @@ -132,10 +132,14 @@ public class ProfilePictureController { } StoredImage image = stored.get(); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(image.contentType())) + // store() re-encodes every upload, so the stored type is always PNG; parsing it + // back would only add a 500 path for a row written by anything else. + .contentType(MediaType.IMAGE_PNG) // Same URI for every user, so a shared browser must not reuse it. The client keeps // the blob for the session anyway, so there is nothing to gain from caching here. .cacheControl(CacheControl.noStore()) + .header("X-Content-Type-Options", "nosniff") + .header("Content-Disposition", "inline; filename=\"avatar.png\"") .body(image.data()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/ProfilePictureControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/ProfilePictureControllerTest.java index 93dbf3b3f3..8633d40bc1 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/ProfilePictureControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/ProfilePictureControllerTest.java @@ -12,6 +12,8 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -30,6 +32,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -142,12 +145,25 @@ class ProfilePictureControllerTest { mockMvc.perform(get("/api/v1/user/profile-picture").principal(VIEWER)) .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.IMAGE_PNG)) + .andExpect(header().string("X-Content-Type-Options", "nosniff")) .andExpect( result -> assertThat(result.getResponse().getHeader("Cache-Control")) .contains("no-store")); } + @Test + void aStoredTypeThatIsNotAMediaTypeStillServesAsPngRatherThanA500() throws Exception { + // Nothing writes this today, but parsing the column back would turn a bad row into a 500. + when(profilePictureService.findImage(1L)) + .thenReturn(Optional.of(new StoredImage(new byte[] {1, 2, 3}, "not a media type"))); + + mockMvc.perform(get("/api/v1/user/profile-picture").principal(VIEWER)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.IMAGE_PNG)); + } + @Test void anUnknownPrincipalGetsA401RatherThanAnEmptyRoster() throws Exception { when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty()); diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java index 7853c56856..4aec0e77b1 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java @@ -106,6 +106,7 @@ public final class SaasSchemaOwnership { "stored_file_blobs", "stored_files", "user_license_settings", + "user_profile_pictures", "user_server_certificates", "workflow_participants", "workflow_sessions");