[AWS] Lambda@Edge로 Presigned-URL 한 번만 사용하도록 강제하기

bluewhale·2022년 1월 15일
0

AWS

목록 보기
19/19

Presigned Url

AWS에서는 사용자가 statelessPresigned 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의 사용 여부를 검증할 수 있다. 구체적인 과정은 다음과 같다.

  1. CloudFront를 생성하고 S3 버킷을 Origin으로 지정하여, S3버킷에 대한 모든 요청이 CloudFront를 통하도록 한다. 추가적으로, OAI를 적용하면 보다 엄격한 버킷 관리가 가능하다.
  2. CloudFront에 사용자의 요청을 해쉬한 값을 검증하는 Lambda@EdgeViewer Request 이벤트에 연결한다. Viewer Request 이벤트에 적용되는 Lambda@Edge는 메모리 사이즈 128Mb, 실행 시간 5초, 라이브러리를 포함한 코드 압축 파일 크기 1Mb 등, 많은 제약 사항을 적용받는다.

특히, 코드 압축 파일 1Mb 제한으로 인해 데이터베이스 클라이언트 라이브러리들을 사용하는 것이 다소 제한되었다. 따라서, 이번 예제에서는 Redis 서버에 HTTP API를 요청하는 방식을 사용하였다. Redisupstash에서 서버리스 환경에서 제공하는 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',
};
  1. 배포를 마친 후, AWS Console에 접속하여 동일한 요청을 반복하여 전송하면, 두 번째 요청 이후로, 우리가 설정한 Forbidden 응답이 반환되는 것을 확인할 수 있다. 추가적으로 Redis 서버에 접속해보면, 해쉬된 키가 생성되어 있는 것을 확인할 수 있다.

첫 번째 응답

두 번째 응답

Redis 서버

Reference

profile
안녕하세요

0개의 댓글