목차
- "Presigned URL은 안전하다"는 말이 맞지 않는 이유
- Python boto3로 생성할 때 빠뜨리기 쉬운 부분
- Node.js AWS SDK v3 — 함수 시그니처가 바뀌었다
- 만료 시간을 짧게 잡는 것만으로 부족한 이유
- Conditions로 막아야 하는 것들 (POST policy)
- 운영에서 빠지는 함정 — 시계, 리전, CORS
사용자 업로드 파일을 서버를 거치지 않고 S3로 바로 보내려고 AWS S3 Presigned URL 생성 방식을 도입했다. 문서대로 generate_presigned_url 호출하고 만료 시간 5분 주고 끝낸 게 첫 버전이다. 일주일 뒤 S3 버킷에서 정체 모를 .exe 파일이 발견됐다. 키 확장자는 .jpg였다.
반면, 다들 "Presigned URL은 만료만 짧게 주면 안전하다"고 한다. 막상 운영해보면 그 말은 절반만 맞다. 나머지 절반이 어떤 식으로 빠지는지가 이 글의 주제다.
"Presigned URL은 안전하다"는 말이 맞지 않는 이유
AWS 공식 문서는 Presigned URL을 "임시 권한 부여(temporary credentials)"로 표현한다. 임시라는 단어 때문에 짧게만 발급하면 안전하다는 인상을 받기 쉽다. 정확하게는 URL을 발급한 IAM 자격증명의 권한이 그대로 상속된다. PutObject 권한이 있는 IAM 사용자가 URL을 만들면, URL을 받은 누구나 그 권한 범위만큼 행동할 수 있다.
여기서 두 가지 함정이 생긴다. 첫째, 권한 범위가 IAM 정책 그대로라는 점이다. 버킷 전체에 PutObject 권한이 있다면 발급된 URL이 임의 키로 덮어쓰기를 할 수 있는 경우가 있다. SDK 기본 호출은 객체 키를 URL에 포함해 잠그지만, POST policy 형태로 쓸 때 키 prefix 검증을 빼먹으면 다른 사용자 영역까지 침범하는 시나리오가 생긴다.
둘째, 만료가 지나도 URL이 "회수"되지 않는다. 회수 API가 존재하지 않는다. 만료 검증은 S3가 요청을 받은 시각과 서명 시각을 비교하는 방식이라 URL 자체를 사후에 무효화할 방법이 없다. 발급한 순간부터 만료 시점까지의 윈도우 동안에는 누구든 그 URL로 동일한 동작을 반복할 수 있다.
5분짜리 URL이라 안전하다고 생각했다가, 그 5분 동안 무엇이든 가능하다는 사실을 뒤늦게 깨닫는 경우가 많다.
Python boto3로 생성할 때 빠뜨리기 쉬운 부분
그래서, 가장 자주 보이는 코드는 이렇게 생겼다.
import boto3
s3 = boto3.client('s3', region_name='ap-northeast-2')
url = s3.generate_presigned_url(
ClientMethod='put_object',
Params={
'Bucket': 'my-upload-bucket',
'Key': 'uploads/file.jpg',
},
ExpiresIn=300, # 5분
)
이처럼, 이대로 URL을 클라이언트에 넘기면 업로드 자체는 동작한다. 문제는 클라이언트가 PUT 요청에 임의의 Content-Type을 실어 보내도 막을 방법이 없다는 점이다. .jpg 키로 발급한 URL에 application/x-msdownload를 실어 .exe 바이너리를 업로드해도 S3는 그대로 받는다. 키 확장자는 단순 문자열일 뿐 검증 대상이 아니다.
따라서, 이를 막으려면 서명 단계에서 Content-Type을 고정해야 한다.
url = s3.generate_presigned_url(
ClientMethod='put_object',
Params={
'Bucket': 'my-upload-bucket',
'Key': 'uploads/file.jpg',
'ContentType': 'image/jpeg', # 서명에 포함됨
},
ExpiresIn=300,
)
결국, 이렇게 발급한 URL은 클라이언트가 정확히 Content-Type: image/jpeg 헤더를 보내야만 200을 받는다. 다른 값을 보내면 S3가 SignatureDoesNotMatch로 거절한다. 헤더 한 줄을 서명에 묶는 것만으로 임의 파일 형식 우회가 닫힌다.
업로드 크기를 막는 옵션이 PUT에는 없다
실제로, put_object 기반 Presigned URL에는 "최대 파일 크기" 옵션 자체가 없다. 클라이언트가 10GB를 올려도 막을 방법이 없다는 의미다. 크기 제한을 걸려면 generate_presigned_post로 바꿔 Conditions 필드를 써야 한다. 이 차이는 뒤에서 다시 본다.
다운로드 URL은 또 다른 이야기다
다운로드용 Presigned URL은 get_object로 만든다. 여기서는 ResponseContentDisposition을 서명에 넣어 다운로드 파일명을 강제하는 패턴이 유용하다. 사용자가 업로드한 원본 키와 다운로드 표시 이름을 분리하는 용도다.
Node.js AWS SDK v3 — 함수 시그니처가 바뀌었다
즉, v2를 쓰던 코드에서 v3로 옮길 때 헷갈리는 부분이 많다. v2의 s3.getSignedUrl('putObject', params) 호출은 v3에서 다음처럼 바뀌었다.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3 = new S3Client({ region: 'ap-northeast-2' })
const command = new PutObjectCommand({
Bucket: 'my-upload-bucket',
Key: 'uploads/file.jpg',
ContentType: 'image/jpeg',
})
const url = await getSignedUrl(s3, command, { expiresIn: 300 })
한편, Command 객체를 만들고 getSignedUrl로 서명한다. 직관적으로 보이지만, Command 인자에 넣은 필드 중 어떤 것이 서명에 포함되고 어떤 것이 헤더로 들어가는지 SDK 문서에 명확히 나와 있지 않다. 실무에서는 발급된 URL을 한 번 curl로 찍어보고 SignedHeaders 값을 확인하는 게 빠르다.
curl -v -X PUT "$URL" \
-H "Content-Type: image/jpeg" \
--data-binary @sample.jpg
그런데, 응답이 403일 때 <SignedHeaders> 부분을 보면 host;content-type 같은 형태로 어떤 헤더가 서명에 묶였는지 보인다. 클라이언트는 이 헤더들을 정확히 같은 값으로 보내야 한다. 대소문자나 charset 옵션 한 글자만 달라도 서명이 깨진다.
만료 시간을 짧게 잡는 것만으로 부족한 이유
만료 시간을 5분으로 줄이면 안전하다는 통념이 가장 흔하다. 실제로는 다음 위협 모델을 함께 봐야 한다.
| 위협 | 만료 시간 단축으로 막힘? | 추가 대책 |
|---|---|---|
| URL 유출 후 재사용 | 부분적으로 막힘 | One-time token 또는 멱등 키 |
| 발급 직후 즉시 악용 | 못 막음 | Content-Type/Conditions 서명 |
| IAM 권한 escalation | 못 막음 | 최소 권한 정책 + Bucket policy |
| 시계 차이로 즉시 만료 | 부작용 발생 | NTP 동기화 확인 |
| 파일 크기 폭주 | 못 막음 | POST policy + content-length-range |
예를 들어, 짧은 만료 시간은 "발견될 때까지의 노출 시간"을 줄여줄 뿐 행동 자체를 제약하지 않는다. 5분짜리 URL이 유출되면 5분 안에 무엇이든 가능하다.
특히 모바일 클라이언트에서 시계가 1~2분씩 어긋난 디바이스가 섞여 있다. 너무 짧은 만료(예: 60초)는 정상 사용자도 SignatureDoesNotMatch나 RequestTimeTooSkewed로 튕긴다. 보안과 사용성을 둘 다 챙기려면 5~15분 범위에서 시작하고, 정말 민감한 작업만 1~2분으로 잡는 편이 안정적으로 보인다.
Conditions로 막아야 하는 것들 (POST policy)
put_object 기반 URL로는 파일 크기, 키 prefix, 메타데이터를 묶어 검증할 수 없다. 이런 제약을 걸려면 generate_presigned_post로 바꿔야 한다.
post = s3.generate_presigned_post(
Bucket='my-upload-bucket',
Key='uploads/${filename}',
Fields={
'Content-Type': 'image/jpeg',
'x-amz-meta-uploader': 'user-1234',
},
Conditions=[
['starts-with', '$key', 'uploads/'],
['content-length-range', 0, 5 * 1024 * 1024], # 5MB 제한
{'Content-Type': 'image/jpeg'},
],
ExpiresIn=300,
)
# post['url']과 post['fields']를 클라이언트에 함께 전달
게다가, 이 방식은 클라이언트가 multipart/form-data로 업로드해야 한다는 제약이 따라온다. SPA에서 fetch로 단순 PUT을 쓰던 코드를 FormData 기반으로 바꿔야 한다. 번거롭지만 크기 제한이 필요하다면 사실상 유일한 정공법이다. PUT 방식은 클라이언트가 헤더로 협상하는 구조라 서버가 사전에 강제할 수 있는 항목이 적다.
그래서, :::tip
브라우저 직접 업로드 시나리오라면 generate_presigned_post + content-length-range 조합이 거의 필수로 보인다. PUT 방식만 쓰면 클라이언트가 무한히 큰 파일을 올려도 막을 수 없다.
:::
그러나, 자세한 Conditions 옵션은 AWS 공식 문서 — Creating a POST Policy에 정리되어 있다. starts-with, eq, content-length-range 정도만 알아도 대부분 시나리오가 커버된다.
운영에서 빠지는 함정 — 시계, 리전, CORS
마지막으로 운영하면서 자주 만나는 함정 세 가지다.
첫째, 서버 시계가 어긋나면 SigV4 서명이 깨진다. EC2나 EKS는 보통 chrony가 돌지만, 컨테이너에서 호스트 시계와 차이가 생기는 경우가 있다. RequestTimeTooSkewed 에러가 뜨면 우선 date -u로 서버 시각부터 확인하는 게 빠르다. 허용 오차는 약 15분이지만, 짧은 만료 URL에서는 그보다 훨씬 좁은 윈도우에서 깨진다.
둘째, 리전 mismatch다. ap-northeast-2 버킷에 us-east-1 클라이언트 설정으로 URL을 만들면 서명은 통과해도 307 redirect가 발생한다. PUT body 재전송 과정에서 일부 HTTP 클라이언트가 body를 다시 보내지 않아 0바이트 파일이 만들어지는 사례가 보고된다. 클라이언트의 region 옵션과 버킷의 실제 리전을 일치시켜야 한다.
그러나, 셋째, CORS다. 브라우저에서 직접 PUT을 보내려면 버킷의 CORS 정책에 PUT 메서드와 사용할 도메인이 등록되어 있어야 한다. 빠뜨리면 OPTIONS preflight에서 막혀서 "URL은 정상인데 업로드가 안 된다"는 모호한 증상이 나온다.
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST", "GET"],
"AllowedOrigins": ["https://your-app.example.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
CORS 설정은 boto3 generate_presigned_url 문서에는 안 나온다. S3 콘솔의 버킷 → 권한 탭에서 별도로 잡아야 한다.
결국, —
물론, 당장 적용할 액션 3가지:
- 모든
generate_presigned_url호출에ContentType파라미터를 추가한다. 한 줄 추가로 임의 파일 형식 우회가 닫힌다. - 브라우저 직접 업로드 경로는
generate_presigned_post+content-length-range로 전환한다. PUT 단독 방식은 크기 제한이 불가능하다. - CloudWatch에
RequestTimeTooSkewed와SignatureDoesNotMatch메트릭 알람을 걸어 시계·서명 이슈를 조기에 감지한다.
다만 이렇게 운영하더라도 업로드 직후 바이러스 스캐닝과 메타데이터 검증 레이어는 별도로 필요하다는 점은 아직 풀어가는 중이다.
관련 글
- AWS SDK 재시도 전략 Jitter — boto3·Java로 Throttling 완전 해결 – Lambda에서 S3 SlowDown을 만나고 알게 된 AWS SDK 재시도 전략과 Jitter. boto3 standard·adaptiv…
- AWS Lambda API Gateway 연동 실전 — CORS, 커스텀 도메인, 콜드 스타트 – HTTP API와 REST API 중 뭘 골라야 할지, CORS 헤더는 어디서 설정해야 하는지, 커스텀 도메인 연결의 인증서 발급 순서까지…
- AWS EC2 Auto Scaling 설정 — Target Tracking·Step Scaling 비교와 알람 연동 – 트래픽 급증에 ASG가 늦게 반응하는 원인은 정책 선택만의 문제가 아니다. 워밍업, 알람 평가 시간, 스케일인 보호까지 묶어서 살펴본다.