Repository navigation
fix: 첨부 파일이 지워지지 않고, 로그인한 아무나 남의 첨부를 받을 수 있었다 - #152
Merged
Merged
Conversation
FileStorageService.delete(key) 는 처음부터 있었지만 부르는 곳이 없었다. 두 자리에서 파일이 남는다. 1) 첨부 개별 삭제 — attachmentRepository.delete(attachment) 만 했다 2) 건강기록 삭제 — 엔티티의 cascade = ALL, orphanRemoval = true 가 첨부 "행" 만 지운다 남은 파일은 가리키는 행이 없어 조회할 길이 없으니 당장 새어 나가지는 않는다. 문제는 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 서버에 남는다는 점이다. 열람권· 파기 의무를 생각하면 그게 문제다. 디스크도 계속 찬다. 기록 삭제 쪽은 지우기 전에 키를 모아 둔다. 행이 사라지면 그 파일을 가리키는 것이 없어 지울 기회도 없어진다. 순서는 둘 다 "행 먼저, 파일 나중" 이다. 파일 삭제가 실패해도 남은 파일을 가리키는 행이 없어 접근 경로가 없다. 반대 순서라면 파일은 사라졌는데 행이 남아 다운로드가 깨진다. 소유권 확인이 파일 삭제보다 먼저인 것도 테스트로 고정했다 — 남의 첨부 id 로 파일을 지울 수 있으면 삭제가 공격 수단이 된다. - 전체 테스트 654개, 실패 0 - ArchUnit baseline 241 유지 (HealthService 생성자 서명이 바뀌어 기존 위반 1건이 새로 보였을 뿐이라 refreeze 로 확인) Refs #49
업로드 루트 전체(/files/**)가 정적 리소스로 서빙되고 있었다. 보안 설정은
/files/profile-images/** 만 공개로 두고 나머지는 인증을 요구하며, 주석도 "건강기록 첨부는
민감정보라 주소만 알면 열리는 상태로 두면 안 되고, 인증을 거치는 별도 다운로드 경로가
필요하다" 고 적고 있었다.
그런데 인증만으로는 부족했다. 인증을 거치는 다운로드 경로
(GET /health/records/{recordId}/attachments/{id}/download)는 소유권까지 확인하는데, 정적
경로는 로그인한 아무나 통과시켰다. 주소를 알게 된 다른 회원이 남의 진단서를 그대로
받아갈 수 있었다. 파일명이 UUID 라 추측은 어렵지만, 추측이 유일한 방어라면 방어가 아니다.
정적 서빙을 profile-images 하위로만 좁혔다. 리소스 핸들러의 location 자체를 그 디렉터리로
내리므로, 보안 설정의 경로 규칙과 어긋날 여지도 없앴다.
프로필 이미지는 공개로 유지한다. <img src> 로 불러가므로 인증 헤더를 붙일 수 없고 원래
화면에 노출되는 값이다. "차단이 과하면 프로필 이미지가 깨진다" 도 테스트로 고정했다.
테스트는 실제 파일을 만들어 둔다. 없으면 "차단됨" 과 "파일이 없음" 이 둘 다 404 라
구분할 수 없다.
- 전체 테스트 657개, 실패 0
Refs #49
#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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #49
#49 의 체크리스트를 코드와 대조하다 둘 나왔습니다. 둘 다 S3 전환 결정과 무관하게 지금 고쳐야 하는 것입니다.
1. 첨부를 지워도 저장된 파일이 남았다
FileStorageService.delete(key)는 처음부터 있었지만 부르는 곳이 없었습니다.DELETE /health/records/attachments/{id}DELETE /health/records/{id}두 번째는 엔티티의
cascade = ALL, orphanRemoval = true가 첨부 행만 지우기 때문입니다.남은 파일은 가리키는 행이 없어 조회할 길이 없으니 당장 새어 나가지는 않습니다. 문제는 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 서버에 남는다는 점입니다.
구현에서 신경 쓴 두 가지:
남의 첨부 id 로 파일을 지우면 삭제가 공격 수단이 되므로, 소유권 확인이 먼저인 것도 테스트로 고정했습니다.
2. 로그인한 아무나 정적 경로로 남의 첨부를 받을 수 있었다
업로드 루트 전체(
/files/**)가 정적 리소스로 서빙되고 있었습니다. 보안 설정은/files/profile-images/**만 공개로 두고 나머지는 인증을 요구하며, 주석도 이렇게 적고 있었습니다.그런데 인증만으로는 부족했습니다. 인증을 거치는 다운로드 경로는 소유권까지 확인하는데, 정적 경로는 로그인한 아무나 통과시켰습니다. 주소를 알게 된 다른 회원이 남의 진단서를 그대로 받아갈 수 있었습니다.
파일명이 UUID 라 추측은 어렵지만, 추측이 유일한 방어라면 방어가 아닙니다.
정적 서빙을
profile-images하위로만 좁혔습니다. 리소스 핸들러의location자체를 그 디렉터리로 내리므로 보안 설정의 경로 규칙과 어긋날 여지도 없습니다.프로필 이미지는 공개로 유지합니다 —
<img src>로 불러가므로 인증 헤더를 붙일 수 없고 원래 화면에 노출되는 값입니다. "차단이 과하면 프로필 이미지가 깨진다" 도 테스트로 고정했습니다.숫자