공공 웹서비스 파일 다운로드 권한 검증 설계

· 약 8분 읽기 · 조회 1 #보안#파일 다운로드#권한#공공 SI#Spring Boot

공공 웹서비스의 파일 다운로드는 가장 단순해 보이는 기능이면서 개인정보 유출 사고가 가장 자주 나는 기능이다. 목록 화면에서는 권한을 확인했는데 다운로드 주소는 파일 번호만 바꾸면 남의 첨부가 내려받아지거나, 경로 문자열을 그대로 받아 서버의 다른 파일까지 읽히는 식이다. 이 글은 다운로드 요청 하나가 거쳐야 할 검증 단계와 저장 구조, 응답 헤더, 감사 기록까지 설계 기준을 정리한 것이다.

기록물 창구에서 직원이 방문객의 신분증과 신청서를 확인한 뒤 잠긴 서가에서 봉인된 서류철을 건네고, 옆의 장부에 건넨 기록을 남기는 장면

흔한 사고 네 가지

사고 어떻게 생기나 막는 방법
남의 파일 내려받기(IDOR) download?fileId=1024의 번호만 바꿔 요청 다운로드 시점에 업무 권한을 다시 검사, 추측 불가능한 ID
경로 조작 download?path=../../conf/app.yml 클라이언트에게 경로를 받지 않는다. ID → 서버 경로 매핑
웹 경로로 직접 접근 업로드 파일이 웹 루트 아래에 저장 웹 루트 밖 저장소, 애플리케이션만 읽기
영구 공개 링크 한 번 만든 링크가 계속 열림 짧은 만료 서명 URL 또는 매 요청 권한 검사

공통점은 목록 화면의 권한 검사를 다운로드에서 생략했다는 것이다. 다운로드 엔드포인트는 화면과 따로 호출될 수 있는 독립 API로 보고 처음부터 다시 검사한다.

저장 구조: 파일과 업무를 잇는 메타데이터

파일 자체가 아니라 파일 메타데이터가 권한 판단의 근거다.

컬럼 용도
file_id 외부에 노출하는 ID. 순번이 아닌 UUID 같은 추측 불가능한 값
owner_type, owner_id 어느 업무 건(예: 민원 신청 123)에 속한 파일인가
storage_key 실제 저장 위치. 외부에 절대 노출하지 않는다
original_name, content_type, size 응답 헤더용
visibility 공개·기관 내부·당사자 한정
contains_pii 개인정보 포함 여부(감사·사유 기록 대상)
status, retention_until 삭제·보존 기한 처리

순번 ID를 UUID로 바꾸는 것만으로는 부족하다. 추측이 어려워질 뿐, 링크가 유출되면 그대로 열린다. 권한 검사가 본체이고 ID는 보조 장치다.

다운로드 요청이 거치는 단계

다운로드 요청이 인증, 파일 존재·상태, 업무 권한을 차례로 통과해야 하고, 개인정보가 들어 있으면 사유를 기록한 뒤 감사 로그를 남기고 파일을 전달하며, 실패하면 존재 여부를 숨기도록 404로 응답하는 검증 순서

  1. 인증: 로그인 사용자인가. 공개 파일이 아니면 비로그인은 거절한다.
  2. 파일 확인: file_id로 메타데이터를 찾고 삭제·보존 기한 만료 상태가 아닌지 본다.
  3. 업무 권한: 파일이 속한 업무 건을 기준으로 판단한다. "민원 123의 신청인 본인이거나, 담당 부서 직원이거나, 권한 있는 관리자"처럼 업무 규칙으로 쓴다. 역할만 보고 통과시키지 않는다.
  4. 개인정보 파일: 기관 지침에 따라 다운로드 사유를 받거나 대량 다운로드를 제한한다.
  5. 감사 로그: 누가, 언제, 어떤 파일을, 어떤 IP에서, 성공·실패 여부를 기록한다.
  6. 전달: 서버가 저장소에서 읽어 스트리밍하거나, 수 분 안에 만료되는 서명 URL로 넘긴다.

권한이 없을 때는 403보다 404가 낫다. 403은 "그 파일이 존재한다"는 사실을 알려 준다.

구현 예시 (Spring Boot)

@GetMapping("/files/{fileId}")
public ResponseEntity<StreamingResponseBody> download(@PathVariable UUID fileId, Authentication auth) {
    FileMeta meta = fileService.findActive(fileId)
            .filter(m -> fileAccessPolicy.canRead(m, auth))   // 업무 권한: 소유 업무 건 기준
            .orElseThrow(NotFoundException::new);             // 없음과 권한 없음을 구분하지 않는다

    auditLog.recordDownload(meta, auth, requestIp());

    ContentDisposition disposition = ContentDisposition.attachment()
            .filename(meta.originalName(), StandardCharsets.UTF_8)   // 한글 파일명(filename*) 처리
            .build();
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
            .header("X-Content-Type-Options", "nosniff")
            .header(HttpHeaders.CACHE_CONTROL, "private, no-store")
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .contentLength(meta.size())
            .body(out -> storage.copyTo(meta.storageKey(), out));   // 경로는 서버가 메타데이터로만 정한다
}

fileAccessPolicy.canRead는 다운로드뿐 아니라 목록·미리보기·삭제에서도 같은 함수를 쓴다. 권한 규칙이 화면마다 따로 구현되면 그중 하나가 반드시 빠진다.

응답 헤더

헤더 이유
Content-Disposition: attachment; filename*=UTF-8''… 브라우저가 열지 않고 저장하게 하고, 한글 파일명을 깨지지 않게
X-Content-Type-Options: nosniff 업로드된 HTML·스크립트를 브라우저가 내용 추측으로 실행하지 않게
Cache-Control: private, no-store 공용 PC·중간 프록시에 개인 파일이 남지 않게
Content-Type: application/octet-stream 업로드 때 받은 형식을 그대로 믿지 않는다

CDN이나 웹 서버 캐시를 쓰는 사이트라면 다운로드 경로는 캐시 대상에서 반드시 뺀다. 한 사람의 파일 응답이 다른 사람에게 캐시로 전달되는 사고가 실제로 생긴다.

업로드 단계에서 미리 막을 것

다운로드 보안의 절반은 업로드에서 정해진다.

  • 확장자와 실제 파일 내용(시그니처)을 함께 검사하고 허용 목록만 받는다.
  • 저장 파일명은 서버가 새로 만든다. 원래 이름은 메타데이터에만 둔다.
  • 웹 루트 밖이나 비공개 객체 저장소에 둔다.
  • 악성코드 검사를 거친 뒤에 다운로드 가능 상태로 바꾼다.
  • 크기 제한과 사용자별 업로드 횟수 제한을 둔다.

감사와 모니터링

  • 다운로드 로그는 성공뿐 아니라 실패(권한 없음, 없는 파일) 도 남긴다. 번호를 바꿔 가며 시도하는 패턴은 실패 로그에서 보인다.
  • 짧은 시간에 한 사용자가 많은 파일을 내려받으면 알림을 보내고, 기관 기준에 따라 제한한다.
  • 개인정보 파일의 다운로드 기록은 기관의 개인정보 접속기록 보관 기준에 맞춰 보존 기간과 위변조 방지를 정한다.

자주 하는 실수

  • 목록에서만 권한을 검사한다. 다운로드 주소는 화면 없이도 호출된다.
  • 경로나 파일명을 파라미터로 받는다. 경로 조작의 출발점이다.
  • 역할만 보고 통과시킨다. "직원이면 다운로드 가능"은 다른 부서 민원까지 열어 준다.
  • 권한 없음에 403을 준다. 파일 존재 여부가 새어 나간다.
  • 다운로드 응답이 캐시된다. 공용 캐시를 거쳐 다른 사용자에게 전달될 수 있다.

점검표

  • 외부 노출 ID는 추측 불가능하고, 저장 경로는 노출되지 않는다
  • 다운로드 시점에 파일이 속한 업무 건 기준으로 권한을 다시 검사한다
  • 권한 규칙 함수 하나를 목록·미리보기·다운로드·삭제가 함께 쓴다
  • 권한 없음과 없는 파일은 똑같이 404로 응답한다
  • 클라이언트에게 경로·파일명을 받지 않는다
  • Content-Disposition, nosniff, no-store 헤더가 있다
  • 다운로드 경로가 CDN·웹 서버 캐시에서 빠져 있다
  • 업로드 시 확장자·시그니처·악성코드 검사를 한다
  • 성공·실패 다운로드가 모두 감사 로그에 남는다
  • 개인정보 파일은 사유 기록·대량 다운로드 제한 기준이 있다

파일 다운로드의 권한 검증은 "이 사람이 이 업무 건의 파일을 지금 볼 수 있는가"를 매 요청마다 묻는 일이다. 경로는 서버만 알고, 판단은 한 함수가 하고, 모든 시도는 기록에 남기면 가장 흔한 유출 경로가 닫힌다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기