Skip to content

fix: 첨부 파일이 지워지지 않고, 로그인한 아무나 남의 첨부를 받을 수 있었다 - #152

Merged
RosieOh merged 3 commits into
mainfrom
fix/delete-stored-attachment-files
Oct 23, 2026
Merged

RosieOh merged 3 commits into
mainfrom
fix/delete-stored-attachment-files

Conversation

@RosieOh

@RosieOh RosieOh commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Refs #49

#49 의 체크리스트를 코드와 대조하다 둘 나왔습니다. 둘 다 S3 전환 결정과 무관하게 지금 고쳐야 하는 것입니다.

1. 첨부를 지워도 저장된 파일이 남았다

FileStorageService.delete(key) 는 처음부터 있었지만 부르는 곳이 없었습니다.

경로 지워지는 것 남는 것
DELETE /health/records/attachments/{id} 첨부 행 파일
DELETE /health/records/{id} 기록 + 첨부 행 (cascade) 파일

두 번째는 엔티티의 cascade = ALL, orphanRemoval = true 가 첨부 행만 지우기 때문입니다.

남은 파일은 가리키는 행이 없어 조회할 길이 없으니 당장 새어 나가지는 않습니다. 문제는 진단서·처방전 같은 민감정보가 "지웠다" 고 생각한 뒤에도 서버에 남는다는 점입니다.

구현에서 신경 쓴 두 가지:

  • 기록 삭제는 지우기 전에 키를 모아 둡니다. 행이 사라지면 그 파일을 가리키는 것이 없어져 지울 기회도 없어집니다
  • 순서는 "행 먼저, 파일 나중" 입니다. 파일 삭제가 실패해도 남은 파일을 가리키는 행이 없어 접근 경로가 없습니다. 반대 순서라면 파일은 사라졌는데 행이 남아 다운로드가 깨집니다

남의 첨부 id 로 파일을 지우면 삭제가 공격 수단이 되므로, 소유권 확인이 먼저인 것도 테스트로 고정했습니다.

2. 로그인한 아무나 정적 경로로 남의 첨부를 받을 수 있었다

업로드 루트 전체(/files/**)가 정적 리소스로 서빙되고 있었습니다. 보안 설정은 /files/profile-images/** 만 공개로 두고 나머지는 인증을 요구하며, 주석도 이렇게 적고 있었습니다.

업로드 루트(/files/**) 전체를 열지 않는 이유는 같은 저장소에 건강기록 첨부가 들어 있기 때문이다. 그쪽은 민감정보라 주소만 알면 열리는 상태로 두면 안 되고, 인증을 거치는 별도 다운로드 경로가 필요하다.

그런데 인증만으로는 부족했습니다. 인증을 거치는 다운로드 경로는 소유권까지 확인하는데, 정적 경로는 로그인한 아무나 통과시켰습니다. 주소를 알게 된 다른 회원이 남의 진단서를 그대로 받아갈 수 있었습니다.

파일명이 UUID 라 추측은 어렵지만, 추측이 유일한 방어라면 방어가 아닙니다.

정적 서빙을 profile-images 하위로만 좁혔습니다. 리소스 핸들러의 location 자체를 그 디렉터리로 내리므로 보안 설정의 경로 규칙과 어긋날 여지도 없습니다.

프로필 이미지는 공개로 유지합니다 — <img src> 로 불러가므로 인증 헤더를 붙일 수 없고 원래 화면에 노출되는 값입니다. "차단이 과하면 프로필 이미지가 깨진다" 도 테스트로 고정했습니다.

테스트는 실제 파일을 만들어 둡니다. 없으면 "차단됨" 과 "파일이 없음" 이 둘 다 404 라 구분할 수 없습니다.

숫자

  • 전체 테스트 657개, 실패 0
  • ArchUnit baseline 241 유지

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
@RosieOh RosieOh changed the title fix: 첨부를 지워도 저장된 파일이 남았다 fix: 첨부 파일이 지워지지 않고, 로그인한 아무나 남의 첨부를 받을 수 있었다 Oct 23, 2026
#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
@RosieOh
RosieOh merged commit f91e01d into main Oct 23, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant