Rails Active Storage Direct Upload를 안전하게 설계하기

Direct Upload의 신뢰 경계

Active Storage Direct Upload는 큰 파일이 Rails 서버를 경유하지 않아 효율적이지만, 기본 endpoint를 그대로 공개하면 인증되지 않은 사용자가 blob 레코드를 만들거나 허용하지 않은 파일의 업로드 URL을 받을 수 있다. 저장소가 사설망의 NAS라고 해도 인터넷에 노출된 presigned URL은 권한 그 자체다. URL의 유효 시간이 짧다는 이유만으로 요청자를 신뢰해서는 안 된다.

안전한 흐름은 관리자 인증, metadata 검증, blob 생성, S3 PUT, Post 첨부 순서다. 공개 댓글 작성자나 익명 방문자는 업로드 URL 발급 경로에 접근할 수 없어야 한다. Rails가 발급 전에 검사하고 Post 모델이 첨부 후 다시 검사하는 이중 방어가 필요하다.

업로드 URL 발급 전에 차단하기

Rails 기본 Direct Upload route와 같은 경로를 인증된 controller가 처리하도록 구성한다. controller는 요청 JSON의 filename보다 byte size와 content type을 우선 검사한다. 허용 형식이 JPEG, PNG, WebP이고 최대 크기가 10MB라면 blob을 만들기 전에 거절해야 미사용 레코드가 쌓이지 않는다.

class Admin::DirectUploadsController < ActiveStorage::DirectUploadsController
  before_action :require_authentication

  private

  def blob_args
    args = super
    raise ActionController::BadRequest unless
      %w[image/jpeg image/png image/webp].include?(args[:content_type]) &&
      args[:byte_size].to_i.between?(1, 10.megabytes)

    args
  end
end

예시는 핵심 경계를 보여 주기 위한 것이다. 실제 구현에서는 애플리케이션의 기존 인증 concern과 오류 응답 방식을 재사용한다. 클라이언트가 보낸 MIME만 신뢰하지 말고, Post 저장 시 첨부된 blob의 content type과 크기를 모델 validation으로 다시 확인한다.

편집 화면에서 기존 첨부 보존하기

has_many_attached setter는 새 목록을 기존 목록에 덧붙이는 API가 아니라 전달한 목록으로 교체하는 방식으로 동작할 수 있다. 편집 폼이 새 이미지 signed ID만 제출하면 본문에 이미 사용 중인 이미지가 조용히 분리된다. 따라서 편집 화면은 기존 attachment의 signed ID와 새 업로드의 signed ID를 모두 hidden field로 제출해야 한다.

JavaScript는 업로드가 완료된 뒤에만 signed ID를 form에 추가하고, 실패한 항목에는 명확한 재시도 상태를 표시한다. 본문에 삽입할 URL은 직접 만든 bucket URL보다 Rails proxy URL을 사용하면 저장소 구조를 외부에 덜 노출하고 variant 처리를 통일할 수 있다. 다만 proxy signed URL은 SECRET_KEY_BASE에 의존하므로 운영 secret을 임의로 교체하지 않는다.

미첨부 blob과 삭제 수명주기

Direct Upload는 S3 PUT까지 성공했지만 Post 저장이 취소되는 경우를 피할 수 없다. 이때 unattached blob이 남으므로 정리 정책이 필요하다. 너무 짧은 기준을 사용하면 사용자가 긴 글을 작성하는 도중 이미지를 삭제할 수 있다. 예를 들어 24시간 이상 첨부되지 않은 blob만 대상으로 하고, 운영 전에 dry-run으로 개수와 key를 확인한다.

cutoff = 24.hours.ago

ActiveStorage::Blob
  .unattached
  .where(created_at: ...cutoff)
  .find_each(&:purge_later)

Post 삭제 시에는 원본과 WebP variant가 함께 제거되는지 확인한다. purge job 실패는 화면의 삭제 성공과 별도로 감시해야 한다. queue가 멈추면 DB 레코드는 사라졌지만 NAS 객체는 계속 남을 수 있다.

테스트해야 할 전체 경로

controller test만으로 S3 CORS 문제를 발견할 수 없고, 브라우저 test만으로 모델 validation 우회를 모두 확인하기 어렵다. 두 층의 검증을 나눈다.

  • 비로그인 요청은 blob을 생성하지 않고 거절된다.
  • 허용하지 않은 MIME과 10MB 초과 파일은 presign 전에 거절된다.
  • 조작된 signed ID를 Post에 첨부해도 모델 validation이 실패한다.
  • 편집 후에도 기존 본문 attachment가 유지된다.
  • 실제 운영 origin에서 OPTIONS, PUT, ETag 확인이 성공한다.
  • 실패하거나 취소한 업로드는 정리 기준 이후 purge된다.
  • Post 삭제 뒤 원본, variant, attachment가 남지 않는다.

마지막으로 업로드 테스트에 실제 credential 값을 출력하지 않는다. 로그에는 blob key 전체 대신 요청 ID와 실패 단계만 남기는 것이 좋다. Direct Upload의 완료 조건은 “파일이 올라갔다”가 아니라 인증, 제한, 편집 보존, 브라우저 CORS, 삭제 수명주기가 모두 검증된 상태다.

댓글

아직 댓글이 없습니다.

댓글 남기기