stack-analysis
CHAPTER 108 / 120
읽기 약 2분
FUNCTION
파일 업로드 보안: 검증 + 격리 + 스캔
핵심 개념
magic byte·MIME·격리 스토리지·바이러스 스캔 — 안전한 업로드.
본문
검증 4단계
1. 확장자 검증 (1차)
2. MIME type 검증 (2차)
3. Magic Byte 검증 (3차) — 가장 신뢰
4. 콘텐츠 검증 (4차) — 실제 처리Magic Byte 검증
import { fileTypeFromBuffer } from 'file-type';
async function validateUpload(buffer: Buffer, mime: string) {
// 1. file-type으로 실제 타입 감지
const detected = await fileTypeFromBuffer(buffer);
if (!detected) {
throw new Error('Cannot detect file type');
}
// 2. 클라이언트가 보낸 MIME과 일치?
if (detected.mime !== mime) {
throw new Error(`MIME mismatch: ${mime} vs ${detected.mime}`);
}
// 3. 화이트리스트
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!ALLOWED.includes(detected.mime)) {
throw new Error('File type not allowed');
}
// 4. 크기
if (buffer.length > 10 * 1024 * 1024) {
throw new Error('File too large');
}
return detected;
}격리 스토리지
// ❌ 같은 도메인에 호스팅 → XSS 위험
// example.com/uploads/user.html (악성)
// ✅ 별도 도메인 + Sandbox
// uploads.example.com 또는 cdn.example.com
// next.config.js
module.exports = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' },
],
},
};
// 응답 헤더 — 강제 다운로드
res.setHeader('Content-Disposition', 'attachment; filename="user-file.pdf"');
res.setHeader('X-Content-Type-Options', 'nosniff');S3 + Presigned URL (안전)
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
app.post('/upload-url', authenticate, async (req, res) => {
const { filename, contentType, size } = req.body;
// 검증
if (size > 10 * 1024 * 1024) return res.status(400).end();
if (!ALLOWED_MIMES.includes(contentType)) return res.status(400).end();
// 사용자 폴더로 격리
const key = `users/${req.user.id}/${crypto.randomUUID()}-${sanitizeFilename(filename)}`;
const command = new PutObjectCommand({
Bucket: PRIVATE_BUCKET, // public 노출 X
Key: key,
ContentType: contentType,
ContentLength: size, // 크기 제한
Metadata: {
uploaderId: req.user.id,
uploadedAt: new Date().toISOString(),
},
ServerSideEncryption: 'AES256',
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5분
res.json({ uploadUrl: url, key });
});
function sanitizeFilename(name: string) {
return name
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/\.+/g, '.')
.slice(0, 100);
}바이러스 스캔
// ClamAV (오픈소스)
import { NodeClam } from 'clamscan';
const clamscan = await new NodeClam().init({
clamdscan: { socket: '/var/run/clamav/clamd.ctl' },
});
async function scanFile(buffer: Buffer) {
const result = await clamscan.scanStream(Buffer.from(buffer));
if (result.isInfected) {
throw new Error(`Virus detected: ${result.viruses.join(', ')}`);
}
}
// 또는 VirusTotal API
async function scanWithVT(buffer: Buffer) {
const formData = new FormData();
formData.append('file', new Blob([buffer]));
const res = await fetch('https://www.virustotal.com/api/v3/files', {
method: 'POST',
headers: { 'x-apikey': process.env.VT_API_KEY! },
body: formData,
});
return res.json();
}
// 또는 AWS S3 — Bucket Antivirus
// S3 + Lambda + ClamAV (Trend Micro 솔루션)이미지 — 메타데이터 제거
import sharp from 'sharp';
async function processImage(buffer: Buffer) {
return sharp(buffer)
.rotate() // EXIF 회전 적용
.removeMetadata() // EXIF 제거 (위치 정보 등)
.webp({ quality: 80 })
.toBuffer();
}
// EXIF에 GPS 좌표·기기 정보 등 — 프라이버시 보호SVG 위험 — Sanitize 또는 차단
import DOMPurify from 'isomorphic-dompurify';
async function processSVG(buffer: Buffer) {
const text = buffer.toString();
const clean = DOMPurify.sanitize(text, {
USE_PROFILES: { svg: true, svgFilters: true },
});
return Buffer.from(clean);
}
// 또는 단순히 SVG 차단
const ALLOWED_MIMES = [
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
// 'image/svg+xml', // 제외
];다운로드 시 보안
app.get('/files/:id/download', authenticate, async (req, res) => {
const file = await db.file.findUnique({ where: { id: req.params.id } });
// 권한 검증
if (file!.userId !== req.user.id) return res.status(403).end();
// S3 presigned URL (다운로드)
const url = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: PRIVATE_BUCKET,
Key: file!.s3Key,
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(file!.originalName)}"`,
}),
{ expiresIn: 60 }
);
res.redirect(url);
});Path Traversal 방어
// ❌ 사용자 입력으로 경로 구성
const filePath = `/uploads/${req.params.filename}`;
// ?filename=../../../etc/passwd → 시스템 파일 노출
// ✅ 검증 + 격리
import path from 'path';
const SAFE_DIR = '/var/uploads';
const requested = path.join(SAFE_DIR, req.params.filename);
if (!requested.startsWith(SAFE_DIR)) {
return res.status(403).end();
}
const safe = path.resolve(requested);
if (!safe.startsWith(path.resolve(SAFE_DIR))) {
return res.status(403).end();
}다음 챕터
CH.109 "의존성 보안: npm audit + Snyk + Renovate".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 코드의 파일 업로드 보안 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
파일 업로드 보안 관련 베스트 프랙티스 5가지를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 프로젝트 전체에서 파일 업로드 보안 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 시장의 파일 업로드 보안 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
파일 업로드 보안: 검증 + 격리 + 스캔은 이 3가지만 확실히 잡으세요
1.Magic Byte 검증 = 확장자·MIME 위조 방지 (file-type)
2.별도 CDN 도메인으로 격리 + Content-Disposition: attachment
3.S3 presigned URL + 파일 폴더 격리 + 메타데이터 제거
공유하기
진행도 108 / 120