From ed769b18a47d1aedd2dba46a384e866f6372a1c1 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:27:35 +0200 Subject: [PATCH] fix(recipe): add server-side image size limit and use .matches() for type check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Size(max=7_000_000) on heroImageUrl enforces ~5 MB cap at bean validation - ALLOWED_IMAGE_PATTERN uses .matches() for unambiguous full-string check - Tests: oversized image → 400, empty ingredients list → 400 Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/recipe/RecipeService.java | 4 +-- .../recipe/dto/RecipeCreateRequest.java | 2 +- .../recipe/RecipeControllerTest.java | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java index 8228b50..6e3084b 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java @@ -191,11 +191,11 @@ public class RecipeService { // ── Image validation ── private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN = - java.util.regex.Pattern.compile("^data:image/(jpeg|jpg|png|gif|webp);base64,"); + java.util.regex.Pattern.compile("data:image/(jpeg|jpg|png|gif|webp);base64,.*"); private void validateHeroImageUrl(String heroImageUrl) { if (heroImageUrl == null || heroImageUrl.isBlank()) return; - if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).find()) { + if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).matches()) { throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP."); } } diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java index 5852b48..af5bb7e 100644 --- a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java @@ -12,7 +12,7 @@ public record RecipeCreateRequest( Integer serves, Integer cookTimeMin, @NotBlank @Pattern(regexp = "easy|medium|hard") String effort, - String heroImageUrl, + @Size(max = 7_000_000) String heroImageUrl, @NotEmpty @Valid List ingredients, @Valid List steps, @NotEmpty List tagIds diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java index bd07e18..0a1b94b 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java @@ -161,6 +161,33 @@ class RecipeControllerTest { verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID); } + @Test + void createRecipeWithOversizedHeroImageShouldReturn400() throws Exception { + String heroImageUrl = "data:image/jpeg;base64," + "A".repeat(7_000_000); + String body = "{\"name\":\"Test\",\"effort\":\"easy\",\"tagIds\":[\"" + UUID.randomUUID() + "\"]," + + "\"ingredients\":[{\"quantity\":1,\"unit\":\"g\",\"newIngredientName\":\"x\",\"sortOrder\":0}]," + + "\"heroImageUrl\":\"" + heroImageUrl + "\"}"; + + mockMvc.perform(post("/v1/recipes") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + void createRecipeWithEmptyIngredientsListShouldReturn400() throws Exception { + var body = """ + {"name":"Test","effort":"easy","tagIds":["%s"],"ingredients":[]} + """.formatted(UUID.randomUUID()); + + mockMvc.perform(post("/v1/recipes") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + @Test void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception { var body = """