← 전체 글로 돌아가기

웹 개발

파일 업로드 기능을 만들 때 빠뜨리기 쉬운 보안 처리

확장자 검사만 하고 넘어가면 위험하다. MIME 타입 검증부터 저장 경로까지 실제로 확인해야 할 것들을 정리했다.

파일 업로드 기능을 처음 만들 때 accept="image/*"로 프론트에서 막으면 될 것 같지만, 클라이언트 제한은 얼마든지 우회할 수 있다. 서버에서 독립적으로 검증해야 한다.

확장자 검사는 충분하지 않다

.jpg 확장자를 가진 파일이라도 실제로는 HTML이나 JavaScript일 수 있다. 파일 내용의 처음 몇 바이트(magic bytes)를 보면 실제 파일 형식을 알 수 있다.

Node.js에서 file-type 라이브러리를 쓰면 간단하다.

import { fileTypeFromBuffer } from 'file-type';

const buffer = await file.arrayBuffer();
const type = await fileTypeFromBuffer(Buffer.from(buffer));

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

if (!type || !ALLOWED_TYPES.includes(type.mime)) {
  return res.status(400).json({ code: 'INVALID_FILE_TYPE', message: '허용되지 않는 파일 형식입니다.' });
}

파일 크기 제한

업로드 크기 제한은 미들웨어에서 잡는 게 좋다. 파싱이 다 끝난 후 검사하면 이미 서버 메모리를 다 쓴 후다.

// multer 기준
const upload = multer({
  limits: { fileSize: 5 * 1024 * 1024 },  // 5MB
  storage: multer.memoryStorage(),
});

Next.js App Router에서는 route.ts에서 요청 본문을 직접 파싱하기 전에 Content-Length 헤더로 먼저 확인할 수 있다.

웹루트 바깥에 저장하기

업로드된 파일을 public/uploads/에 저장하면 URL만 알면 누구나 직접 접근할 수 있다. 인증이 필요한 파일이라면 웹루트 바깥에 저장하고 API를 통해서만 제공한다.

// /data/uploads/ (웹 서버가 서브하지 않는 경로)
const uploadDir = path.join(process.cwd(), '..', 'data', 'uploads');

S3나 R2 같은 스토리지를 쓴다면 버킷을 기본적으로 비공개로 두고, 필요할 때만 presigned URL로 접근을 열어주는 패턴이 깔끔하다.

파일명 정제

사용자가 업로드한 원본 파일명을 그대로 쓰면 ../../../etc/passwd 같은 경로 순회 공격에 노출될 수 있다. 파일명은 UUID나 해시로 바꿔서 저장한다.

import { randomUUID } from 'crypto';
import path from 'path';

const ext = path.extname(originalName).toLowerCase();
const savedName = `${randomUUID()}${ext}`;

원본 파일명은 DB에 따로 저장해두면 사용자에게 보여줄 때 쓸 수 있다.

간단한 검사 목록

기능을 배포하기 전에 아래 항목을 확인해두면 나중에 고칠 일이 줄어든다.

  • magic bytes로 실제 MIME 타입 검증
  • 파일 크기 제한 (미들웨어 레벨)
  • 저장 경로가 웹루트 바깥인지, 또는 스토리지가 비공개인지
  • 저장 파일명이 UUID/해시로 변환되는지
  • 업로드 후 파일 접근 권한 확인 (인증 필요한 경우)