From 27afafa01dc3ec7c0f050b083ddf796c87725c01 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 18:33:08 +0200 Subject: [PATCH] =?UTF-8?q?fix(security):=20restrict=20DRAFT=20list=20to?= =?UTF-8?q?=20author=20=E2=80=94=20prevent=20cross-user=20draft=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeschichteService.list() now applies hasAuthor(currentUser()) whenever status == DRAFT, so BLOG_WRITE users cannot read other users' unpublished stories. Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/GeschichteService.java | 2 ++ .../geschichte/GeschichteSpecifications.java | 5 +++++ .../GeschichteServiceIntegrationTest.java | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index d6d38048..1b0cfc78 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -77,8 +77,10 @@ public class GeschichteService { GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); + UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null; Specification spec = Specification.allOf( GeschichteSpecifications.hasStatus(effective), + GeschichteSpecifications.hasAuthor(authorId), GeschichteSpecifications.hasAllPersons(personIds), GeschichteSpecifications.hasDocument(documentId), GeschichteSpecifications.orderByDisplayDateDesc() diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java index 695c3315..6834a783 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java @@ -42,6 +42,11 @@ public final class GeschichteSpecifications { }; } + public static Specification hasAuthor(UUID authorId) { + return (root, query, cb) -> + authorId == null ? null : cb.equal(root.get("author").get("id"), authorId); + } + public static Specification hasDocument(UUID documentId) { return (root, query, cb) -> { if (documentId == null) return null; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index e0cb9f27..55eaaa4c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -159,6 +159,26 @@ class GeschichteServiceIntegrationTest { .isEmpty(); } + @Test + void list_DRAFT_does_not_return_other_users_drafts() { + // writer creates a draft; writer2 (also BLOG_WRITE) should not see it + AppUser writer2 = appUserRepository.save(AppUser.builder() + .email("writer2-int@test") + .password("hash") + .build()); + + authenticateAs(writer, Permission.BLOG_WRITE); + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("Writer 1 draft"); + dto.setBody("

private

"); + geschichteService.create(dto); + + authenticateAs(writer2, Permission.BLOG_WRITE); + List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50); + + assertThat(result).isEmpty(); + } + private UUID publishedStoryWithPersons(String title, List personIds) { GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); dto.setTitle(title);