S3 Presigned Url 도입하기 (+Java로 파일 크기 제한[content-length] 추가)

Hansu Park·2023년 9월 3일
9
post-thumbnail

S3 PreSigned Url이 무엇인지, 어떻게 활용하는지에 대해 알아보자.

S3 PreSigned Url이란?

일반적으로 S3를 사용하는 웹 어플리케이션들은 아래와 같은 방식으로 파일을 업로드, 다운로드하였다.

(multipart/form-data를 통한 파일 업로드 프로세스)

이 때 1번 과정에서 용량이 큰 파일을 보내게 된다면 이는 성능에 악영향을 미친다. (서버의 자원을 많이 사용하기도 하고, HTTP에서 큰 파일을 여러 번의 작은 요청으로 분할하여 보내게 되어 성능이 좋지않다.) (자세히는 스프링, 이미지 처리하는 MutlifileResolver은 기본적으로 10240byte까지 메모리에, 그 이후부터 임시 파일에 저장한다. 이 때 메모리에 저장되는 10MB는 엄청 크다..)

이러한 성능 문제를 해결하기 위해 S3에서 제공하는 방법이 PreSigned Url이다.

PreSigned Url은 말 그대로 미리 서명한 Url이라는 뜻이다. S3의 소유자가 미리 특정 권한(파일 업로드, 파일 다운로드 등)에 대해 서명을 해준 뒤 사용자에게 해당 Url을 제공해준다. 사용자는 서명된 권한을 사용할 수 있다. 소유자가 여러 정보에 대해 미리 서명을 해두고, 사용자가 추후에 전달받아 사용한다는 점에서 수표와 비슷하다.

PreSigned Url을 사용하게 된다면, 클라이언트가 서버에 거치지 않고 파일을 저장소에 직접 업로드할 수 있어, 서버의 자원을 절약할 수 있다.

동작 과정은 아래와 같다.

(PreSigned Url을 통한 파일 업로드 프로세스)

위와 같이 동작하며, 6번 이후 동작과정은 기존과 동일하게, API의 요청에 여러 데이터와 함께 파일 저장 경로를 넘겨주면 된다.

사용 방법 (클라이언트)

  1. 사용자가 파일을 선택한다.
  2. 선택한 파일에서 파일 정보{파일 명, 컨텐츠 타입(파일 타입), 컨텐츠 길이(파일 용량)}을 획득한다.
  3. 입력한 파일 정보는 서버에서 허용가능한지 판단하며, 이와 다른 정보를 가진 파일을 업로드 하려한다면 실패한다.
  4. 파일 정보를 기반으로 API에 요청하여 파일 경로들을 획득한다.
  • presigendUrl: 파일을 업로드할 URL
  • fileUrl: 업로드하게 될 파일이 저장될 URL
  1. 경로(preSignedUrl)에 파일을 업로드(전송)한다.
function uploadFile() {
        const fileInput = document.getElementById("fileInput");
        const file = fileInput.files[0];

        if (!file) {
          alert("파일을 선택하세요.");
          return;
        }

        const presignedUrlInput = document.getElementById("presignedUrl");
        const presignedUrl = presignedUrlInput.value;

        if (!presignedUrl) {
          alert("프리사인드 URL을 입력하세요.");
          return;
        }

        // 파일 업로드를 위한 PUT 요청 생성
        fetch(presignedUrl, {
          method: "PUT",
          body: file,
        })
          .then((response) => {
            console.log(response);
            if (response.ok) {
              alert("파일 업로드 성공!");
            } else {
              alert("파일 업로드 실패.");
            }
          })
          .catch((error) => {
            console.error("에러:", error);
            alert("파일 업로드 중 에러 발생.");
          });
      }

(test.html 코드)

(test.html의 모습)

(파일 업로드에 성공한 모습)

  1. fileUrl을 통해 저장된 이미지를 확인한다.

(저장된 모습)

설정 방법

S3는 IAM의 권한, 버킷 설정, 버킷과 버킷의 특정 지점(액세스 포인트)에 대한 정책, ACL(액세스 권한)을 통해 여러 보안 설정을 할 수 있다. PreSigned Url을 설정하기 위해서 이를 수정해야한다.

IAM

PreSigned Url을 만들기 위한 소유자의 권한을 설정한다. 이는 기존 버킷 소유자가 S3FullAccess로 되어있어 수정사항이 없었다.

생성이 필요하다면 참고: https://velog.io/@chrkb1569/AWS-S3-적용하기-IAM-프로젝트-설정#iam-설정

버킷 설정

모두 열어준다.

1, 2번은 ACLs을 설정이 되어있더라도 퍼블릭 액세스를 제한할 수 있는 옵션이다. 새로운 권한에 대해서 차단할 것인지 기존 권한에 대해서 차단할 것인지만 다르다.

3, 4번은 Bucket Policy, Access Point Policy 설정이 되어있더라도 퍼블릭 액세스를 차단할 수 있는 옵션이다. 3번은 새로운 정책에 대해서만 적용되며 ,4번은 public, cross account(기존 소유자, 권한 받은 사용자가 아닌 사용자들)에 대해 적용된다.

⇒ ACL, Policy로 설정할 것이기 때문에 ACL, Policy 이후에 2차적으로 제한하는 해당 설정들은 모두 열어줘서 퍼블릭 액세스를 허용하도록 한다.

버킷 정책 설정

위와 같이 설정해준다.

⇒ s3 리소스에 대해 액션인 S3 Put, Get을 허용해준다.

설정 절차

Edit을 누르면 나오는 위 창에서 Generator을 클릭한 후

위와 같이 작성한 후

Generate Policy를 눌러 json을 확인 후 복사한다.

이후 Resource에 적어주는데,

이와 같이 Resource에 /* 을 추가해줍니다.

  • 내용 보기
    {
        "Version": "2012-10-17",
        "Id": "Policy1693643266715",
        "Statement": [
            {
                "Sid": "Stmt1693643264665",
                "Effect": "Allow",
                "Principal": "*",
                "Action": [
                    "s3:GetObject",
                    "s3:PutObject"
                ],
                "Resource": "arn:aws:s3:::koin-temp/*"
            }
        ]
    }

CORS 설정

  • 내용 보기
    [
        {
            "AllowedHeaders": [
                "*"
            ],
            "AllowedMethods": [
                "HEAD",
                "GET",
                "PUT",
                "POST"
            ],
            "AllowedOrigins": [
                "*"
            ],
            "ExposeHeaders": []
        }
    ]

위와 같이 설정, 해당 URL에 접근하였을 때 CORS를 방지한다.

객체 소유권 설정

위와 같이 설정한다.

Disable 설정을 한다면, 사용자와 무관하게 공통된 정책으로 처리된다.

ACLs를 허용해주어야, 버킷 소유자의 읽기, 쓰기 권한을 사용할 수 있어 PreSigned Url이 유효해진다.

코드 설정

  1. S3 객체 생성

    1. iam의 access key, secret key를 이용하여 생성
    2. region도 설정해주어야 한다.

    (찾아보니 base64 관련 에러가 생기는 경우도 있다고 한다.)

  2. GeneratePreSignedUrlRequest 생성

    1. PUT 메서드
    2. 만료일자 설정
  3. ACL 옵션 추가

    1. canned-acl : public-read를 설정한다.

      미리 모든 사용자가 해당 파일을 읽을 수 있도록 서명한다.

이 절차를 마친다면, 기본적인 presigned url을 사용할 수 있다.

파일 크기, 타입 제한

기존 요구사항인 파일의 크기와 타입을 제한하는 방법에 대해 알아보자.

찾아본 결과 POST POLICY를 이용하는 방법과 Content 헤더들을 추가하는 방법이 있다.

POST POLICY를 이용

(23.09.14 기준) JAVA SDK는 POST POLICY를 간단하게 적용하는 방법은 제공하지 않는 듯 하다. (참고)

그렇다면 일일이 만들어줘야 하는데

  • JSON 형식으로 정책 생성
  • BASE64를 이용한 인코딩
  • HMAC을 이용하여 서명
    을 적용해야 해서 무척 번거롭다. 링크예 예제가 나와있긴 하나, 굉장히 적용하기 힘들어 보인다.

이러한 번거로움으로 인해 Content 헤더들을 추가하는 방식을 택했다.

CONTENT 헤더들을 추가

API를 통해 사전에 클라이언트로부터 Content-Length, Content-Type 값을 입력받고, PreSigned Urll을 만들 때 헤더에 이들에 대한 정보도 추가한다. 이러한 헤더를 추가함으로써 일치하지 않는 파일을 업로드하려 한다면 제한할 수 있다. 또한 Content-Length, Content-Type 헤더에 대해 서명함으로써, 공격자가 헤더의 값을 임의로 수정할 수 없도록 한다. (서명이 안 이루어진다는 글도 있어서 별도로 언급했다.)

이렇게 입력된 헤더(Content-Length, Content-Type)와 다른 파일(파일 크기가 다르거나, 타입이 다른 파일)을 PreSigned Url로 업로드하려 한다면 실패한다.

private void enrichContentMetaData(GeneratePresignedUrlRequest generatePresignedUrlRequest, UploadFileMetaData uploadFileMetaData) {
   generatePresignedUrlRequest.putCustomRequestHeader(Headers.CONTENT_TYPE,uploadFileMetaData.getContentType());
   generatePresignedUrlRequest.putCustomRequestHeader(Headers.CONTENT_LENGTH,uploadFileMetaData.getContentLength().toString());
}

코드 상으로는 위와 같이 putCustomRequestHeader 에 Headers.CONTENT_TYPE, Headers.CONTENT_LENGTH를 넣는다.

기타
노션에서도 s3을 이용하여 업로드한다고 알고있어 이를 확인해보았는데 유사하게 사용하는 것을 확인했다.

요청

{
   "bucket":"secure",
   "name":"Untitled",
   "contentType":"image/png",
   "record":{
      "table":"block",
      "id":"c5d31dc5-742b-4054-a367-2e554de83df4",
      "spaceId":"84d795b0-981a-45d2-bd59-b49a66fbde38"
   },
   "supportExtraHeaders":true,
   "contentLength":41835
}

응답

{
   "putHeaders":[
      {
         "name":"Content-Length",
         "value":"41835"
      },
      {
         "name":"x-amz-tagging",
         "value":"..."
      }
   ],
   "signedGetUrl":"https://file.notion.so/f/f/84d795b0-981a-45d2-bd59-b49a66fbde38/408f3451-dd4b-43d7-b5e3-0b4e019b4e0c/Untitled.png?id=c5d31dc5-742b-4054-a367-2e554de83df4&table=block&spaceId=84d795b0-981a-45d2-bd59-b49a66fbde38&expirationTimestamp=1694563200000&signature=i-l_6fqXfi9jZhVqSytfebG1ojonQCIDkLAUlI5nPPo",
   "signedPutUrl":"https://prod-files-secure.s3.us-west-2.amazonaws.com/84d795b0-981a-45d2-bd59-b49a66fbde38/408f3451-dd4b-43d7-b5e3-0b4e019b4e0c/Untitled.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIAT73L2G45EIPT3X45%2F20230911%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20230911T075029Z&X-Amz-Expires=86400&X-Amz-Signature=0ffba674d5dc135211b7214a777266ad2bc33e04d69b82a1dca6eadf9f5dbe81&X-Amz-SignedHeaders=content-length%3Bhost%3Bx-amz-tagging&x-id=PutObject",
   "url":"https://prod-files-secure.s3.us-west-2.amazonaws.com/84d795b0-981a-45d2-bd59-b49a66fbde38/408f3451-dd4b-43d7-b5e3-0b4e019b4e0c/Untitled.png"
}

확인해보면,

  • 요청시 파일 타입 크기를 요청한다.
  • 응답시 get url(조회 용)과 put url(업로드 용)을 제공한다.
  • put url에는 content-length에 대한 서명이 들어가 있다.

URL 제공 방법 고려사항

URL을 제공하는 방법으로 CDN, 정적 호스팅, 신규 주소 발급, S3 URL 이용이 있다. 이들에 대해 비교해보자.

CDN

CDN을 고려해봤다. (사실은 다운로드를 위해 이미 적용되어 있었다.) 사용시의 이점은 버킷 명을 숨길 수 있다는 것 외에 크게 없어 보였다. 왜냐하면 이 서비스는 다운로드-캐시를 위한 서비스이기 때문이다. 이러한 이점은 크지 않고 오히려 클라이언트-서버 사이에 중간 지점이 생겨 비효율적이며 요금 부과에도 영향을 주기 때문에 사용하지 않기로 했다.
(다운로드 용도로 사용하던 CDN이 아닌 다른 URL로 PreSigned Url을 제공하기로 하였다.)

CDN에서 GET 뿐만이 아닌 PUT, POST 등도 지원하기에 업로드 시에도 이점이 있을 줄 알았으나, 그건 아닌 듯 싶다.
(CDN의 정의 자체가 파일 다운로드에 캐싱을 적용할 수 있다는 점이기 때문이다.)

참고

{
    "Version": "2008-10-17",
    "Id": "PolicyForCloudFrontPrivateContent",
    "Statement": [
        {
            "Sid": "1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity {ID}"
            },
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource": "arn:aws:s3:::{bucket_name}/*"
        }
    ]
}

(여담이지만, 이런 식으로 S3 자체 URL 접근을 막고 CDN(CloudFront)을 통해서만 접근하게 할 수 있다. 참고)

정적 호스팅

무료로 버킷 명을 숨길 수 있는 장점이 있다. 하지만 HTTPS를 사용할 수 없다.

신규 주소 발급

HTTPS를 사용할 수 있으나, 생성 리소스가 소모되고, 비용이 부과된다.

S3의 URL 사용

버킷 명을 숨길 수 없으나, HTTPS 사용이 가능하고 별도 리소스가 들지 않는다.
다양한 고려사항이 있겠지만, 버킷 명을 숨겨지 않아도 된다면 마지막 방법이 제일 괜찮아 보였고, 버킷 명을 꼭 숨겨야하는지에 대해 고민해보았다.

버킷 명을 숨겨야 하는가?

상황에 따라 다를 수 있겠지만 일반적으로는 아니다. 숨기지 않았을 때의 문제는 무차별 대입 공격에 의해 저장소의 여러 파일들이 노출된다는 점이다. 하지만 이는 액세스 권한을 설정하여 방지할 수 있다.

또한, 버킷 명은 조직 내의 여러 사람들이 알고있기에 숨기기도 쉽지 않다.

마지막으로, 코인 서비스는 현재 public 이미지들만 제공하고 있기에 숨길 이유가 없다.

(참고: https://security.stackexchange.com/questions/227140/is-it-insecure-to-expose-private-bucket-names-through-signed-url )

결론

이러한 결론 끝에 S3 버킷 자체 URL로 URL을 제공하기로 결정했다.

참고

0개의 댓글