(AWS) S3 Public Bucket 문제점

최건·2025년 8월 18일

S3 Public Bucket 사용 시 문제점 정리

1. 보안 위험

  • 누구나 접근 가능: URL만 알면 로그인/권한 검증 없이 모든 사람이 이미지에 접근할 수 있음.
  • 데이터 유출: 리뷰 이미지에 개인정보·민감한 데이터가 포함될 경우, 외부 유출 위험이 큼.
  • 링크 유출 회수 불가: 한 번 노출된 퍼블릭 URL은 삭제 전까지 계속 유효 → 접근 통제 불가능.

2. 비용/운영 문제

  • 무단 트래픽 발생: 다른 사이트에서 이미지 핫링킹이 가능 → 불필요한 S3 egress 비용 발생.
  • 트래픽 폭주 대응 어려움: 퍼블릭으로 열려 있으면 DDoS나 무단 다운로드에도 그대로 노출.
  • 캐시 제어 한계: 버킷에서 내려주는 응답 헤더 제어가 제한적이라, 캐시 정책 관리가 어렵다.

3. 관리 및 확장성 부족

  • 권한 제어 불가: 사용자별/권한별로 접근 허용 범위를 설정할 수 없음.
  • 접근 로그/감사 어려움: Presigned URL이나 CloudFront와 달리, “누가 접근했는지” 추적이 힘듦.
  • 운영 정책 위반 위험: 보안 규정을 지켜야 하는 서비스(예: 개인정보 처리, 기업 규정)에서는 Public Bucket이 문제 소지가 큼.

각 문제에 대한 해결 방법

1. 보안 위험

누구나 접근 가능

해결:

  • S3 모든 퍼블릭 액세스 차단(Block Public Access ON), ACL 비활성/Bucket owner enforced.
  • 공개 정책/퍼블릭 ACL 제거 → 직접 URL 접근 불가.
  • 오직 서버가 발급한 Presigned GET/PUT(짧은 TTL)으로만 접근.

데이터 유출(민감 이미지 포함 가능)

해결(완화):

  • Presigned 짧은 TTL(예: 3~5분) + 서버 인증/인가 통과 후 발급.
  • 키 설계: users/{uuid}/avatar/..., review/{date}/... 처럼 소유자/용도별 prefix.
  • prefix 검증: PATCH /users/me 등에서 전달된 profileImgKey내 prefix로 시작하는지 확인.
  • MIME 화이트리스트(예: image/jpeg/png/webp) + (선택) 업로드 후 HEAD 검사/AV 스캔/리사이즈.

링크 유출 회수 불가

해결:

  • Presigned는 만료되면 자동 무효 → 유출 창구를 시간으로 제한.
  • 즉시 차단이 필요하면 객체 삭제 또는 키 변경.
  • (규모↑ 시) CloudFront Signed Cookie/URL로 세션 단위 통제도 가능.

2. 비용/운영 문제

무단 트래픽(핫링킹)로 egress 비용 증가

해결:

  • 버킷 Private 전환 → 퍼블릭 핫링킹 원천 차단.
  • Presigned는 서버 통과 후 발급되므로 레이트 리밋/WAF로 제어 가능.

트래픽 폭주 대응 어려움

해결:

  • Presigned 발급 API에 레이트 리밋/토큰 버킷 적용.
  • 업로드/뷰 모두 파일 크기 제한동시 업로드 제한.

3. 관리 및 확장성 부족

권한 제어 불가

해결:

  • DB에는 key만 저장, URL 저장 금지.
  • 접근은 항상 서버 인증 → 소유자 확인 → Presigned 발급 순서.
  • IAM 최소 권한(경로 스코프) 부여:
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject","s3:PutObject","s3:DeleteObject"],
      "Resource": [
        "arn:aws:s3:::<BUCKET>/users/*",
        "arn:aws:s3:::<BUCKET>/review/*"
      ]
    }
    
  • 리소스 스코프형 API:
    POST /users/me/avatar/presignedPATCH /users/me/avatarGET /users/me/avatar.

접근 로그/감사 어려움

해결:

  • 애플리케이션 로그에 Presigned 발급 이벤트 기록(누가/어떤 key/TTL/IP).
  • S3 CloudTrail Data Events(GetObject/PutObject) 또는 Server Access Logs 활성화.
  • (CDN 사용 시) CloudFront 액세스 로그로 조회 트래킹.

운영 정책 위반 위험(보안 규정)

해결:

  • 기본 암호화(SSE-S3 또는 KMS) 활성화.
  • Lifecycle 정책으로 오래된 원본 IA/Glacier 이동.
  • 파일명/메타에 PII 포함 금지, 업로드 후 정책 기반 검사(필요 시).
  • 버킷 정책에 HTTPS 강제 Deny 추가:
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": ["arn:aws:s3:::<BUCKET>","arn:aws:s3:::<BUCKET>/*"],
      "Condition": {"Bool": {"aws:SecureTransport": "false"}}
    }
    

S3 Private 설정

  • 모든 퍼블릭 액세스 차단을 선택해서 만든다.

I AM 설정


코드 설정

1) DTO

// dto/profile.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
import { Transform } from 'class-transformer';

export class ProfileImagePresignedDTO {
  @ApiProperty({
    description: '사용자가 업로드하려는 원본 파일명',
    example: 'profile.png',
  })
  @IsString()
  @IsNotEmpty()
  originalName: string;

  @ApiProperty({
    description: '콘텐츠 타입(MIME). 허용되는 이미지 타입만 전달',
    enum: ['image/jpeg', 'image/png', 'image/webp'],
    example: 'image/png',
  })
  @IsString()
  @IsIn(['image/jpeg', 'image/png', 'image/webp'])
  contentType!: string;
}

export class PreSignedURLResponseDto {
  @ApiProperty({ description: 'DB에 저장할 S3 Key', example: 'users/<uuid>/avatar/20250818/xxxx.webp' })
  key!: string;

  @ApiProperty({ description: 'S3로 직접 PUT할 Presigned URL', example: 'https://...X-Amz-Expires=300...' })
  url!: string;

  @ApiProperty({ description: '유효기간(초)', example: 300 })
  expiresIn!: number;

  constructor(args: { key: string; url: string; expiresIn: number }) {
    Object.assign(this, args);
  }
}

export class PresignedUserProfileImgResponseDto {
  @ApiPropertyOptional({ description: '프로필 이미지(표시용) Presigned GET URL', example: 'https://...X-Amz-Expires=300...' })
  profileImg?: string | null;

  @ApiProperty({ description: '유효기간(초)', example: 300 })
  expiresIn!: number;

  constructor(profileImg: string | null, expiresIn: number) {
    this.profileImg = profileImg;
    this.expiresIn = expiresIn;
  }
}

export class UpdateProfileDto {
  @ApiPropertyOptional({ example: '소소한유저' })
  @IsOptional()
  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
  @Matches(/^[^\\x00-\\x1F\\x7F]*$/, { message: '제어 문자를 포함할 수 없습니다.' })
  @Matches(/^(?!.*(<script|<\\/script>|<iframe|on\\w+=|javascript:|eval\\()).*$/i, { message: '스크립트 또는 악성 코드를 포함할 수 없습니다.' })
  @Matches(/^[A-Za-z0-9가-힣 _-]{2,20}$/, { message: '닉네임은 2~20자' })
  nickName?: string | null;

  @ApiPropertyOptional({ description: '업로드 완료된 S3 Key', example: 'users/<uuid>/avatar/20250818/xxxx.webp' })
  @IsOptional()
  profileImgKey?: string | null;
}

2) AWS Service

// aws/aws.service.ts
import {
  S3Client,
  PutObjectCommand,
  GetObjectCommand,
  HeadObjectCommand,
  DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';

const ALLOWED_IMAGE_MIME = ['image/jpeg','image/png','image/webp']; // Common 폴더에서 관리

@Injectable()
export class AwsService {
  private readonly s3: S3Client;
  private readonly bucket: string;
  private readonly region: string;

  constructor(private readonly config: ConfigService) {
    this.region = this.config.get<string>('AWS_REGION')!;
    this.bucket = this.config.get<string>('AWS_BUCKET_NAME')!;
    this.s3 = new S3Client({
      region: this.region,
      credentials: {
        accessKeyId: this.config.get<string>('AWS_ACCESS_KEY')!,
        secretAccessKey: this.config.get<string>('AWS_SECRET_ACCESS_KEY')!,
      },
    });
  }

  /** 프로필 이미지 전용 Key 생성 */
  private generateReviewKey(originalName: string) {
    const safe = originalName.replace(/[^\w.\-]/g, '_');
    const yyyy = new Date().toISOString().slice(0, 10).replace(/-/g, '');
    return `review/${yyyy}/${Date.now()}-${safe}`;
  }

  /** PUT용 Presigned (키 지정) */
  async getPresignedPutUrlByKey(key: string, contentType: string, ttlSec = 300) {
    if (!ALLOWED_IMAGE_MIME.includes(contentType)) {
      throw new BadRequestException(`Unsupported contentType: ${contentType}`);
    }
    const cmd = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ContentType: contentType,
    });
    const url = await getSignedUrl(this.s3, cmd, { expiresIn: ttlSec });
    return { key, url, expiresIn: ttlSec };
  }

  /** GET용 Presigned (표시) */
  async getPresignedGetUrlByKey(key: string, ttlSec = 300, disposition: string = 'inline') {
    const cmd = new GetObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ResponseContentDisposition: disposition,
    });
    return getSignedUrl(this.s3, cmd, { expiresIn: ttlSec });
  }

  /** 존재 확인(선택) */
  async assertObjectExists(key: string) {
    try {
      await this.s3.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
    } catch {
      throw new NotFoundException('S3 object not found');
    }
  }

  async deleteByKey(key: string) {
    await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
  }
}

3) Users Service

// users/users.service.ts
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { AwsService } from '../aws/aws.service';
import { ProfileImagePresignRequestDto, PreSignedURLResponseDto, PresignedUserProfileImgResponseDto, UpdateProfileDto } from './dto/profile.dto';

@Injectable()
export class UsersService {
  constructor(
    private readonly aws: AwsService,
    // private readonly userRepository: UserRepository, // 실제 구현 주입
  ) {}

  /** 업로드용 Presigned 발급 */
  async createProfileImagePresignedUrl(profileImagePresignedDTO: ProfileImagePresignedDTO, uuid: string) {
    const { originalName, contentType } = profileImagePresignedDTO;
    const result = await this.awsService.getPresignedPutUrl(originalName, contentType);

    return new PreSignedURLResponsesDTO(result);
  }

  /** 프로필 정보 업데이트(닉네임/프로필 이미지 키) */
  async updateUserProfile(update: UpdateProfileDto, uuid: string) {
    const { nickName, profileImgKey } = update;

    if (nickName) {
      const exists = await this.userRepository.findUserByNickName(nickName);
      if (exists) throw new ConflictException('Nickname already exists.');
      const r = await this.userRepository.updateNickName(uuid, nickName);
      if (r.affected === 0) throw new NotFoundException('Fail update nickname');
    }

    if (profileImgKey) {
      // (선택) 실제 존재 확인
      await this.aws.assertObjectExists(profileImgKey);
      const updateUrl = await this.userRepository.updateUserPhotoUrl(uuid, profileImgKey);
      if (updateUrl.affected == 0) throw new NotFoundException('Fail update profileImg');
    }
  }

  /** 표시용 Presigned GET */
  async getPresignedURLUserProfileImg(uuid: string) {
    const user = await this.userRepository.findUserByUUID(uuid);
    if (!user) throw new NotFoundException('Not Found User');

    const expiresIn = 300;
    const url = await this.aws.getPresignedGetUrlByKey(user.photoUrl, expiresIn, 'inline');
    return new PresignedUserProfileImgResponseDto(url, expiresIn);
  }
}

4) Users Controller

// users/users.controller.ts
import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiExtraModels, ApiOkResponse, ApiOperation, getSchemaPath } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt.guard';
import { GetUUID } from '../common/decorators/get-uuid.decorator';
import { UsersService } from './users.service';
import { ProfileImagePresignRequestDto, PreSignedURLResponseDto, PresignedUserProfileImgResponseDto, UpdateProfileDto } from './dto/profile.dto';
import { SuccessResponseDTO, SuccessNoResultResponseDTO } from '../common/response/response.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Patch('/me')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth('JWT-auth')
  @ApiOperation({ summary: '프로필 정보 수정(닉네임/프로필 이미지 키)' })
  @ApiOkResponse({ description: '프로필 정보 수정 성공', type: SuccessNoResultResponseDTO })
  async updateProfile(@GetUUID() uuid: string, @Body() dto: UpdateProfileDto) {
    await this.users.updateUserProfile(dto, uuid);
    return new SuccessNoResultResponseDTO();
  }

  @Post('/me/avatar/presigned')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth('JWT-auth')
  @ApiExtraModels(SuccessResponseDTO, PreSignedURLResponseDto)
  @ApiOperation({ summary: '프로필 이미지 업로드용 Presigned(PUT) 발급' })
  @ApiOkResponse({
    description: 'Presigned URL 발급 성공',
    schema: {
      allOf: [
        { $ref: getSchemaPath(SuccessResponseDTO) },
        { properties: { result: { $ref: getSchemaPath(PreSignedURLResponseDto) } } },
      ],
    },
  })
  async generateProfileImagePresignedUrl(@GetUUID() uuid: string, @Body() dto: ProfileImagePresignRequestDto) {
    const result = await this.users.createProfileImagePresignedUrl(dto, uuid);
    return new SuccessResponseDTO(result);
  }

  @Get('/me/avatar')
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth('JWT-auth')
  @ApiExtraModels(SuccessResponseDTO, PresignedUserProfileImgResponseDto)
  @ApiOperation({ summary: '프로필 이미지 보기(Presigned GET URL 반환)' })
  @ApiOkResponse({
    description: 'Presigned URL 발급 성공',
    schema: {
      allOf: [
        { $ref: getSchemaPath(SuccessResponseDTO) },
        { properties: { result: { $ref: getSchemaPath(PresignedUserProfileImgResponseDto) } } },
      ],
    },
  })
  async getMyAvatar(@GetUUID() uuid: string) {
    const result = await this.users.getPresignedURLUserProfileImg(uuid);
    return new SuccessResponseDTO(result);
  }
}

5) Flow

  1. 업로드 준비 (Presigned 발급)
    • 클라 → POST /users/me/avatar/presigned (body: { originalName,contentType })
      • 파일 이름: originalName
      • 파일 타입: contentType
    • 서버:
      • generateProfileKey내 소유 prefix 키 생성
      • getPresignedPutUrlByKey(key, contentType, 300)으로 PUT Presigned URL 발급
    • 응답: { key, url, expiresIn }
  2. 브라우저에서 S3 업로드
    • 클라 → PUT url (헤더: Content-Type: <파일 MIME>, 바디: 파일)
    • 버킷 CORS에 AllowedOrigins=프론트, Methods=PUT,GET,HEAD가 설정되어 있어야 함
  3. 프로필에 반영 (DB 저장) (2번에서 Success후)
    • 클라 → PATCH /users/me (body: { profileImgKey, nickName? })
    • 서버:
      • prefix 검증: profileImgKeyusers/{uuid}/avatar/로 시작하는지 확인
      • (선택) HeadObject실존 확인
      • DB에 photoUrl = key 저장 (닉네임도 있으면 중복검사 후 업데이트)
      • (옵션) 기존 아바타 키가 있으면 백그라운드로 삭제
  4. 조회 (표시용 URL 받기)
    • 클라 → GET /users/me/avatar
    • 서버:
      • DB에서 내 photoUrl(=key) 조회
      • getPresignedGetUrlByKey(key, 300, 'inline')으로 GET Presigned URL 발급
    • 응답: { profileImg: <url>, expiresIn: 300 }
    • 클라는 <img src={profileImg}>로 렌더링 (만료되면 다시 호출)

profile
개발이 즐거운 백엔드 개발자

0개의 댓글