From 3bd7fd138647615f65dc8a635ccc62cc8e514fc3 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 23 Oct 2026 13:09:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=B2=A8=EB=B6=80=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=80=EC=9B=8C=EB=8F=84=20=EC=A0=80=EC=9E=A5=EB=90=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=B4=20=EB=82=A8=EC=95=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileStorageService.delete(key) 는 처음부터 있었지만 부르는 곳이 없었다. 두 자리에서 파일이 남는다. 1) 첨부 개별 삭제 — attachmentRepository.delete(attachment) 만 했다 2) 건강기록 삭제 — 엔티티의 cascade = ALL, orphanRemoval = true 가 첨부 "행" 만 지운다 남은 파일은 가리키는 행이 없어 조회할 길이 없으니 당장 새어 나가지는 않는다. 문제는 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 서버에 남는다는 점이다. 열람권· 파기 의무를 생각하면 그게 문제다. 디스크도 계속 찬다. 기록 삭제 쪽은 지우기 전에 키를 모아 둔다. 행이 사라지면 그 파일을 가리키는 것이 없어 지울 기회도 없어진다. 순서는 둘 다 "행 먼저, 파일 나중" 이다. 파일 삭제가 실패해도 남은 파일을 가리키는 행이 없어 접근 경로가 없다. 반대 순서라면 파일은 사라졌는데 행이 남아 다운로드가 깨진다. 소유권 확인이 파일 삭제보다 먼저인 것도 테스트로 고정했다 — 남의 첨부 id 로 파일을 지울 수 있으면 삭제가 공격 수단이 된다. - 전체 테스트 654개, 실패 0 - ArchUnit baseline 241 유지 (HealthService 생성자 서명이 바뀌어 기존 위반 1건이 새로 보였을 뿐이라 refreeze 로 확인) Refs #49 --- .../a6b21c55-f3df-4514-8faa-c32628f81448 | 4 +- .../HealthRecordAttachmentService.java | 10 ++++ .../domain/health/service/HealthService.java | 22 +++++++- .../HealthRecordAttachmentServiceTest.java | 50 +++++++++++++++++++ .../health/service/HealthServiceTest.java | 39 +++++++++++++++ 5 files changed, 121 insertions(+), 4 deletions(-) diff --git a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 index 37d68a0..fddb93c 100644 --- a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 +++ b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 @@ -155,7 +155,7 @@ health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.ChildMapper, com.carecode.domain.user.app.CurrentUserFacade, com.carecode.domain.health.service.VaccinationScheduleService, com.carecode.core.analytics.EventLogger)> has parameter of type in (ChildService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.user.app.CurrentUserFacade)> has parameter of type in (GrowthChartService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository)> has parameter of type in (HealthActorResolver.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.health.service.HealthActorResolver, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.core.storage.FileStorageService, com.carecode.domain.health.service.HealthActorResolver, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.user.app.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.repository.ChildRepository)> has parameter of type in (VaccinationScheduleService.java:0) health → user 리포지토리: Field has type in (HealthFacade.java:0) @@ -175,7 +175,7 @@ health → user 리포지토리: Method calls method in (GrowthChartService.java:85) health → user 리포지토리: Method calls method in (HealthActorResolver.java:70) health → user 리포지토리: Method calls method in (HealthActorResolver.java:64) -health → user 리포지토리: Method calls method in (HealthService.java:72) +health → user 리포지토리: Method calls method in (HealthService.java:74) health → user 리포지토리: Method calls method in (SiblingOverviewService.java:44) health → user 리포지토리: Method calls method in (VaccinationScheduleService.java:32) notification → user 리포지토리: Constructor (com.carecode.domain.notification.service.NotificationService, com.carecode.domain.notification.service.NotificationPreferenceService, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.domain.notification.repository.NotificationPreferenceRepository)> has parameter of type in (NotificationFacade.java:0) diff --git a/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java b/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java index 37f34ab..31d0389 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java @@ -102,6 +102,16 @@ public void delete(Long recordId, Long attachmentId) { } attachmentRepository.delete(attachment); + + // 저장된 파일도 지운다. FileStorageService.delete 는 처음부터 있었지만 부르는 곳이 + // 없어서, 첨부를 삭제해도 디스크에 파일이 남았다. 남은 파일은 조회할 길이 없으니 당장 + // 새어 나가지는 않지만, 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 서버에 + // 남는다. 파기 의무를 생각하면 그게 문제다. + // + // 행을 먼저 지우는 순서다. 파일 삭제가 실패해도 사용자에게는 삭제된 것으로 보이고, + // 남은 파일은 가리키는 행이 없어 접근 경로가 없다. 반대 순서라면 파일은 사라졌는데 + // 행이 남아 다운로드가 깨진다. + fileStorageService.delete(fileStorageService.toKey(attachment.getFileUrl())); } /** 건강기록 조회 + 소유권 검증. 건강기록은 민감정보이므로 본인 기록만 접근할 수 있어야 한다. */ diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 4850583..2247c98 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -3,6 +3,7 @@ import com.carecode.domain.user.entity.ConsentType; import com.carecode.domain.user.service.ConsentGuard; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.storage.FileStorageService; import com.carecode.core.exception.CareCodeException; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.ResourceNotFoundException; @@ -46,6 +47,7 @@ public class HealthService { private final HealthRecordRepository healthRecordRepository; private final ConsentGuard consentGuard; private final HealthRecordAttachmentRepository healthRecordAttachmentRepository; + private final FileStorageService fileStorageService; /** * 건강 기록을 자녀에 붙이려면 엔티티가 필요해 남겨 둔다. 조회·소유권 판단은 * {@link ChildDirectory} 가 하고, 여기서는 통과한 뒤 연관을 걸 때만 쓴다. @@ -168,9 +170,25 @@ public void deleteHealthRecord(Long recordId, Long actorUserId) { HealthRecord record = healthRecordRepository.findById(recordId) .orElseThrow(() -> new HealthRecordNotFoundException(recordId)); assertHealthRecordOwnedByUser(record, actorUserId); - + + // 엔티티의 cascade = ALL, orphanRemoval = true 는 첨부 "행" 만 지운다. 저장된 파일은 + // 디스크에 남아서, 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 서버에 + // 남았다. 행이 사라지면 그 파일을 가리키는 것이 없어 지울 기회도 없어지므로, 지우기 + // 전에 키를 모아 둔다. + List storageKeys = healthRecordAttachmentRepository + .findByHealthRecordIdOrderByDisplayOrderAsc(recordId).stream() + .map(HealthRecordAttachment::getFileUrl) + .filter(url -> url != null && !url.isBlank()) + .map(fileStorageService::toKey) + .toList(); + healthRecordRepository.delete(record); - log.info("건강 기록이 삭제되었습니다: 기록ID={}", recordId); + + // 행을 먼저 지운다. 파일 삭제가 실패해도 남은 파일을 가리키는 행이 없어 접근 경로가 + // 없다. 반대 순서라면 파일은 사라졌는데 행이 남아 다운로드가 깨진다. + storageKeys.forEach(fileStorageService::delete); + + log.info("건강 기록이 삭제되었습니다: 기록ID={}, 첨부파일={}건", recordId, storageKeys.size()); } // ===== 아동 정보 관리 ===== diff --git a/src/test/java/com/carecode/domain/health/service/HealthRecordAttachmentServiceTest.java b/src/test/java/com/carecode/domain/health/service/HealthRecordAttachmentServiceTest.java index 14581d9..d731631 100644 --- a/src/test/java/com/carecode/domain/health/service/HealthRecordAttachmentServiceTest.java +++ b/src/test/java/com/carecode/domain/health/service/HealthRecordAttachmentServiceTest.java @@ -27,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -115,6 +116,55 @@ void resolvesStorageKeyFromPublicUrl() { } } + @Nested + @DisplayName("삭제") + class Deletion { + + /** + * 첨부를 지우면 저장된 파일도 지워야 한다. + * + *

{@code FileStorageService.delete(key)} 는 처음부터 있었지만 부르는 곳이 + * 없었다. 그래서 첨부를 삭제해도 디스크에 파일이 남았다. 건강기록을 지울 때도 + * 같다 — 엔티티의 {@code cascade = ALL, orphanRemoval = true} 가 첨부 행만 + * 지운다. + * + *

남은 파일은 조회할 길이 없으니 당장 새어 나가지는 않지만, 민감정보(진단서·처방전)가 + * "지웠다" 고 생각한 뒤에도 서버에 남는다. 열람권·파기 의무를 생각하면 그게 문제다. + */ + @Test + @DisplayName("첨부를 지우면 저장된 파일도 지운다") + void deletingAttachmentRemovesStoredFile() { + when(attachmentRepository.findById(5L)).thenReturn(Optional.of(attachment(5L, MY_RECORD))); + + service.delete(MY_RECORD, 5L); + + verify(attachmentRepository).delete(any(HealthRecordAttachment.class)); + verify(fileStorageService).delete("health-records/2026/08/29/stored.png"); + } + + /** 소유권 확인이 먼저다. 남의 첨부로 파일을 지우게 하면 삭제가 공격 수단이 된다. */ + @Test + @DisplayName("남의 기록이면 파일을 지우지 않는다") + void foreignRecordDeletesNothing() { + assertThatThrownBy(() -> service.delete(OTHERS_RECORD, 5L)) + .isInstanceOf(HealthRecordNotFoundException.class); + + verify(fileStorageService, never()).delete(anyString()); + verify(attachmentRepository, never()).delete(any(HealthRecordAttachment.class)); + } + + @Test + @DisplayName("다른 기록에 달린 첨부 id 로는 지울 수 없다") + void crossRecordDeleteIsRejected() { + when(attachmentRepository.findById(7L)).thenReturn(Optional.of(attachment(7L, 12345L))); + + assertThatThrownBy(() -> service.delete(MY_RECORD, 7L)) + .isInstanceOf(ResourceNotFoundException.class); + + verify(fileStorageService, never()).delete(anyString()); + } + } + @Nested @DisplayName("접근 제어") class AccessControl { diff --git a/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java b/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java index 04dbef9..5341501 100644 --- a/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java +++ b/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java @@ -4,8 +4,11 @@ import com.carecode.core.exception.HealthRecordNotFoundException; import com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest; import com.carecode.domain.health.dto.response.HealthRecordResponse; +import com.carecode.domain.health.entity.HealthRecordAttachment; +import com.carecode.core.storage.FileStorageService; import com.carecode.domain.health.entity.HealthRecord; import com.carecode.domain.health.mapper.HealthRecordMapper; +import com.carecode.domain.health.repository.HealthRecordAttachmentRepository; import com.carecode.domain.health.repository.HealthRecordRepository; import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; @@ -54,6 +57,13 @@ class HealthServiceTest { @Mock private ConsentGuard consentGuard; + /** 기록을 지우면 첨부 파일도 지워야 한다. 그 호출을 보기 위한 목이다. */ + @Mock + private HealthRecordAttachmentRepository healthRecordAttachmentRepository; + + @Mock + private FileStorageService fileStorageService; + @InjectMocks private HealthService healthService; @@ -175,5 +185,34 @@ void getHealthRecordById_ShouldThrowException_WhenRecordNotFound() { verify(healthRecordRepository).findById(recordId); verify(healthRecordMapper, never()).toResponse(any()); } + + /** + * 건강기록을 지우면 첨부 파일도 지워야 한다. + * + *

엔티티의 {@code cascade = ALL, orphanRemoval = true} 는 첨부 행만 지운다. + * 저장된 파일은 디스크에 남는다. 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 + * 서버에 남아 있는 상태다. + */ + @Test + @DisplayName("기록을 지우면 첨부 파일도 지운다") + void deletingRecordRemovesAttachmentFiles() { + Long recordId = 1L; + HealthRecord record = HealthRecord.builder().id(recordId).user(testUser).build(); + HealthRecordAttachment attachment = HealthRecordAttachment.builder() + .healthRecord(record) + .fileUrl("health-records/2026/10/23/stored.png") + .fileName("진단서.png") + .build(); + + when(healthRecordRepository.findById(recordId)).thenReturn(java.util.Optional.of(record)); + when(healthRecordAttachmentRepository.findByHealthRecordIdOrderByDisplayOrderAsc(recordId)) + .thenReturn(java.util.List.of(attachment)); + when(fileStorageService.toKey(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + healthService.deleteHealthRecord(recordId, testUser.getId()); + + verify(fileStorageService).delete("health-records/2026/10/23/stored.png"); + verify(healthRecordRepository).delete(record); + } } From 28eb2c646497d89aad09236fd675e45096d441e2 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 23 Oct 2026 13:15:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=ED=95=9C?= =?UTF-8?q?=20=EC=95=84=EB=AC=B4=EB=82=98=20=EC=A0=95=EC=A0=81=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A1=9C=20=EB=82=A8=EC=9D=98=20=EA=B1=B4=EA=B0=95?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=B2=A8=EB=B6=80=EB=A5=BC=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=20=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업로드 루트 전체(/files/**)가 정적 리소스로 서빙되고 있었다. 보안 설정은 /files/profile-images/** 만 공개로 두고 나머지는 인증을 요구하며, 주석도 "건강기록 첨부는 민감정보라 주소만 알면 열리는 상태로 두면 안 되고, 인증을 거치는 별도 다운로드 경로가 필요하다" 고 적고 있었다. 그런데 인증만으로는 부족했다. 인증을 거치는 다운로드 경로 (GET /health/records/{recordId}/attachments/{id}/download)는 소유권까지 확인하는데, 정적 경로는 로그인한 아무나 통과시켰다. 주소를 알게 된 다른 회원이 남의 진단서를 그대로 받아갈 수 있었다. 파일명이 UUID 라 추측은 어렵지만, 추측이 유일한 방어라면 방어가 아니다. 정적 서빙을 profile-images 하위로만 좁혔다. 리소스 핸들러의 location 자체를 그 디렉터리로 내리므로, 보안 설정의 경로 규칙과 어긋날 여지도 없앴다. 프로필 이미지는 공개로 유지한다. 로 불러가므로 인증 헤더를 붙일 수 없고 원래 화면에 노출되는 값이다. "차단이 과하면 프로필 이미지가 깨진다" 도 테스트로 고정했다. 테스트는 실제 파일을 만들어 둔다. 없으면 "차단됨" 과 "파일이 없음" 이 둘 다 404 라 구분할 수 없다. - 전체 테스트 657개, 실패 0 Refs #49 --- .../carecode/core/config/WebMvcConfig.java | 25 ++- .../AttachmentStaticServingTest.java | 157 ++++++++++++++++++ 2 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/carecode/integration/AttachmentStaticServingTest.java diff --git a/src/main/java/com/carecode/core/config/WebMvcConfig.java b/src/main/java/com/carecode/core/config/WebMvcConfig.java index a6a49a2..b50b057 100644 --- a/src/main/java/com/carecode/core/config/WebMvcConfig.java +++ b/src/main/java/com/carecode/core/config/WebMvcConfig.java @@ -23,11 +23,30 @@ public class WebMvcConfig implements WebMvcConfigurer { @Value("${app.storage.public-base-url:/files}") private String publicBaseUrl; - /** 업로드된 파일을 정적 리소스로 서빙한다. (S3 로 전환하면 이 매핑 대신 버킷 URL 을 그대로 쓰면 된다.) */ + /** 정적으로 열어 두는 하위 디렉터리. 이 밖의 업로드물은 인증·소유권을 거치는 경로로만 나간다. */ + private static final String PUBLIC_SUBDIRECTORY = "profile-images"; + + /** + * 업로드된 파일 중 프로필 이미지만 정적 리소스로 서빙한다. + * + *

전에는 업로드 루트 전체({@code /files/**})를 서빙했다. 보안 설정이 + * {@code /files/profile-images/**} 만 공개로 두고 나머지는 인증을 요구했지만, + * 인증만으로는 부족했다 — 인증을 거치는 다운로드 경로 + * ({@code GET /health/records/{recordId}/attachments/{id}/download})는 소유권까지 확인하는데, + * 정적 경로는 로그인한 아무나 통과시켰다. 주소를 알게 된 다른 회원이 남의 진단서를 그대로 + * 받아갈 수 있었다. 파일명이 UUID 라 추측은 어렵지만, 추측이 유일한 방어라면 방어가 아니다. + * + *

프로필 이미지는 공개로 둔다. {@code } 로 불러가므로 인증 헤더를 붙일 수 없고, + * 원래 화면에 노출되는 값이다. + * + *

S3 로 전환하면 이 매핑 대신 버킷의 공개 프리픽스를 쓰고, 첨부는 서명 URL 로 내려준다. + * 그때도 경계는 같다 — 첨부는 소유권을 확인한 뒤에만 주소가 나간다. + */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { - String location = Paths.get(storageRoot).toAbsolutePath().normalize().toUri().toString(); - registry.addResourceHandler(publicBaseUrl + "/**") + String location = Paths.get(storageRoot).resolve(PUBLIC_SUBDIRECTORY) + .toAbsolutePath().normalize().toUri().toString(); + registry.addResourceHandler(publicBaseUrl + "/" + PUBLIC_SUBDIRECTORY + "/**") .addResourceLocations(location) .setCachePeriod(3600); } diff --git a/src/test/java/com/carecode/integration/AttachmentStaticServingTest.java b/src/test/java/com/carecode/integration/AttachmentStaticServingTest.java new file mode 100644 index 0000000..50e5c2e --- /dev/null +++ b/src/test/java/com/carecode/integration/AttachmentStaticServingTest.java @@ -0,0 +1,157 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * 건강기록 첨부는 정적 경로로 내려가지 않아야 한다. + * + *

업로드 루트는 {@code /files/**} 로 정적 서빙된다. 보안 설정은 그중 + * {@code /files/profile-images/**} 만 공개로 두고 나머지는 인증을 요구하며, 주석도 그렇게 적고 + * 있다 — "건강기록 첨부는 민감정보라 주소만 알면 열리는 상태로 두면 안 되고, 인증을 거치는 + * 별도 다운로드 경로가 필요하다". + * + *

그런데 인증만으로는 부족하다. 인증을 거치는 다운로드 경로 + * ({@code GET /health/records/{recordId}/attachments/{id}/download})는 소유권까지 확인하지만, + * 정적 경로는 로그인한 아무나 통과시킨다. 주소를 알게 된 다른 회원이 남의 진단서를 + * 그대로 받아갈 수 있다. 파일명이 UUID 라 추측은 어렵지만, 추측이 유일한 방어라면 방어가 아니다. + * + *

그래서 정적 서빙을 프로필 이미지로만 좁힌다. 첨부는 소유권을 확인하는 경로로만 받는다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:carecode_static_files;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "app.storage.local.root=" + AttachmentStaticServingTest.STORAGE_ROOT, + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("첨부 정적 서빙 차단") +class AttachmentStaticServingTest { + + /** 컨텍스트 프로퍼티로 넘겨야 해서 상수다. 빌드 디렉터리 안이라 정리 대상에 들어간다. */ + static final String STORAGE_ROOT = "build/test-uploads/static-serving"; + + private static final String ATTACHMENT_PATH = "health-records/2026/10/23/secret-diagnosis.png"; + private static final String PROFILE_PATH = "profile-images/avatar.png"; + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + + private User stranger; + + /** 서빙 대상 파일을 실제로 만들어 둔다. 없으면 차단과 404 를 구분할 수 없다. */ + @BeforeAll + static void createFiles() throws IOException { + for (String relative : new String[] {ATTACHMENT_PATH, PROFILE_PATH}) { + Path file = Path.of(STORAGE_ROOT).resolve(relative); + Files.createDirectories(file.getParent()); + Files.writeString(file, "binary-ish", StandardCharsets.UTF_8); + } + } + + @BeforeEach + void setUp() { + stranger = saveUser(); + } + + @Test + @DisplayName("로그인한 다른 회원도 정적 경로로 첨부를 받을 수 없다") + void authenticatedStrangerCannotFetchAttachment() throws Exception { + int status = mockMvc.perform(get("/files/{path}", ATTACHMENT_PATH) + .header("Authorization", "Bearer " + token(stranger))) + .andReturn().getResponse().getStatus(); + + assertThat(status) + .as("로그인만 하면 남의 진단서를 받을 수 있으면 안 된다") + .isNotEqualTo(200); + } + + @Test + @DisplayName("비로그인도 당연히 받을 수 없다") + void anonymousCannotFetchAttachment() throws Exception { + assertThat(mockMvc.perform(get("/files/{path}", ATTACHMENT_PATH)) + .andReturn().getResponse().getStatus()) + .isNotEqualTo(200); + } + + /** + * 차단이 과하면 프로필 이미지가 깨진다. {@code } 는 인증 헤더를 붙일 수 없어 + * 이 경로는 공개로 유지해야 한다. + */ + @Test + @DisplayName("프로필 이미지는 비로그인에도 계속 열린다") + void profileImageStaysPublic() throws Exception { + assertThat(mockMvc.perform(get("/files/{path}", PROFILE_PATH)) + .andReturn().getResponse().getStatus()) + .as("프로필 이미지는 화면이 직접 불러간다") + .isEqualTo(200); + } + + private String token(User user) { + return jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name()); + } + + private User saveUser() { + String id = UUID.randomUUID().toString().substring(0, 8); + return userRepository.save(User.builder() + .userId("user_" + id) + .email(id + "@example.com") + .password("{noop}unused") + .name("회원" + id) + .role(UserRole.PARENT) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now()) + .build()); + } +} From fe0ea2f02e09cc8fa218840500d58d5e9be135f6 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 23 Oct 2026 13:16:53 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=EB=A7=88=EB=8B=A4?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=EB=90=9C=20=EC=B2=A8=EB=B6=80?= =?UTF-8?q?=EA=B0=80=20=EC=82=AC=EB=9D=BC=EC=A1=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #49 를 보다 더 급한 것을 찾았다. 업로드물은 컨테이너 안 /app/uploads 에 쌓이는데 배포 명령에 볼륨 마운트가 없다. docker run -d --name "$APP" --restart unless-stopped ... --env-file "$ENV_FILE" "$1" 교체할 때 컨테이너를 stop/rm 하므로, 사용자가 올린 진단서·프로필 이미지가 배포마다 전부 날아간다. 운영 배포가 아직 시작되지 않아(#94 시크릿 미설정) 드러나지 않았을 뿐이다. 시크릿을 넣는 순간부터 매 배포가 첨부를 지운다. /opt/carecode/uploads 를 /app/uploads 로 마운트한다. 스테이징도 같게 했다(별도 디렉터리). 서버 준비 명령(mkdir + chown 1000:1000)을 배포 문서에, 전제 조건을 운영 문서에 적었다. 여러 인스턴스로 늘릴 때는 이 방식으로 안 된다 — 그때는 S3 구현체가 필요하다(#49). Refs #49, #94 --- .github/workflows/ci-cd.yml | 10 ++++++++-- docs/features/operations.md | 4 ++++ docs/operations/deployment-secrets.md | 6 ++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 314d7a6..09bc59c 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -272,7 +272,7 @@ jobs: IMAGE: ${{ needs.build-docker.outputs.image-tag }} run: | ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \ - "docker pull $IMAGE && (docker stop carecode-staging || true) && (docker rm carecode-staging || true) && docker run -d --name carecode-staging --memory=640m --memory-swap=640m -p 8082:8082 --env-file /opt/carecode/.env $IMAGE" + "mkdir -p /opt/carecode/uploads-staging && docker pull $IMAGE && (docker stop carecode-staging || true) && (docker rm carecode-staging || true) && docker run -d --name carecode-staging --memory=640m --memory-swap=640m -v /opt/carecode/uploads-staging:/app/uploads -p 8082:8082 --env-file /opt/carecode/.env $IMAGE" - name: Run health check if: steps.secrets.outputs.configured == 'true' @@ -450,8 +450,14 @@ jobs: echo "===== pull $IMAGE =====" docker pull "$IMAGE" + # 업로드물은 컨테이너 밖에 둔다. 볼륨이 없으면 교체할 때마다 /app/uploads 가 컨테이너와 + # 함께 사라진다 — 사용자가 올린 진단서·프로필 이미지가 배포마다 전부 날아간다는 뜻이다. + # (여러 인스턴스로 늘릴 때는 S3 구현체로 바꿔야 한다. 이슈 #49) + UPLOADS_DIR=/opt/carecode/uploads + mkdir -p "$UPLOADS_DIR" + run_app() { - docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null + docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" -v "$UPLOADS_DIR:/app/uploads" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null } wait_healthy() { diff --git a/docs/features/operations.md b/docs/features/operations.md index 6c5398d..dbce8eb 100644 --- a/docs/features/operations.md +++ b/docs/features/operations.md @@ -308,6 +308,10 @@ ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보입니다. 기동 단계에서 실패합니다(의도된 fail-fast). 검증 단계에서 걸리므로 **운영은 무사합니다**. 이슈 #90 - 컨테이너 이름은 `carecode` 로 통일합니다. 예전 워크플로가 만들던 `carecode-blue` / `carecode-green` 은 교체 단계에서 함께 정리합니다 +- **업로드물은 `/opt/carecode/uploads` 에 둡니다.** 배포가 그 디렉터리를 컨테이너의 + `/app/uploads` 로 마운트합니다. 볼륨이 없으면 교체할 때마다 업로드물이 컨테이너와 함께 + 사라집니다 — 사용자가 올린 진단서·프로필 이미지가 **배포마다 전부 날아갑니다.** + (여러 인스턴스로 늘릴 때는 S3 구현체가 필요합니다. 이슈 #49) - **MariaDB 와 Redis 는 서버에 미리 있어야 합니다.** 배포는 앱 컨테이너만 교체합니다. Redis 는 운영에서 선택이 아닙니다 — 리프레시 토큰 폐기, 레이트 리밋, 이메일 인증코드가 여기에 있습니다. 준비 명령은 [1GB 인스턴스(프리티어)에 올리기](#1gb-인스턴스프리티어에-올리기) 에 있습니다 diff --git a/docs/operations/deployment-secrets.md b/docs/operations/deployment-secrets.md index 97b58f1..5d9c848 100644 --- a/docs/operations/deployment-secrets.md +++ b/docs/operations/deployment-secrets.md @@ -104,6 +104,12 @@ sudo usermod -aG docker ubuntu # sudo 없이 docker 를 쓸 수 있어야 한 free -h # 스왑 확인 — 1GB 인스턴스는 스왑 2GB 가 필요하다 ``` +```bash +# 업로드물을 담을 디렉터리. 배포가 컨테이너의 /app/uploads 로 마운트한다. +sudo mkdir -p /opt/carecode/uploads +sudo chown 1000:1000 /opt/carecode/uploads # 컨테이너의 carecode 사용자 +``` + 스왑이 없으면 기동 중 OOM 으로 죽습니다. 설정 명령과 근거(메모리 실측 포함)는 [`docs/features/operations.md` 의 "1GB 인스턴스(프리티어)에 올리기"](../features/operations.md#1gb-인스턴스프리티어에-올리기) 를 보세요.