fix(recipe): add server-side image size limit and use .matches() for type check
- @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 <noreply@anthropic.com>
This commit is contained in:
@@ -191,11 +191,11 @@ public class RecipeService {
|
|||||||
// ── Image validation ──
|
// ── Image validation ──
|
||||||
|
|
||||||
private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN =
|
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) {
|
private void validateHeroImageUrl(String heroImageUrl) {
|
||||||
if (heroImageUrl == null || heroImageUrl.isBlank()) return;
|
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.");
|
throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public record RecipeCreateRequest(
|
|||||||
Integer serves,
|
Integer serves,
|
||||||
Integer cookTimeMin,
|
Integer cookTimeMin,
|
||||||
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
|
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
|
||||||
String heroImageUrl,
|
@Size(max = 7_000_000) String heroImageUrl,
|
||||||
@NotEmpty @Valid List<IngredientEntry> ingredients,
|
@NotEmpty @Valid List<IngredientEntry> ingredients,
|
||||||
@Valid List<StepEntry> steps,
|
@Valid List<StepEntry> steps,
|
||||||
@NotEmpty List<UUID> tagIds
|
@NotEmpty List<UUID> tagIds
|
||||||
|
|||||||
@@ -161,6 +161,33 @@ class RecipeControllerTest {
|
|||||||
verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID);
|
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
|
@Test
|
||||||
void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception {
|
void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception {
|
||||||
var body = """
|
var body = """
|
||||||
|
|||||||
Reference in New Issue
Block a user