TechFeedTechFeed
Security

파일 업로드, 매직 바이트, 확장자, 용량 | 문의 첨부가 서버를 깨면?

문의 첨부는 확장자가 아니라 앞 바이트로 받습니다. 피엔지 서명과 제이펙 서명만 통과하고, 에스브이지는 스크립트가 돌아 거절합니다. 버셀 함수는 요청 본문 4.5메가에서 413입니다. 개발자, Next.js, 보안, API, 백엔드, 프론트엔드, 버셀 기준으로 이름과 용량 칸만 보고 턴스타일, 에스에스알에프 글과 자리를 섞지 않습니다. 2026년 8월 공식 문서.

by

확장자만 보고 첨부를 받으면, 이름만 피엔지인 파일이 그대로 들어갑니다. 앞 바이트가 피엔지 서명인 것만, 제이펙 서명인 것만 받습니다. 버셀 함수는 요청 본문이 4.5메가바이트를 넘으면 413을 냅니다. 그보다 큰 파일은 브라우저가 저장소로 바로 올립니다. 에스브이지는 그림이 아니라 마크업이라 로고 칸에서 거절합니다. 저는 파트너십 폼에 로고를 받다가, 확장자만 막은 날 에스브이지 스크립트가 관리 화면에 뜬 적이 있습니다. 봇은 턴스타일 글, 서버가 주소를 대신 여는 칸은 에스에스알에프 글입니다. 여기는 첨부 파일의 앞 바이트와 용량 칸만 봅니다.


숫자는 2026년 8월 오와스프 파일 업로드 치트시트와 버셀 함수 한도 문서 기준입니다.


확장자만 막으면 이름만 피엔지인 파일이 들어온다

브라우저가 보내는 이름과 콘텐츠 타입은 사용자가 바꿉니다. 허용 목록은 파일 앞 바이트로 겁니다.


오와스프 파일 업로드 치트시트는 콘텐츠 타입 헤더를 믿지 말라고 적습니다. 확장자 차단만 두는 것도 약하다고 적습니다. 이중 확장자, 널 바이트, 정규식 구멍은 이름 칸에서 납니다. 치트시트가 권하는 순서는 허용 확장자, 타입 검증, 앱이 만든 파일 이름, 용량 상한, 웹 루트 밖 저장입니다.


저는 파트너십 문의에 accept=".png,.jpg"만 걸어 둔 적이 있습니다. 화면은 피엔지와 제이펙만 고르게 했습니다. 개발자 도구에서 이름을 바꾸면 서버는 그대로 받았습니다. 엠디엔 파일 타입 문서도 브라우저 File.type은 추측 값이라고 적습니다. 버튼이 두 번 눌리는 칸은 폼 상태 글입니다. 오늘은 파일이 진짜 그림인지 보는 칸입니다.


누가 정하나속이기어디에 쓰나
파일 이름 확장자사용자쉬움. 이름만 바꿈화면 힌트. 저장 이름으로 쓰지 않음
콘텐츠 타입 헤더사용자, 브라우저쉬움. 폼에서 바꿈빠른 거절. 최종 판정 아님
앞 바이트 서명파일 내용서명을 붙이면 가능. 힌트허용 목록의 1차 판정
다시 인코딩서버 이미지 라이브러리어려움로고, 썸네일처럼 그림만 받을 때

시크릿이 저장소 주소에 붙는 칸은 시크릿 체크 글입니다. 업로드 토큰도 그 목록에 넣습니다.


accept 속성은 보호가 아닙니다 | 입력 칸의 허용 목록은 파일 고르는 창만 좁힙니다. 서버가 앞 바이트를 보지 않으면, 이름이 logo.png인 다른 파일이 그대로 저장됩니다.


앞 8바이트로 허용 목록을 거는 순서

저장하기 전에 버퍼 앞자리를 허용 서명과 맞춥니다. 맞으면 그 타입의 확장자만 붙입니다. 원본 이름은 버립니다.


더블유쓰리씨 피엔지 문서는 파일 서명을 8바이트로 고정합니다. 89 50 4E 47 0D 0A 1A 0A입니다. 제이펙은 FF D8 FF로 시작합니다. 웹피는 리프 헤더 뒤에 WEBP가 옵니다. 파일 타입 패키지 문서도 매직 넘버는 힌트라고 적습니다. 허용 목록 밖의 타입은 거절합니다.


아이폰에서 카카오로 받은 사진은 에이치이아이씨인 날이 많습니다. 피엔지와 제이펙만 받는 폼은 그 파일을 거절하는 편이 맞습니다. 변환을 넣기 전에, 파트너에게 제이펙으로 다시 보내 달라고 하는 쪽이 사고 면적이 작습니다.


앞 바이트로 타입을 고르고, 원본 이름은 버린다
// 서명: W3C PNG, JPEG SOI, WebP RIFF // https://www.w3.org/TR/png/#5PNG-file-signature const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const JPEG = Buffer.from([0xff, 0xd8, 0xff]); const MAX_BYTES = 1024 * 1024; // 로고 1메가 function detectImage(buf) { if (buf.length > MAX_BYTES) return null; if (buf.subarray(0, 8).equals(PNG)) return 'png'; if (buf.length >= 3 && buf.subarray(0, 3).equals(JPEG)) return 'jpg'; if ( buf.length >= 12 && buf.subarray(0, 4).toString('ascii') === 'RIFF' && buf.subarray(8, 12).toString('ascii') === 'WEBP' ) return 'webp'; return null; } // pages/api 또는 앱 라우트 export async function POST(request) { const form = await request.formData(); const file = form.get('logo'); if (!(file instanceof File)) { return Response.json({ error: '파일이 없습니다' }, { status: 400 }); } const buf = Buffer.from(await file.arrayBuffer()); const kind = detectImage(buf); if (!kind) { return Response.json({ error: '피엔지, 제이펙, 웹피만 받습니다' }, { status: 415 }); } const name = crypto.randomUUID() + '.' + kind; // 웹 루트 밖 또는 비공개 버킷에 name 으로 저장 return Response.json({ ok: true, name }); }
파일 앞 바이트 서명으로 피엔지와 제이펙만 허용하는 검사 흐름
원본 이름은 버리고, 서명이 맞은 타입의 확장자만 붙인다

서명은 입구입니다 | 앞 바이트가 맞아도 뒤에 다른 내용이 붙을 수 있습니다. 로고처럼 그림만 필요하면 서버에서 한 번 다시 인코딩합니다. 샤프가 이미 있으면 그 출력을 저장합니다.


에스브이지를 이미지로 받으면 스크립트가 돈다

에스브이지는 그림이 아니라 마크업입니다. 스크립트와 외부 주소를 넣을 수 있습니다. 로고 첨부에 넣지 않습니다.


오와스프는 공개로 받는 파일의 클라이언트 활성 내용을 따로 적습니다. 관리 화면이 에스브이지를 이미지로 넣으면, 그 탭을 연 사람의 세션에서 스크립트가 돕니다. 콘텐츠 보안 정책이 인라인 스크립트를 막아도, 외부 그림 주소나 스타일로 새는 칸이 남습니다. 헤더 칸은 보안 헤더 글입니다.


제가 막힌 날은 파트너가 일러스트에서 보낸 로고였습니다. 확장자는 피엔지처럼 보였고, 앞부분은 텍스트였습니다. 관리 페이지가 그 파일을 이미지 태그로 열었습니다. 그때부터 파트너십 로고는 피엔지, 제이펙, 웹피만 받습니다. 벡터가 필요하면 제가 받아서 정적 폴더에 직접 넣습니다.


에스브이지는 살균해도 기본 거절입니다 | 문의 첨부에서 벡터를 받아야 하면, 관리자만 여는 비공개 저장소에 두고 브라우저에 그대로 넣지 않습니다. 공개 씨디엔에 올리지 않습니다.


4.5메가를 넘기면 함수가 아니라 저장소로 올린다

버셀 함수의 요청 본문과 응답 본문 한도는 4.5메가바이트입니다. 넘으면 413입니다. 큰 파일은 브라우저가 저장소로 바로 보냅니다.


버셀 함수 한도 문서가 그 숫자를 적습니다. 지식베이스는 큰 요청을 함수에 넣지 말고, 클라이언트가 블롭으로 올리라고 안내합니다. 블롭 클라이언트 업로드 문서는 브라우저가 저장소로 바로 보내고, 서버는 토큰만 만든다고 적습니다. 토큰을 만들기 전에 로그인과 허용 타입을 검사하지 않으면, 누구나 저장소에 올립니다.


문의 로고는 1메가면 충분합니다. 그 크기는 함수로 받아 앞 바이트를 봐도 됩니다. 스크린샷 묶음이나 원본 사진은 4.5를 쉽게 넘습니다. 그때는 핸들 업로드의 허용 콘텐츠 타입에 제이펙, 피엔지, 웹피만 넣고, 끝난 뒤에 서버가 다시 받아 서명을 확인합니다. 로컬에서 끝난 콜백이 안 오는 칸은 터널 글과 겹칩니다. 주소는 클라우드플레어 터널입니다.


크기경로서버가 하는 일
1메가 이하 로고폼데이터로 함수앞 바이트, 용량, 다시 인코딩
4.5메가 근처함수에 넣지 않음413이 나기 전에 경로를 나눔
그 이상브라우저에서 블롭토큰 발급 전 로그인, 끝난 뒤 서명 재확인

블롭 토큰은 로그인 뒤에만 만든다. 허용 타입을 좁힌다
// https://vercel.com/docs/vercel-blob/client-upload import { handleUpload } from '@vercel/blob/client'; import { NextResponse } from 'next/server'; export async function POST(request) { const body = await request.json(); try { const json = await handleUpload({ body, request, onBeforeGenerateToken: async () => { // 세션이 없으면 throw. 문서가 인증을 필수로 적음 return { allowedContentTypes: ['image/jpeg', 'image/png', 'image/webp'], addRandomSuffix: true, maximumSizeInBytes: 8 * 1024 * 1024 }; }, onUploadCompleted: async ({ blob }) => { // 로컬호스트는 콜백이 안 옴. 터널 주소가 필요 // blob.url 을 다시 받아 앞 바이트를 확인한 뒤에만 디비에 남김 } }); return NextResponse.json(json); } catch (err) { return NextResponse.json({ error: err.message }, { status: 400 }); } }
큰 파일은 브라우저가 저장소로 바로 올리고 서버는 토큰만 만드는 흐름
버셀 함수 본문 한도는 4.5메가바이트다. 큰 파일은 함수를 거치지 않는다

원본 이름을 그대로 쓰면 경로가 열린다

저장 이름은 앱이 만듭니다. 원본 이름에 점 두 개나 슬래시가 있으면 폴더를 벗어납니다.


오와스프는 파일 이름을 무작위 문자열로 바꾸라고 적습니다. 길이와 문자 종류도 제한하라고 적습니다. 윈도 예약 이름, 앞쪽 점, 연속 점은 넣지 않습니다. 저는 crypto.randomUUID()에 감지한 확장자만 붙입니다. 파트너가 보낸 최종_로고 (2).PNG는 디비의 표시 이름 칸에만 남깁니다.


Next.js의 public 폴더에 저장하면 그 파일이 정적 주소가 됩니다. 누구나 이름을 찍을 수 있습니다. 치트시트는 다른 호스트이거나 웹 루트 밖을 먼저 적습니다. 버셀 블롭은 비공개로 만들고, 볼 때만 짧은 주소나 로그인 뒤 프록시를 줍니다. 로그인 쿠키 칸은 세임사이트 글입니다.


공개 버킷은 주소가 곧 권한입니다 | 업로드가 끝나는 순간 유추 가능한 경로면, 로그인하지 않은 사람도 파일을 받습니다. 무작위 접미사를 켜고, 관리 화면이 아니면 공개 읽기를 끄세요.


공개 폴더에 두면 누구나 주소를 찍는다

첨부를 사이트 정적 폴더에 넣으면, 배포본에 파일이 실립니다. 문의 로고는 배포 산출물이 아닙니다.


12개 사이트 파트너십 첨부를 예전에 public/uploads에 둔 적이 있습니다. 다음 프리빌트에 그 파일이 같이 올라갔습니다. 주소만 알면 내려받았습니다. 지금은 저장소가 앱과 다릅니다. 디비에는 아이디와 저장 키만 남깁니다. 관리 화면이 열 때만 서버가 바이트를 읽어 내려줍니다. 내려줄 때는 콘텐츠 디스포지션을 첨부로 둡니다. 브라우저가 그 파일을 페이지로 실행하지 않게 합니다.


용량 상한은 저장소 앞과 함수 앞에 같이 겁니다. 치트시트는 압축을 푸는 파일의 크기도 푼 뒤로 재라고 적습니다. 문의 폼은 압축 파일을 받지 않습니다. 그림 한 장만 받습니다. 봇으로 빈 파일을 반복해서 올리는 칸은 턴스타일과 서버 상한이 같이 막습니다.


문의 첨부를 웹 루트 밖 저장소에 두고 관리 화면에서만 내려받는 구조
정적 폴더에 올리면 배포본에 파일이 실리고 주소만으로 열린다

참고 자료


한도와 서명은 쓰는 런타임 문서가 우선입니다. 위 링크는 2026년 8월 확인입니다.


자주 묻는 질문

확장자 허용 목록만으로 충분한가?

충분하지 않습니다. 이름은 사용자가 바꿉니다. 서버에서 앞 바이트를 보고, 저장 이름은 앱이 만듭니다. 입력 칸의 accept는 파일 고르는 창만 좁힙니다.


파일 타입 패키지만 믿어도 되나?

힌트로만 씁니다. 패키지 문서도 매직 넘버는 최선의 추정이라고 적습니다. 허용 목록 밖은 거절하고, 그림이면 다시 인코딩합니다.


블롭으로 바로 올리면 서버 검사가 빠지나?

빠질 수 있습니다. 토큰을 주기 전에 로그인을 확인하고, 허용 콘텐츠 타입을 좁힙니다. 업로드가 끝나면 서버가 파일을 다시 받아 앞 바이트를 확인한 뒤에만 디비에 남깁니다.


에스브이지 로고를 꼭 받아야 하면?

문의 폼에서는 거절하는 쪽이 사고 면적이 작습니다. 꼭 필요하면 비공개 저장소에 두고 관리 화면에도 이미지 태그로 넣지 않습니다. 제가 받아서 정적 폴더에 직접 넣는 순서를 씁니다.


로컬에서 업로드 완료 콜백이 안 오면?

버셀 블롭이 로컬호스트를 부르지 못합니다. 문서가 터널을 안내합니다. 클라우드플레어 터널로 https 주소를 열고, 콜백 주소 환경 변수에 그 값을 넣습니다.


카카오에서 받은 아이폰 사진이 거절되면?

에이치이아이씨인 경우가 많습니다. 피엔지와 제이펙만 받는 폼은 그 파일을 거절합니다. 변환을 넣기 전에 제이펙으로 다시 보내 달라고 하는 편이 안전합니다.


파일 업로드매직 바이트확장자용량보안개발자Next.jsAPI백엔드프론트엔드버셀에스브이지

함께 보면 좋은 문제 해결

EXPLORE / Security

이어서 읽어보기

전체 토픽 둘러보기