S3 업로드 보안: 격리·검사·게시 파이프라인
S3 직접 업로드 파일을 격리하고 정확한 객체 버전·체크섬·파일 형식을 검사한 뒤 통과한 파일만 게시하는 구조를 설명한다.
이 글의 목차업로드와 게시 사이에 검역소를 둔다7
5분짜리 Presigned URL을 적용한 작업은 브라우저의 장기 AWS 키와 애플리케이션 서버의 파일 중계를 없앴다.
다음 과제는 S3에 저장됐다는 사실과 다른 사용자에게 게시해도 된다는 판단을 분리하는 일이었다.
Amazon S3는 파일을 객체 단위로 보관하는 AWS의 객체 스토리지다.
버킷은 객체를 담는 최상위 저장 공간이고, 객체 키는 버킷 안에서 파일을 찾는 경로 같은 식별자다.
Presigned URL은 서버가 특정 S3 작업을 제한된 시간 동안 허용하도록 서명한 주소다.
브라우저는 이 주소로 S3에 직접 업로드할 수 있지만, 주소가 유효하다는 사실은 파일 내용이 안전하다는 뜻이 아니다.
예를 들어 프로필 이미지 업로드 주소로 확장자만 .jpg인 실행 파일이나, 이미지 처리기가 감당하지 못할 만큼 큰 파일이 올라올 수 있다.
S3는 요청 권한과 전송된 바이트를 보관하지만 그 파일을 서비스에 공개해도 되는지까지 판단하지 않는다.
직접 업로드 이후에는 파일을 격리하고, 정확한 객체 버전을 검사하고, 통과한 파일만 게시하는 단계가 필요했다.
이 글은 그 빈틈을 막기 위해 정리한 운영 설계다.
아직 배포 결과가 아닌 만큼 성공 조건은 권한·이벤트·자동 시험으로 확인할 수 있게 만들었다.
업로드와 게시 사이에 검역소를 둔다
파일이 S3에 저장됐다는 사실과 사용자에게 보여 줘도 된다는 판단을 분리한다.
전체 흐름은 다음과 같다.
- 서버가 로그인 사용자와 tenant(회사나 팀 같은 조직 단위) 권한, 할당량을 확인한 뒤 예측하기 어려운 객체 키를 직접 발급한다.
- Presigned URL은 공개 권한이 없는 격리 버킷에만
PUT할 수 있다. - 브라우저가 파일을 직접 업로드한다.
- S3
ObjectCreated이벤트가 검증 워커를 깨운다. - 워커가 객체 버전, 크기, 체크섬과 파일 시그니처를 확인한다.
- 검사를 통과한 정확한 버전만 게시 버킷으로 복사한다.
- 애플리케이션은 게시 키를 저장하고 격리 객체의 수명은 Lifecycle 정책으로 제한한다.
PUT은 지정한 객체 키에 바이트를 저장하는 HTTP 요청 방식이고, ObjectCreated는 S3에 객체가 만들어졌음을 알리는 이벤트다.
Lifecycle은 일정 기간이 지난 객체나 오래된 버전을 자동으로 만료시키는 S3 보관 규칙이다.
클라이언트가 bucket, 다른 tenant의 경로, 기존 객체 키나 최종 게시 키를 정하게 두지 않는다.
발급 서버가 업로드 요청 레코드와 고유 키를 묶고, 서명 역할에는 그 격리 경로의 PutObject만 허용한다.
ListBucket, GetObject, DeleteObject, ACL 변경과 게시 버킷 쓰기는 주지 않는다.
게시 버킷에는 브라우저가 직접 쓸 수 없고 검증 워커만 쓸 수 있게 해야, 검사가 끝난 파일이 나중에 덮어써지는 일을 막을 수 있다.
격리 버킷은 S3 Block Public Access를 켜고 Object Ownership을 Bucket owner enforced로 두며, 일반 사용자와 브라우저에는 읽기·목록·삭제 권한도 주지 않는 편이 기본값으로 안전하다.
정책을 적용한 뒤에는 콘솔 화면만 믿지 않고 익명 요청과 일반 사용자 자격으로 격리 객체가 403인지 확인한다.
별도 버킷 대신 같은 버킷의 quarantine/과 published/ prefix를 사용할 수도 있지만, prefix 이름만으로 접근이 제한되지는 않는다.
AWS의 접근 권한을 정하는 IAM·버킷 정책과 CDN 정책에서 두 경로의 읽기·쓰기 주체를 나누고, S3 이벤트 알림은 quarantine/ prefix의 ObjectCreated만 워커에 전달하도록 필터링해야 한다.
버킷 전체의 생성 이벤트를 구독한 채 워커가 같은 버킷의 게시 경로로 복사하면, 그 복사도 새 생성 이벤트가 되어 워커를 반복 호출할 수 있다.
그래서 이 글의 기본안은 격리 버킷과 게시 버킷을 분리하는 방식이다.
| 구성 | 장점 | 주의할 점 | 어울리는 상황 |
|---|---|---|---|
| 격리·게시 버킷 분리 | 권한과 이벤트 경계가 눈에 보인다 | 버킷 정책과 수명주기를 두 곳에서 관리한다 | 외부 사용자의 파일을 자동 처리·공개하는 서비스 |
| 한 버킷의 prefix 분리 | 리소스 수가 적고 기존 구조에 붙이기 쉽다 | IAM·CDN·이벤트 필터를 잘못 쓰면 경계가 무너진다 | 내부 파일처럼 위험이 낮고 정책을 충분히 시험할 수 있는 경우 |
IAM(Identity and Access Management)은 AWS 리소스에서 누가 어떤 작업을 할 수 있는지 정하는 권한 체계다.
CDN(Content Delivery Network)은 사용자와 가까운 위치에서 게시 파일을 전달하는 배포 계층이다.
이벤트의 키와 중복부터 정규화한다
S3 이벤트의 객체 키는 URL 인코딩되어 온다.
예를 들어 공백은 +로 표현된다.
이벤트는 최소 한 번 전달되도록 설계되어 있어 같은 사건이 중복될 수 있고, 발생 순서대로 도착한다는 보장도 없다.
아래 코드는 이 조건을 드러내기 위한 설계 예시다.
실제 SDK 호출과 재시도 설정은 생략했지만, 한 알림의 모든 레코드를 처리하고 테스트 이벤트를 일반 객체 이벤트와 구분하는 경계는 남겼다.
async function handleS3Notification(event: S3Event | S3TestEvent) {
if ("Event" in event && event.Event === "s3:TestEvent") {
return;
}
await Promise.all(event.Records.map(verifyUploadedObject));
}
async function verifyUploadedObject(record: S3EventRecord) {
assertAllowedSource(record);
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(
record.s3.object.key.replace(/\+/g, " "),
);
const versionId = record.s3.object.versionId;
const sequencer = record.s3.object.sequencer;
const eventIdentity = JSON.stringify({
bucket,
key,
versionId: versionId ?? null,
eventName: record.eventName,
sequencer,
});
if (!(await claimLatestEvent({
eventIdentity,
bucket,
key,
versionId,
eventName: record.eventName,
sequencer,
}))) {
return;
}
const request = await loadUploadRequest({ bucket, key });
const object = await readObjectAttributes({
bucket,
key,
versionId,
});
assertAllowedSize(object.size);
assertAllowedFileSignature(
await readHeaderBytes({ bucket, key, versionId }),
);
if (request.expectedChecksum) {
assertChecksumMatches(
request.expectedChecksum,
object.checksum,
);
}
await copyVerifiedVersionConditionally({
source: { bucket, key, versionId, eTag: object.eTag },
destination: {
bucket: publishBucket,
key: request.publishKey,
},
});
const publishedObject = await readPublishedAttributes({
bucket: publishBucket,
key: request.publishKey,
});
assertPublishedObjectMatches(object, publishedObject);
await markPublishedConditionally({
uploadId: request.id,
bucket: publishBucket,
destinationKey: request.publishKey,
});
}실제 S3 이벤트에는 Records 배열이 있고 객체 키는 record.s3.object.key에 들어 있다.
assertAllowedSource는 이벤트 버전의 주 버전, AWS 리전, 격리 버킷, 입력 prefix와 허용한 ObjectCreated 유형을 확인하는 자리다.
S3가 알림 구성을 확인할 때 보내는 s3:TestEvent는 일반 알림과 구조가 다르므로 먼저 분리한다.
중복 판정에는 버킷, 디코딩한 키, versionId, eventName과 sequencer를 함께 사용한다.
문자열을 구분자 하나로 이어 붙이면 키 자체에 같은 문자가 들어 있을 때 경계가 모호해질 수 있어, 예시에서는 필드가 분리된 JSON 문자열로 식별자를 만들었다.
같은 알림이 다시 전달되면 이 값도 같으므로 이미 소유권을 얻은 실행 뒤의 중복 처리를 건너뛸 수 있다.
같은 키에서 발생한 이벤트의 상대 순서를 비교할 때는 sequencer를 사용할 수 있다.
길이가 다른 값은 짧은 쪽 앞에 0을 채운 뒤 사전식으로 비교해야 한다.
서로 다른 객체 키의 sequencer는 전체 시간 순서를 뜻하지 않으므로 비교하지 않는다.
예시의 claimLatestEvent는 같은 이벤트의 중복을 건너뛰고, 같은 키에 이미 더 큰 sequencer가 저장돼 있다면 늦게 도착한 과거 이벤트를 무시하는 원자적 상태 변경을 뜻한다.
상태는 PENDING → SCANNING → PUBLISHED 또는 REJECTED로만 움직이고, 같은 전이는 한 실행만 성공하게 해야 동시 워커가 같은 파일을 두 번 게시하지 않는다.
파서 오류, 권한 오류, 시간 초과와 지원하지 않는 형식은 모두 게시하지 않는 쪽으로 실패해야 한다.
Lambda의 비동기 재시도와 실패 destination 또는 DLQ(처리하지 못한 이벤트를 따로 보관하는 큐)를 구성하고, 재시도가 끝난 뒤에도 PENDING이나 SCANNING에 머문 업로드를 찾아 다시 확인하는 작업과 경보를 둔다.
“검사기가 실패했으니 일단 게시”는 이 구조에서 허용하지 않는다.
S3 체크섬과 애플리케이션 기대값은 다르다
현재 S3는 업로더나 SDK가 다른 알고리즘을 지정하지 않으면 기본적으로 CRC64NVME 체크섬을 계산해 객체와 함께 저장한다.
체크섬을 요청에 제공하면 S3가 받은 바이트로 다시 계산해 일치하지 않는 업로드를 거부할 수 있다.
이 기능은 전송·저장된 바이트의 무결성을 확인하는 데 유용하다.
애플리케이션이 의도한 파일인지까지 증명하지는 않는다.
악의적인 클라이언트는 다른 파일과 그 파일에 맞는 체크섬을 함께 보낼 수 있다.
승인 조건으로 체크섬을 쓰려면 서버가 신뢰할 수 있는 경로에서 기대값을 알고 있어야 한다.
그런 값이 없다면 S3 체크섬은 전송 무결성 확인에 사용하고, 크기와 파일 시그니처, 실제 디코딩 같은 콘텐츠 검사를 별도로 수행해야 한다.
표준 ObjectCreated 이벤트에는 객체 크기와 ETag, 버저닝 사용 시 versionId가 있지만 체크섬은 기본 필드가 아니다.
필요한 체크섬은 HeadObject 또는 GetObjectAttributes로 다시 조회한다.
ETag도 항상 MD5 체크섬은 아니다.
멀티파트 업로드와 일부 암호화 방식에서는 계산 방식이 달라진다.
ETag 하나만 파일 무결성의 근거로 사용하지 않는다.
검사한 버전과 게시한 버전을 묶는다
검사가 끝난 뒤 같은 격리 키가 덮어써지면 검사한 파일과 게시한 파일이 달라질 수 있다.
버저닝을 켰다면 이벤트의 versionId를 상태 레코드에 저장하고, 속성 조회, 파일 시그니처 읽기, 복사 대상을 모두 그 버전으로 고정한다.
버저닝을 쓰지 않는다면 업로드마다 고유한 키를 사용하고, 예시의 copyVerifiedVersionConditionally처럼 읽기와 복사 직전에 ETag와 크기를 다시 확인해 달라졌다면 게시하지 않아야 한다.
ETag는 객체가 바뀌었는지 비교할 때 쓸 수 있는 응답값이지만, 아래에서 설명하듯 항상 파일의 MD5 체크섬은 아니다.
게시 대상은 If-None-Match: * 같은 조건부 쓰기로 기존 키 덮어쓰기를 막고 409와 412를 명시적으로 처리한다.
CopyObject는 HTTP 200 OK 응답 본문 안에 오류가 들어올 수 있으므로 SDK가 응답을 끝까지 읽고 파싱한 결과를 사용한다.
복사 뒤에는 게시 객체의 버전, 크기와 체크섬을 다시 조회해 검사한 객체와 일치할 때만 상태를 PUBLISHED로 바꾼다.
검사 항목은 파일 종류에 맞춰 달라진다.
- 선언한 MIME 유형과 실제 파일 시그니처가 일치하는가
- 허용한 픽셀 수와 파일 크기를 넘지 않는가
- 이미지 디코더가 파일을 정상적으로 읽는가
- SVG처럼 실행 가능한 내용을 포함할 수 있는 형식은 별도 정책으로 다루는가
- 공개 전에 재인코딩이나 악성 파일 검사가 필요한가
Presigned PUT은 Presigned POST 정책의 content-length-range를 그대로 사용할 수 없다.
업로드 뒤 크기를 검사하면 이미 저장 공간과 네트워크를 사용한 뒤다.
발급 전에 사용자가 밝힌 크기로 빠르게 거를 수는 있지만 그 값도 신뢰할 수 없으므로 사후 검사를 대신하지 못한다.
운영에서 완료로 판단할 기준
이 글에서 해결한 핵심은 “S3에 저장됨”과 “다른 사용자에게 게시해도 됨”을 권한과 상태로 분리한 것이다.
다음 시험을 모두 통과해야 그 경계가 실제로 작동한다고 판단할 수 있다.
| 시험 | 완료 조건 |
|---|---|
| 정상 파일 | 이벤트가 가리킨 정확한 버전이 한 번만 게시된다 |
| MIME만 위장한 파일·깨진 파일 | 시그니처 또는 디코딩 검사에서 거부되고 공개되지 않는다 |
| 크기 초과·지원하지 않는 형식 | REJECTED가 되거나 정책대로 만료되고 게시 키가 생기지 않는다 |
| 중복·역순 이벤트 | 최신 객체 버전만 한 번 처리되고 중복 부작용이 없다 |
| 게시 버킷 복사 | 검증 워커를 다시 호출하지 않는다 |
| 같은 키 덮어쓰기 경쟁 | 검사한 버전과 다른 바이트는 게시되지 않는다 |
| 워커 시간 초과·권한 거부 | 게시되지 않고 실패 큐와 경보에서 다시 찾을 수 있다 |
| 익명·일반 사용자 읽기 | 격리 객체는 403, 게시가 끝난 객체만 정책에 따라 읽힌다 |
운영에서는 업로드 완료부터 게시까지 걸린 시간의 p95, 거부 사유별 건수, 중복 이벤트를 건너뛴 수, 실패 큐 깊이, 오래 남은 PENDING/SCANNING 수를 기록한다.
실패한 격리 객체는 Lifecycle로 정리하되 버저닝을 켰다면 현재 버전과 이전 버전 만료, 완료되지 않은 멀티파트 업로드 정리를 각각 설정한다.
사용자가 올린 파일을 다른 사용자가 내려받거나 이미지 처리기·문서 파서가 읽는 서비스라면 이 경계를 둔다.
반대로 신뢰된 내부 작업자가 올리고 외부 게시나 자동 처리가 없는 파일이라면 운영 복잡도가 이득보다 클 수 있다.
이 설계가 주는 이득은 막연한 “보안 강화”가 아니라, 검사에 실패했거나 검사하지 못한 객체가 공개 경로에 도달하지 않는다는 확인 가능한 조건이다.
결론
Presigned URL은 브라우저가 S3에 직접 업로드할 수 있게 만들 뿐, 그 파일을 신뢰할 근거까지 주지 않는다.
외부 사용자의 파일을 서비스가 다시 보여 주거나 처리한다면 저장과 게시 사이에 별도의 상태가 필요하다.
격리 객체의 정확한 버전을 고정하고, 크기·체크섬·실제 파일 형식을 검사하며, 통과한 버전만 게시 영역으로 복사해야 한다.
이 글은 배포 완료 성과가 아니라 운영 설계와 검증 조건을 다룬다.
구현 뒤에는 정상 파일뿐 아니라 중복 이벤트, 역순 이벤트, 덮어쓰기 경쟁과 검사 워커 실패까지 시험해야 설계가 실제 경계가 된다.
업로드 성공은 파일을 받았다는 뜻이고, 게시 성공은 검사한 바로 그 파일을 공개해도 된다는 판단까지 끝났다는 뜻이다.