Presigned Url로 파일 업로드할 때 확장자, 용량을 제한시키자

이플 (Dongsik Ga)·2024년 9월 23일
0

기술

목록 보기
5/11
post-thumbnail

저는 현재 어떤 창업팀에서 계약직 개발자로 근무하고 있습니다. 지금까지 친구들이랑 프로젝트를 해오다 실제로 계약을 통해 프로젝트를 진행하는건 처음인데요.
주변의 지인들이 항상 "돈 받고하는 프로젝트와 그렇지 않은 프로젝트는 생각보다 많이 다르다"라고 하는데, 이 차이를 점점 느끼게 되는 요즈음입니다. ㅎ


상황

프로젝트를 진행하다 S3에 파일을 업로드해서 처리해야하는 작업이 생겼습니다. S3에 파일을 업로드하는 방식은 많지만, 저는 Presigned Url을 발급받아서 프론트측에서 처리하는 방식을 선택했습니다.
Presigned Url
그 이유는 다음과 같습니다.

  • 파일 자체가 서버를 거쳐서 S3로 올라가는 상황을 막고 싶음
  • 여러 사용자가 동시에 요청했을 때 처리하기 수월함

반드시 이 방법을 써야한다! 하는 이유들은 아니지만, 다른 방법들 중 가장 효율적으로 파일 업로드 과정을 관리할 수 있는 방식인것 같아 선택하게 되었습니다.

고민이 되는 상황

이렇게 파일 업로드 기능을 구현하는 것은 간단하지만, 파일이 서버로 직접 넘어오는게 아니다보니 생기는 고민이 있었습니다. 파일을 무작정 업로드만 하는것이 아니라 기획에 확장자와 용량에 제한이 걸려있다는 것인데요. 명확한 요구사항은 다음과 같습니다.

  1. 업로드 되는 파일의 용량은 50MB를 넘으면 안된다.
  2. 파일의 확장자는 jpg, jpeg, png 한정한다.

presigned url을 여러번 써보면서 다음처럼 제한을 신경쓰면서 구현해보지는 않았는데요. 이번 기회에 해보고자 했습니다.

과정

Presigned Url을 사용하게 되면, 프론트에서 올리는 파일은 서버를 거치지 않습니다. 때문에 파일에 관련된 정보를 따로 서버에서 받지 않는한 서버는 프론트에서 올리는 파일이 뭔지 알 수 없습니다.
그래서 용량이나 확장자를 검증하기 위해서는 메타데이터를 Url을 요청할 때 같이 받아야하는데요. 저는 우선 이런 추가 데이터 없이 자동으로 필터링해줄 수 있는 방법이 있을지 생각해보았습니다.

1. S3의 Policy를 건들여보자

우선 가장 먼저 생각한 방식은 파일을 업로드할 때 S3 자체에서 검증할 수 있는 방법이 있을까 찾아보는 것이었습니다.

AWS의 S3는 버킷이라는 공간을 만들어서 버킷마다 관리할 수 있습니다. 따라서 이 버킷마다 접근 권한이나, 정책을 추가하고 수정할 수 있죠. 저는 여기서 해당 버킷의 정책을 수정하고자 했습니다.

S3 버킷의 권한

해당 권한 탭에 들어가게 되면 여러 정책을 수정할 수 있는데요. 이 중 버킷 자체의 정책을 수정했습니다.

S3 버킷 정책 편집

여기서 제가 설정한 정책은 다음과 같습니다.

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "AllowSpecificExtensions",
			"Effect": "Deny",
			"Principal": {
				"AWS": "arn:aws:iam::{Id}:user/S3Uploader"
			},
			"Action": "s3:PutObject",
			"NotResource": [
				"arn:aws:s3:::{bucket}/*.png",
				"arn:aws:s3:::{bucket}/*.jpg",
				"arn:aws:s3:::{bucket}/*.jpeg"
			]
		}
	]
}

해당 정책의 요소는 각각 다음의 의미를 가집니다. (By GPT)

Version: "2012-10-17"은 정책 언어의 버전을 나타냅니다. 이는 현재 AWS에서 사용하는 최신 정책 버전입니다.

Statement: 정책의 주요 요소로, 하나 이상의 개별 명령문을 포함합니다.

Sid: "AllowSpecificExtensions"는 이 명령문의 식별자입니다.

Effect: "Deny"는 이 명령문이 특정 작업을 거부함을 나타냅니다. (<-> "Allow")

Principal:
    이 정책이 적용되는 AWS 계정 또는 사용자를 지정합니다.
    특정 IAM 사용자 "S3Uploader"를 대상으로 합니다.

Action: "s3:PutObject"는 이 정책이 S3에 객체를 업로드하는 작업에 적용됨을 나타냅니다.

NotResource:
    이 배열은 정책이 적용되지 않을 리소스(여기서는 파일)를 지정합니다.
    ".png", ".jpg", ".jpeg" 확장자를 가진 파일들이 여기에 포함됩니다.

따라서 해당 정책 자체의 의미를 해석하면 다음과 같습니다.

S3Uploader라는 사용자가 해당 버킷에 접근할 때 PutObject(업로드)하는 작업에 대해서 모든것을 거부한다. 단, 업로드된 파일에 붙은 확장자가 png / jpg / jpeg인 경우는 예외로 한다.

따라서 png, jpg, jpeg의 확장자만을 업로드할 수 있도록 처리가 되었음을 알 수 있습니다.

이렇게 설정하고 테스트해보기 위해서 파일을 한번 직접 올려보고자 했습니다.

파일 업로드

이렇게 직접 올렸더니

업로드 성공

다음과 같이 파일 업로드에 성공했습니다.
그 이유는 현재 계정이 S3Uploader가 아니라 다른 User로 로그인한 상태이기 때문인데요. 실제 S3Uploader로는 IAM 로그인 계정이 설정되어있지 않기 때문에, 위 정책에서 약간의 수정을 거쳐야합니다.

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "AllowSpecificExtensions",
			"Effect": "Deny",
			"Principal": "*",
			"Action": "s3:PutObject",
			"NotResource": [
				"arn:aws:s3:::{bucket}/*.png",
				"arn:aws:s3:::{bucket}/*.jpg",
				"arn:aws:s3:::{bucket}/*.jpeg"
			]
		}
	]
}

Principal을 *로 설정하여, 모든 계정에 대해 적용되도록 할 수 있습니다. 이제 다시 동일하게 파일을 업로드해보면 아래와 같은 결과가 나옵니다.

업로드 일부 실패

결과를 보면 xlsx 파일은 정책에 설정된 확장자가 아니기 때문에 파일 및 폴더를 업로드할 권한이 없습니다. 라며 실패하는 것을 볼 수 있고, png 파일은 성공하는 것을 볼 수 있습니다.

이렇게 파일 확장자에 대해서 설정은 파일 업로드시에 확인하도록 처리할 수 있었습니다. 하지만 여기에서 문제사항은 다음과 같습니다.

  1. 서버에서 Presigned Url을 업로드할 때 고려사항에 관계없이 일단 url을 제공하게 됨.
  2. 정책상에서 파일 크기에 대한 제한을 두는 방식을 찾지 못했음.

따라서 정책상에서 제한을 두는 것은 그 이전 과정까지 도달하는 절차가 길기 때문에 메타데이터를 서버에서 받지 않아도 되지만 해당 방식만 사용하는 것은 부족하다는 생각이 들었습니다.

2. Spring 서버 내에서 처리해보자

그래서 presigned url을 발급받을 때 어느정도 유효성 검사를 진행하는게 효율적일 것 같다는 결론에 다다랐고 서버 내에서 처리하는 과정을 추가하게 되었습니다.

입력값

프론트에서 서버로 보내줘야하는 파일의 메타데이터는 다음과 같습니다.

  • 파일의 이름 + 확장자
  • 파일의 크기

구현상의 요구사항에 필요한 데이터만 입력받고, presigned url을 발급받기 전에 해당 데이터로 확장자와 파일 사이즈를 확인하고자 했습니다.

확장자 확인

presigned url을 발급받기 전에 확장자는 다음처럼 확인했습니다.

    public static final List<String> ALLOWED_EXTENSIONS = Arrays.asList(
       "png", "jpg", "jpeg"
    );
    
    private String getFileExtension(String contentName) {
        int lastDotIndex = contentName.lastIndexOf('.');
        if (lastDotIndex == -1 || lastDotIndex == contentName.length() - 1) {
            throw new GeneralException(ErrorStatus._CONTENT_NAME_INVALID);
        }
        String extension = contentName.substring(lastDotIndex+1).toLowerCase();

        if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
            throw new GeneralException(ErrorStatus._CONTENT_EXTENSION_INVALID);
        }

        return "." + extension;
    }
  1. 입력된 파일 이름(contentName)에서 확장자 부분이 있는지 체크
  2. 확장자 부분이 존재하지 않는다면 에러 발생
  3. 존재한다면 확장자 부분은 substring 진행
  4. 해당 확장자가 ALLOWED_EXTENSIONS에 존재하는지 확인
  5. 없으면 에러 발생
  6. .확장자의 형식으로 반환

이제 실제로 API를 실행해보면 아래처럼 실행했을 때

아래와 같은 결과를 얻을 수 있습니다. 확장자가 잘못되었음을 올바르게 캐치하고 있는 것이죠.

파일 크기 확인

@RequestParam 
@Positive 
@Max(value = MAX_FILE_SIZE, message = "파일 크기는 50MB를 초과할 수 없습니다.") 
Long uploadedContentSize

단순하게 Controller 부분에서 RequestParam을 불러올 때 validation을 체크하게 하였습니다.


이렇게 최대 파일 크기 또한 마찬가지로 제한이 걸렸음을 알 수 있습니다.

현재 위의 확장자 검증은 presigned Url을 발급받는 부분에서, 파일 크기 검증은 Controller 부분에서 검증을 진행하고있어 통일되지 않는 문제가 있습니다. 따라서 추후에는 해당 부분을 따로 모아서 한번에 검증을 진행할 계획입니다. (검증만 파일을 빼서 Controller에서 함수 실행을 하게 수정할 계획입니다.)

이렇게 파일의 메타데이터를 통해서 한번 검증을 했습니다.
하지만 또 아직 문제가 남아있습니다. 검증을 한 값과 Presigned Url에 올라오는 파일의 메타데이터가 동일함을 보장하지 못한다는 것입니다.

이를 해결하기 위해 Presigned Url을 생성할 때 헤더를 추가하는 방식을 적용했습니다.

Content Header 추가

Presigned Url을 발급받을 때 헤더에 정보를 입력해서 특정 데이터가 들어와야함을 강제할 수 있습니다.

일반적으로 어떤 요청을 보낼 때 헤더에는 아래처럼 컨텐츠의 정보가 함께 포함되어 전송됩니다.
컨텐츠 정보
이를 Presigned Url을 발급받을 때 업로드될 파일의 길이는 Content-Length로 강제할 수 있으며, Content-Type으로 파일의 확장자로 강제할 수 있습니다.

private GeneratePresignedUrlRequest getGeneratePresignedUrlRequest(String contentName,
        Long contentSize) {
        GeneratePresignedUrlRequest generatePresignedUrlRequest =
            new GeneratePresignedUrlRequest(bucket, contentName)
                .withMethod(HttpMethod.PUT)
                .withExpiration(getExpirationDate());

        generatePresignedUrlRequest.addRequestParameter(
            Headers.S3_CANNED_ACL,
            CannedAccessControlList.PublicRead.toString()
        );

        generatePresignedUrlRequest.putCustomRequestHeader(
            Headers.CONTENT_LENGTH,
            String.valueOf(contentSize)
        );
        
        generatePresignedUrlRequest.putCustomRequestHeader(
            Headers.CONTENT_TYPE,
            ALLOWED_CONTENT_TYPE.get(getFileExtension(contentName))
        );

        return generatePresignedUrlRequest;
    }

위 코드처럼 말이죠.
putCustomRequestHeader를 통해서 요청할 때 헤더의 값을 검증할 수 있습니다.


이렇게 요청을 했을 때 발급받은 Presigned Url로 요청을 보내보면

올바른 확장자로 요청을 했지만, 파일 크기가 요청한 크기와 다른경우

업로드시에 에러가 발생하게 됩니다. 파일의 사이즈는 바이트 단위로 정확해야한다는 것이죠.

이렇게 S3의 버킷 정책을 제한하고 입력되는 메타데이터의 값도 제한하며, Presigned Url을 통해 보내질 때의 메타데이터 또한 제한할 수 있었습니다.

결론

이 과정을 통해 해당 API를 통해서 파일을 업로드할 때 악용할 수 있는 여지는 거의 대부분 막은 것 같다고 생각합니다.

제가 직접 찾아보면서 적용하는 과정을 서술하였기에 다른 방법이 또 있을수도 있을 것이고, 잘못된 사용방식이나, 더 효율적인 방식도 있을지도 모르지만 나름대로 잘 구현한 것 같습니다. ㅎㅎ

혹시 수정해야할 부분이나, 기능 개선을 제안하고싶은 부분이 있다면 댓글 남겨주시면 감사하겠습니다.

profile
어제보다 더 나은 오늘의 나를 위해 노력하는 개발자입니다.

0개의 댓글

관련 채용 정보