1.10 업로드·다운로드 파일 검증

개요

파일 업로드 및 다운로드 기능은 웹 애플리케이션에서 흔히 사용되지만, 보안 검증이 미흡할 경우 심각한 위협으로 이어질 수 있습니다. 업로드 시에는 악성 스크립트(웹쉘), 바이러스 등이 포함된 파일이 서버에 저장되어 실행될 위험이 있으며, 다운로드 시에는 접근 권한이 없는 사용자가 중요 파일에 접근하거나(경로 조작), 의도하지 않은 파일이 외부에 노출될 수 있습니다.

따라서 업로드되는 파일에 대해서는 확장자, MIME 타입, 파일 크기, 내용 등을 철저히 검증하고 안전한 방식으로 저장해야 합니다. 다운로드 요청 시에는 요청된 파일 경로와 사용자의 접근 권한을 정확히 확인하여 비인가 접근을 차단해야 합니다.

이 기준은 안전한 파일 업로드 및 다운로드 기능을 구현하기 위한 필수적인 검증 절차와 보안 요구사항을 정의합니다.

보안 대책 (업로드)

  • - 확장자 제한 (Whitelist 방식): 허용된 확장자 목록을 정의하고, 목록에 없는 파일은 차단합니다.
  • - MIME 타입 검증: 파일 확장자와 실제 파일 타입이 일치하는지 서버 측에서 검증합니다.
  • - 서버사이드 검증 필수: 클라이언트 측 검증은 우회 가능하므로 반드시 서버에서 다시 검증합니다.
  • - 파일 크기 제한: 서비스 거부 공격 방지를 위해 최대 파일 크기를 제한합니다.
  • - 파일 이름 변경 및 안전한 경로 저장: 예측 불가능한 이름으로 변경하고 웹 루트 외부의 안전한 경로에 저장합니다.
  • - 실행 권한 제거: 파일 저장 디렉토리의 스크립트 실행 권한을 제거합니다.
  • - 콘텐츠 검사: 필요 시 파일 내용을 검사하여 악성코드 포함 여부를 확인합니다.

보안 대책 (다운로드)

  • - 경로 조작 방지: 파일 경로 파라미터에 `../` 등 경로 조작 문자가 포함되지 않도록 검증하고, 경로 정규화를 수행합니다.
  • - 접근 권한 검증: 파일을 다운로드하려는 사용자가 해당 파일에 대한 적절한 접근 권한을 가지고 있는지 서버 측에서 확인합니다.
  • - 다운로드 경로 제한: 다운로드 가능한 파일이 위치하는 디렉토리를 제한하고, 요청된 파일 경로가 해당 디렉토리 내부에 있는지 확인합니다.
  • - 간접 참조 매핑: 실제 파일 경로 대신 안전한 식별자(파일 ID 등)를 사용하여 다운로드를 요청받고, 서버 내부에서 실제 경로로 매핑하여 처리합니다.
  • - Content-Disposition 헤더 사용: `Content-Disposition: attachment; filename="..."` 헤더를 사용하여 브라우저가 파일을 직접 열지 않고 다운로드하도록 유도하고, 안전한 파일명을 지정합니다.

코드 예시 (Java - 안전한 파일 업로드)

안전한 파일 업로드는 확장자 검증(Whitelist), 파일명 변경, 안전한 경로 저장, 크기 제한 등의 조치가 필요합니다.

안전한 코드 (확장자 검증, 이름 변경, 경로 분리):

import javax.servlet.annotation.MultipartConfig;
import javax.servlet.http.Part;
import java.util.UUID;
import java.nio.file.Paths;
import java.io.File;
import java.util.Set; // Set import 추가

// ... other necessary imports ...

@MultipartConfig(fileSizeThreshold = 1024 * 1024 * 2, maxFileSize = 1024 * 1024 * 10, maxRequestSize = 1024 * 1024 * 50)
public class SafeFileUploadServlet extends HttpServlet {

    private static final String UPLOAD_DIRECTORY = "/path/to/safe/storage"; // 웹 루트 외부!
    private static final Set ALLOWED_EXTENSIONS = Set.of(".jpg", ".jpeg", ".png", ".pdf");

    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        
        Part filePart = request.getPart("userFile");
        String originalFileName = Paths.get(filePart.getSubmittedFileName()).getFileName().toString();
        String fileExtension = getFileExtension(originalFileName);

        // 1. 확장자 검증 (Whitelist)
        if (fileExtension == null || !ALLOWED_EXTENSIONS.contains(fileExtension.toLowerCase())) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().println("Error: Invalid file type.");
            return;
        }

        // 2. 파일 이름 변경 (UUID 사용)
        String savedFileName = UUID.randomUUID().toString() + fileExtension;

        // 3. 안전한 경로에 저장 (웹 루트 외부)
        File uploadDir = new File(UPLOAD_DIRECTORY);
        if (!uploadDir.exists()) uploadDir.mkdirs();
        
        try {
             filePart.write(UPLOAD_DIRECTORY + File.separator + savedFileName);
             response.getWriter().println("File uploaded successfully as: " + savedFileName);
             // TODO: DB에 파일 정보 기록
        } catch (IOException e) {
             response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
             response.getWriter().println("Error: File upload failed.");
        }
    }

    private String getFileExtension(String fileName) {
        // ... (이전 예시와 동일) ...
        if (fileName == null || fileName.lastIndexOf(".") == -1) {
            return null;
        }
        return fileName.substring(fileName.lastIndexOf("."));
    }
}
                        

코드 예시 (Java - 안전한 파일 다운로드)

안전한 파일 다운로드는 경로 조작 방지 및 접근 권한 확인이 필수적입니다.

안전한 코드 (경로 검증 및 권한 확인):

import java.io.File;
import java.io.FileInputStream;
import java.io.OutputStream;
import java.nio.file.Path;
import java.nio.file.Paths;

// ... other necessary imports ...

public class SafeFileDownloadServlet extends HttpServlet {

    private static final String DOWNLOAD_BASE_DIRECTORY = "/path/to/safe/storage"; // 실제 파일 저장 경로

    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // 예: DB 조회 등을 통해 얻은 안전한 파일 식별자 또는 저장된 파일명 사용
        String requestedFileId = request.getParameter("fileId"); 
        String savedFileName = lookupFileNameById(requestedFileId); // DB 등에서 실제 저장된 파일명 조회

        if (savedFileName == null) {
             response.setStatus(HttpServletResponse.SC_NOT_FOUND);
             response.getWriter().println("Error: File not found.");
             return;
        }
        
        // 1. 경로 조작 방지 (저장된 파일명 사용)
        Path baseDir = Paths.get(DOWNLOAD_BASE_DIRECTORY).toAbsolutePath();
        Path filePath = baseDir.resolve(savedFileName).normalize();

        // 2. 최종 경로가 기본 디렉토리 내부에 있는지 확인
        if (!filePath.startsWith(baseDir) || !filePath.toFile().exists() || !filePath.toFile().isFile()) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
             response.getWriter().println("Error: Invalid file path.");
             return;
        }
        
        // 3. 접근 권한 검증 (예: 세션 확인)
        // if (!hasPermissionToDownload(request.getSession(false), requestedFileId)) {
        //     response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        //     response.getWriter().println("Error: Access denied.");
        //     return;
        // }

        File downloadFile = filePath.toFile();

        // 4. Content-Disposition 설정 (다운로드 유도 및 파일명 지정)
        // 원본 파일명을 DB 등에서 가져와 사용
        String originalFileName = lookupOriginalFileNameById(requestedFileId); 
        response.setContentType("application/octet-stream"); // 일반적인 다운로드 타입
        response.setContentLengthLong(downloadFile.length());
        String headerValue = String.format("attachment; filename=\"%s\"", 
                             java.net.URLEncoder.encode(originalFileName, "UTF-8").replaceAll("\\+", "%20")); // 파일명 인코딩
        response.setHeader("Content-Disposition", headerValue);

        // 파일 전송
        try (FileInputStream fis = new FileInputStream(downloadFile);
             OutputStream os = response.getOutputStream()) {
            byte[] buffer = new byte[4096];
            int bytesRead;
            while ((bytesRead = fis.read(buffer)) != -1) {
                os.write(buffer, 0, bytesRead);
            }
        } catch (IOException e) {
             // 파일 전송 오류 처리
             System.err.println("Error during file download: " + e.getMessage());
        }
    }
    
    // --- Helper methods (실제 구현 필요) ---
    private String lookupFileNameById(String fileId) { /* DB 조회 로직 */ return "example_file.pdf"; }
    private String lookupOriginalFileNameById(String fileId) { /* DB 조회 로직 */ return "원본 파일명.pdf"; }
    // private boolean hasPermissionToDownload(HttpSession session, String fileId) { /* 권한 검증 로직 */ return true; }

}