AWS에서는 사용자가 stateless
한 Presigned URL
을 생성하여 객체에 대한 접근 권한을 임시로 허용할 수 있다. Presigned Url
을 사용하면 서버에서 파일 업로드 부담이 사라지고, 클라이언트에서 직접 S3 버킷으로 파일을 업로드할 수 있어, Next.js
+ Vercel
과 같이 serverless
환경에서 백엔드를 호스팅 할 때 유용하게 활용될 수 있다.
Presigned Url
은 쿼리 스트링에 접근 대상 객체, 정책 등을 정의하고 이를 대칭 암호화한 서명(Signature)을 포함한다. Presigned Url
로 요청을 받은 S3 서비스에서는 서명을 확인하여 페이로드가 위변조 되었는지 여부를 확인한다. 이러한 대칭키 기반의 암호화로 페이로드의 위변조를 확인하는 방식은 JWT
와 유사하다.
{
"url": "www.my-bucket.s3.ap-northeast-2.amazonaws.com/my-bucket"
"query": {
"bucket": "my-bucket",
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": "DBHJSSDNFERBDFJSDF/20211122/ap-northeast-2/s3/aws4_request",
"X-Amz-Date": "20211122T072329Z",
"key": "config.json"
"Policy": "NSKJDBFSIERskhberjwhebfsdf0sdnjfjksdfsdnw409nty7q8weyrtdn8932549q87236-awe870n-4n67809-q34sdnfjskdfwesndjfksnrkjsefs5tbfq7iwoe",
"X-Amz-Signature": "swernjkdfgld;fg,d;fg1dnhjkernjksfdnjksber3730699b9daf5a76aceffc9140a58"
}
}
Presigned Url
자체는 stateless
하기 때문에, 만료 시간(X-Amz-Date
) 이전에는 여러 번 사용할 수 있다. 이러한 경우, 클라이언트에게 발급한 Presigned Url
이 악용될 수 있는 여지가 있다. 예를 들어, 악의적인 사용자가 크기가 큰 파일에 대한 GET
많은 요청을 발생시키면 높은 egress 비용이 부과될 수도 있다.
이러한 경우, CloudFront
+ Lambda@Edge
를 활용하여 발급된 Presigned Url
의 사용 여부를 검증할 수 있다. 구체적인 과정은 다음과 같다.
CloudFront
를 생성하고 S3
버킷을 Origin
으로 지정하여, S3
버킷에 대한 모든 요청이 CloudFront
를 통하도록 한다. 추가적으로, OAI를 적용하면 보다 엄격한 버킷 관리가 가능하다.CloudFront
에 사용자의 요청을 해쉬한 값을 검증하는 Lambda@Edge
를 Viewer Request
이벤트에 연결한다. Viewer Request
이벤트에 적용되는 Lambda@Edge
는 메모리 사이즈 128Mb, 실행 시간 5초, 라이브러리를 포함한 코드 압축 파일 크기 1Mb 등, 많은 제약 사항을 적용받는다.특히, 코드 압축 파일 1Mb 제한으로 인해 데이터베이스 클라이언트 라이브러리들을 사용하는 것이 다소 제한되었다. 따라서, 이번 예제에서는 Redis
서버에 HTTP API
를 요청하는 방식을 사용하였다. Redis
는 upstash
에서 서버리스 환경에서 제공하는 Redis
서버를 사용하였다. Upstash
에서는 자체적으로 REST API
프로토콜을 지원하여서 가벼운 검증 파이프라인을 만들기에 유용하였다.
Lambda@Edge
에서는 사용자의 요청을 해쉬한 값을 키로 사용하여 Redis
서버에 동일한 키가 있는지 여부를 검증함으로써 중복 사용을 막았다.
// main.js
'use strict';
const crypto = require('crypto');
const https = require('https');
const config = require('../config.json');
module.exports.handler = async (event) => {
const { request } = event.Records[0].cf;
const { uri, querystring } = request;
const hash = crypto.createHash('sha256').update(`${uri}?${querystring}`).digest('hex');
const getOpt = {
host: config.redisHost,
token: config.redisToken,
key: hash,
};
const getRes = await get(getOpt);
if (getRes.statusCode === 200 && getRes.body.result !== null) {
return forbiddenResponse; // presigned url used already
}
const setOpt = {
...getOpt,
val: 'x',
expire: 3600, // 1-hour
};
const setRes = await set(setOpt);
if (setRes.statusCode !== 200 || setRes.body.result !== 'OK') {
return forbiddenResponse; // failed to set usage record to redis
}
return request;
};
function get(opt) {
const { host, token, key } = opt;
const url = `https://${host}/get/${key}`;
const options = {
headers: {
Authorization: `Bearer ${token}`,
},
};
return new Promise((resolve, reject) => {
const req = https.get(url, options);
req.on('response', (res) => {
res.setEncoding('utf8');
res.on('data', function (chunk) {
resolve({ statusCode: res.statusCode, body: JSON.parse(chunk) });
});
});
req.on('error', (err) => {
reject(err);
});
});
}
function set(opt) {
const { host, token, key, val, expire } = opt;
const url = `https://${host}/set/${key}/${val}/EX/${expire}`;
const options = {
headers: {
Authorization: `Bearer ${token}`,
},
};
return new Promise((resolve, reject) => {
const req = https.get(url, options);
req.on('response', (res) => {
res.setEncoding('utf8');
res.on('data', function (chunk) {
resolve({ statusCode: res.statusCode, body: JSON.parse(chunk) });
});
});
req.on('error', (err) => {
reject(err);
});
});
}
const forbiddenResponse = {
status: '403',
statusDescription: 'Forbidden',
headers: {
'content-type': [
{
key: 'Content-Type',
value: 'text/plain',
},
],
'content-encoding': [
{
key: 'Content-Encoding',
value: 'UTF-8',
},
],
},
body: 'Forbidden',
};
AWS Console
에 접속하여 동일한 요청을 반복하여 전송하면, 두 번째 요청 이후로, 우리가 설정한 Forbidden
응답이 반환되는 것을 확인할 수 있다. 추가적으로 Redis
서버에 접속해보면, 해쉬된 키가 생성되어 있는 것을 확인할 수 있다.첫 번째 응답
두 번째 응답
Redis 서버