• S3 pre-signed URL 한번만 사용하기 :: 마이구미
    AWS 2020. 9. 27. 21:53
    반응형
    이 글은 pre-signed URL 에 관련된 내용을 다룬다.
    pre-signed url 은 만료시간 전까지 유효하다.
    이러한 흐름에서 나올 수 있는 문제점과 대처할 수 있는 방안이 글의 주제가 된다.
    전반적인 pre-signed url 을 다루는 글이 아닌, 생성한 pre-signed url 을 소멸시키는 방법을 다룬다.

    참고 링크
    https://medium.com/@laardee/uploading-objects-to-s3-using-one-time-presigned-urls-4374943f0801

    참고한 글에서 제공하는 예제 코드를 기반으로 설명한다.
    https://github.com/laardee/one-time-presigned-url

     

    글의 주제를 시작하기에 앞서, pre-signed URL 을 간단하게 설명한다.

    pre-signed URL 는 무엇이고, 왜 사용하는지, 어떤 경우에 쓰는지 확인해보자.

    pre-signed URL 은 무엇인가?

    그대로 해석하면 "미리 서명된 URL" 을 의미한다.

    결론적으로 사전에 이미 AWS S3 를 접근할 수 있는 권한을 가진 URL 을 의미한다.

    무슨 말인가?

    이미지 업로드 예를 들어보자.

    이미지 업로드와 같은 행위는 단순히 서버에게 정보를 요청하는 것이 아닌, 정보를 저장하는 행위이다.

    대부분 이와 같은 행위에는 이를 위한 권한이 필요한 것은 당연한 일이다.

     

    일반적인 경우를 보자.

     

     

    클라이언트가 서버에게 이미지 업로드를 요청하고, 서버는 이 요청을 처리하기 위해 권한을 검증한다.

    그 후, 권한이 올바르다면 이미지를 S3 와 같은 저장소에 업로드하게 된다.

     

    pre-signed URL 을 사용한 경우를 보자.

    pre-signed URL 은 위와 같이 서버에서 권한을 검증과 같은 행위를 마쳐서 나온 상태이다.

    즉, 이 URL 은 이미 S3에 접근할 수 있는 권한을 가진 상태로써, URL 을 그대로 사용하면 권한 상관없이 누구나 업로드를 할 수 있다.

    이미 권한을 가진 상태를 의미하기에, 서버를 거쳐서 권한 검증을 할 필요가 없다.

    클라이언트에서 바로 S3 에 요청을 보냄으로써, 서버의 부담을 줄일 수 있다.

     

     

    비슷한 사례에 대한 예제로써, 이전에 작성한 글을 읽어보면 좋다. (https://mygumi.tistory.com/362)

     

    이러한 내용만 보면 장점만 보일 것이다.

    이 하나의 URL 이 권한을 포함하고 있다는 것은 장점이자 단점이 될 수 있다.

     

    이미 권한이 검증되었기 때문에, 누구나 이 URL 만 가질 수 있다면 사용할 수 있다는 것이다.

    보안적으로 취약할 수 있어, 주의하여 사용해야한다.

    다행?이게도 pre-signed URL 은 만료시간이 존재한다.

    만료 시간이 지나게 되면, 이 URL 은 사용하려고 해도 사용할 수 없는 상태가 된다.

    하지만 반대로 얘기하면, 만료시간이 지나기전까지는 몇번이든 사용할 수 있게 된다.

     

    이것이 이 글의 주제가 되는 것이다.

    우리는 pre-signed URL 을 한번 사용하고 나면, 만료시간이 지나기 전에도 사용할 수 없게 만드는 것이 목표이다.

     


     

     

    이에 대한 대안책을 위한 방법은 여러가지 존재할 것이다.

    여기서 다루는 방식은 Lambda@Edge 를 사용하는 것이다.

    Lambda@Edge 에 대한 기본적인 이해를 위해 관련 글을 읽어보면 좋다.

    https://mygumi.tistory.com/377

     

    어떠한 방식이든 pre-signed URL 생성과 검증 두 단계가 존재해야한다.

    각각의 두 단계를 Lambda@Edge 의 viewer-request 를 활용한다.

     

    1. Pre-signed URL "생성"을 위한 viewer-request
    2. 생성된 Pre-signed URL 을 PUT 요청하는 과정에서 "검증"을 위한 viewer-request

     

    pre-signed URL 를 생성하는 시점을 첫번째 viewer-request 를 의미한다.

    그리고 생성된 URL 을 통해 업로드를 요청하는 시점에 두번째 viewer-request 를 의미한다.

     

    각 단계의 흐름을 이미지를 기반으로 알아보자.

     

     

    1. 클라이언트는 pre-signed URL 을 얻기 위한 요청을 보낸다.

    2. CloudFront 는 관련 Lambda 함수를 트리거한다.

    3. pre-signed URL 을 생성한다.

    4. 생성된 pre-signed URL 을 기반으로 한 hash 값을 통해 "유효" 를 의미하는 파일을 S3 에 저장한다.

     

    const signedUrl = s3.getSignedUrl('putObject', {
        Bucket: bucket,
        Key: `upload/${uuidv4()}`,
      });
    
    const { path } = url.parse(signedUrl);
    const host = headers.host[0].value;
    
    const hash = crypto
        .createHash('sha256')
        .update(path)
        .digest('hex');
    
    await s3
        .putObject({
          Bucket: bucket,
          Key: `signatures/valid/${hash}`,
          Body: JSON.stringify({ created: Date.now() }),
          ContentType: 'application/json',
          ContentEncoding: 'gzip',
        })
        .promise();

     

    5, 6. pre-signed URL 을 반환한다.

     

    위 흐름이 끝난 후에는, 클라이언트는 pre-signed URL 을 가지고 있고, 업로드를 하기 위한 준비가 끝난 상태이다.

    이후 업로드 요청을 보내게 되면 다음과 같다.

     

     

    1. 클라이언트는 pre-signed URL 을 가지고 업로드를 위해 PUT 요청을 보낸다.

    2. CloudFront 는 CloudFront 는 관련 Lambda 함수를 트리거한다.

    3. 요청된 URL 을 다시 한번 암호화하여 "생성" 단계에서 S3 에 저장한 hash 값과 비교한다. (동일한 암호화 로직)

     

    const hash = crypto
        .createHash('sha256')
        .update(`${uri}?${querystring}`)
        .digest('hex');
    
    const [validSignature, expiredSignature] = await Promise.all([
        headSignature({ type: 'valid', hash }),
        headSignature({ type: 'expired', hash }),
    ]);
    
    if (!validSignature || expiredSignature) {
        return forbiddenResponse;
    }

     

    4. 유효한 pre-signed URL 이라고 판단됨으로써, 다음에 사용하지 못하도록 "만료" 를 의미하는 파일을 S3 에 저장한다.

    5. 추가적으로 생성한 "만료" 파일의 버전과 새로 가져온 "만료" 파일의 가장 최신버전까지 검증한다.

     

    const { VersionId: version } = await s3
      .putObject({
        Bucket: bucket,
        Key: `signatures/expired/${hash}`,
        Body: JSON.stringify({ created: Date.now() }),
        ContentType: 'application/json',
        ContentEncoding: 'gzip',
      })
      .promise();
    
    const { Versions: versions } = await s3
      .listObjectVersions({
        Bucket: bucket,
        Prefix: `signatures/expired/${hash}`,
      })
      .promise();
    
    const sortedVersions = versions.concat().sort((a, b) => {
      return a.LastModified > b.LastModified;
    });
    
    // if there are more that one version of the index file and current is not the initial version
    if (sortedVersions.length > 1 && sortedVersions[0].VersionId !== version) {
      return forbiddenResponse;
    }

     

    당연히 경우에 따라, 필요없을 수도 있고 해도그만 안해도 그만일 수 있다.

    pre-signed URL 을 활용하는 경우에 꼭 필수적으로 존재해야하는 기능은 아니라고 본다.

    개인적으로는 이러한 문제점이 있을 수 있고, 문제가 된다면 이런 대안이 있다 정도로 마무리하려고 한다.

     

    마지막으로 주제는 조금 다를 수 있으나...개인적으로 "우선 심플하게 접근해보자" 를 노력하려고 한다.

    이 주제에서 "S3 정책에서 create는 허용하고 update 를 막으면 되지 않나?" 라는 질문을 받았다.

    결과적으로는 S3 정책은 create 와 update 모두 putObject 로 선언하기 때문에 불가능하다.

    하지만 이런 생각을 처음에 하지 못했다는 게 많은 부분을 다시 생각하게 만들어서 좋았다.

    반응형

    댓글

Designed by Tistory.