fix(profile-pictures): declare schema ownership and serve avatars as png with nosniff

This commit is contained in:
Anthony Stirling
2026-08-30 10:18:38 +01:00
parent 25e8fbba2f
commit 7a414a37f0
3 changed files with 22 additions and 1 deletions
@@ -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());
}
@@ -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());
@@ -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");