<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Malware Scanning on jyukki's Blog</title><link>https://jyukki.com/tags/malware-scanning/</link><description>Recent content in Malware Scanning on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Fri, 22 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/malware-scanning/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Object Upload Quarantine과 비동기 스캔, 파일 업로드를 안전하게 공개하는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-object-upload-quarantine-scanning-playbook/</link><pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-object-upload-quarantine-scanning-playbook/</guid><description>Presigned URL과 Object Storage 기반 파일 업로드에서 업로드 완료와 공개 가능 상태를 분리하고, 격리 버킷·비동기 스캔·상태 전이·운영 지표로 안전하게 공개하는 기준을 정리합니다.</description><content:encoded><![CDATA[<p>파일 업로드는 구현이 쉬워 보이지만 운영 사고가 자주 나는 영역입니다. Presigned URL을 발급하고 클라이언트가 S3 같은 Object Storage에 직접 올리게 만들면 앱 서버 대역폭은 아낄 수 있습니다. 하지만 이 구조를 &ldquo;업로드가 끝나면 곧바로 공개&quot;로 설계하면 위험합니다. 사용자는 이미지라고 올렸지만 실제로는 HTML, 스크립트, 압축 폭탄, 악성 문서, 실행 파일일 수 있습니다. Content-Type 헤더와 확장자는 사용자가 정할 수 있고, 업로드 완료 이벤트는 중복되거나 늦게 올 수 있으며, 스캔 워커가 멈춘 동안 파일이 CDN에 먼저 노출될 수도 있습니다.</p>
<p>그래서 실무에서는 direct upload와 direct publish를 분리해야 합니다. 파일은 먼저 격리 영역에 들어오고, 서버가 메타데이터와 상태를 관리하며, 스캔과 정책 검사를 통과한 뒤에만 서비스 경로로 공개됩니다. 이 글은 <a href="/learning/deep-dive/deep-dive-system-design-file-serving/">파일 업로드와 서빙 시스템 설계</a>, <a href="/learning/deep-dive/deep-dive-object-storage-s3/">Object Storage S3 기초</a>, <a href="/learning/deep-dive/deep-dive-ssrf-egress-control-playbook/">SSRF와 Egress Control</a>, <a href="/learning/deep-dive/deep-dive-async-request-reply-operation-resource-playbook/">비동기 요청-응답 Operation Resource</a>와 함께 보면 좋습니다. 핵심은 파일을 저장하는 것이 아니라, <strong>공개 가능한 파일인지 설명할 수 있는 상태 전이</strong>를 만드는 것입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>Presigned URL 기반 업로드에서 업로드 완료와 공개 가능 상태를 분리하는 이유를 이해할 수 있습니다.</li>
<li>quarantine bucket, metadata row, scan worker, clean bucket/CDN을 어떤 순서로 연결할지 잡을 수 있습니다.</li>
<li>파일 크기, MIME signature, 압축 해제 비율, 스캔 지연, 공개 TTL 같은 숫자 기준을 세울 수 있습니다.</li>
<li>악성 파일, 스캔 실패, 이벤트 중복, CDN 캐시 노출, 개인정보 처리 같은 운영 리스크를 체크리스트로 관리할 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-direct-upload는-direct-publish가-아니다">1) Direct upload는 direct publish가 아니다</h3>
<p>Presigned URL을 쓰는 가장 큰 이유는 앱 서버가 대용량 바이너리를 직접 받지 않게 하는 것입니다. 하지만 클라이언트가 Object Storage에 직접 업로드한다고 해서 그 객체를 곧바로 서비스에 노출해야 한다는 뜻은 아닙니다. 업로드 직후 파일의 기본 상태는 <code>CLEAN</code>이 아니라 <code>UPLOADED</code> 또는 <code>PENDING_SCAN</code>이어야 합니다.</p>
<p>권장 상태 전이는 아래처럼 단순하게 시작할 수 있습니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>INITIATED -&gt; UPLOADED -&gt; SCANNING -&gt; CLEAN -&gt; PUBLISHED
</span></span><span style="display:flex;"><span>                         |          |
</span></span><span style="display:flex;"><span>                         v          v
</span></span><span style="display:flex;"><span>                      REJECTED    EXPIRED
</span></span></code></pre></div><p><code>INITIATED</code>는 서버가 업로드 의도를 등록하고 presigned URL을 발급한 상태입니다. <code>UPLOADED</code>는 object storage 이벤트나 완료 콜백으로 실제 파일이 들어온 상태입니다. <code>SCANNING</code>은 워커가 파일을 검사 중인 상태이고, <code>CLEAN</code>은 서비스 정책상 공개 가능하다는 판정입니다. 사용자가 접근할 수 있는 URL은 <code>CLEAN</code> 이후에만 발급합니다. 만약 아바타 이미지처럼 빨리 보여야 하는 파일도 이 원칙은 유지하되, 스캔 목표 시간을 짧게 잡는 방식으로 풀어야 합니다.</p>
<h3 id="2-격리-영역은-권한과-네트워크-경계가-달라야-한다">2) 격리 영역은 권한과 네트워크 경계가 달라야 한다</h3>
<p>quarantine은 단순 폴더명이 아닙니다. 가능하면 공개 버킷과 격리 버킷을 분리하거나, 최소한 bucket policy와 CDN origin을 분리해야 합니다. <code>uploads/quarantine/...</code> 같은 prefix만 쓰더라도 CDN이 해당 prefix를 origin으로 읽을 수 있으면 격리 의미가 약합니다. 격리 영역의 객체는 앱 사용자에게 직접 서빙되지 않아야 하고, 스캔 워커와 제한된 운영 도구만 읽을 수 있어야 합니다.</p>
<p>실무 기준은 아래처럼 잡을 수 있습니다.</p>
<table>
  <thead>
      <tr>
          <th>영역</th>
          <th>접근 주체</th>
          <th>공개 여부</th>
          <th>보존 기준</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>quarantine bucket/prefix</td>
          <td>upload client write, scanner read</td>
          <td>비공개</td>
          <td>1~7일 후 미처리 만료</td>
      </tr>
      <tr>
          <td>clean bucket/prefix</td>
          <td>backend copy/write, CDN read</td>
          <td>정책에 따라 공개</td>
          <td>서비스 보존 정책</td>
      </tr>
      <tr>
          <td>rejected bucket/prefix</td>
          <td>scanner write, operator read</td>
          <td>비공개</td>
          <td>7~30일 또는 즉시 삭제</td>
      </tr>
      <tr>
          <td>metadata DB</td>
          <td>backend, worker</td>
          <td>API로만 노출</td>
          <td>감사·정책 기준</td>
      </tr>
  </tbody>
</table>
<p>중요한 것은 파일이 아니라 메타데이터가 source of truth라는 점입니다. Object Storage에 객체가 있어도 DB 상태가 <code>CLEAN</code>이 아니면 사용자에게 보여주지 않습니다. 반대로 DB가 <code>CLEAN</code>인데 객체가 없으면 스캔이나 이동 파이프라인 장애입니다. 이 불일치를 정기적으로 잡는 관점은 <a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation 파이프라인</a>과도 이어집니다.</p>
<h3 id="3-presigned-url은-짧고-좁아야-한다">3) Presigned URL은 짧고 좁아야 한다</h3>
<p>Presigned URL은 임시 권한입니다. 너무 길게 열어두거나 넓은 key 범위를 허용하면 업로드 경로가 권한 우회 통로가 됩니다. 출발점은 아래 기준이 현실적입니다.</p>
<ul>
<li>URL TTL: 일반 업로드 5<del>15분, 대용량 업로드 30</del>60분</li>
<li>최대 파일 크기: 아바타 5<del>10MB, 일반 문서 50</del>100MB, 대용량 미디어는 별도 multipart 정책</li>
<li>object key: 서버가 생성한 <code>tenant_id/user_id/file_id</code> 기반 key만 허용</li>
<li>overwrite 금지: 같은 <code>file_id</code>에 재업로드가 필요하면 object version 또는 attempt id 분리</li>
<li>Content-Length 제한: 클라이언트 선언만 믿지 말고 완료 후 실제 크기 검증</li>
<li>Content-Type: 허용 목록으로 받되, 최종 판단은 magic byte와 파서 검사로 수행</li>
</ul>
<p>특히 <code>public-read</code> ACL을 presigned upload에 섞는 것은 피해야 합니다. 업로드 순간 공개되는 구조가 되기 때문입니다. 공개는 서버가 <code>CLEAN</code> 판정 후 별도 copy, tag 변경, DB 상태 변경, signed download URL 발급 중 하나로 처리합니다.</p>
<h3 id="4-스캔은-바이러스-검사-하나로-끝나지-않는다">4) 스캔은 바이러스 검사 하나로 끝나지 않는다</h3>
<p>파일 스캔이라고 하면 ClamAV 같은 악성코드 검사를 떠올리기 쉽습니다. 하지만 백엔드 업로드 보안은 더 넓습니다. 최소한 아래 검사가 필요합니다.</p>
<ul>
<li>실제 파일 크기와 선언 크기 비교</li>
<li>확장자, Content-Type, magic byte 일치 여부</li>
<li>이미지/문서 파서로 열리는지 확인</li>
<li>압축 파일의 entry 수, 총 해제 크기, 최대 depth 확인</li>
<li>실행 파일, HTML, SVG script, macro 문서 차단 여부</li>
<li>악성코드/평판 스캔</li>
<li>EXIF 위치 정보, 개인정보 메타데이터 제거 필요 여부</li>
<li>썸네일 생성 또는 안전한 포맷 재인코딩 가능 여부</li>
</ul>
<p>압축 파일은 특히 조심해야 합니다. 업로드 크기는 5MB인데 압축 해제 후 5GB가 되는 파일은 스캐너와 저장소를 동시에 압박합니다. 출발 기준으로는 압축 해제 총량이 원본의 20~100배를 넘거나, entry 수가 10,000개를 넘거나, depth가 5단계를 넘으면 자동 거절 또는 수동 검토로 보내는 편이 안전합니다. 서비스 성격상 zip 업로드가 필요 없다면 처음부터 금지하는 것이 가장 단순합니다.</p>
<h3 id="5-완료-이벤트는-중복과-지연을-기본값으로-본다">5) 완료 이벤트는 중복과 지연을 기본값으로 본다</h3>
<p>Object Storage 이벤트, 큐 메시지, 클라이언트 완료 콜백은 모두 중복될 수 있습니다. 같은 파일에 대해 스캔 job이 두 번 생성될 수 있고, 사용자가 네트워크 문제로 완료 API를 여러 번 호출할 수도 있습니다. 그래서 scan job은 <code>file_id + upload_attempt + object_version</code> 기준으로 멱등해야 합니다.</p>
<p>예를 들어 <code>UPLOADED</code> 전이는 <code>INITIATED</code>일 때만 성공하고, 이미 <code>SCANNING</code>이나 <code>CLEAN</code>인 파일에 같은 이벤트가 다시 오면 현재 상태를 반환합니다. 스캔 워커도 같은 object hash를 이미 검사했다면 결과를 재사용할 수 있습니다. 다만 hash 기반 재사용은 조심해야 합니다. 같은 바이너리라도 tenant, 공개 범위, 정책 버전이 다르면 결과 적용 방식이 달라질 수 있습니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-메타데이터-테이블을-먼저-설계한다">1) 메타데이터 테이블을 먼저 설계한다</h3>
<p>파일 객체보다 먼저 파일 상태를 설명하는 row가 있어야 합니다. 최소 모델은 아래 정도입니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">CREATE</span> <span style="color:#ff79c6">TABLE</span> upload_file (
</span></span><span style="display:flex;"><span>  file_id              <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">PRIMARY</span> <span style="color:#ff79c6">KEY</span>,
</span></span><span style="display:flex;"><span>  tenant_id            <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  owner_id             <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  object_key           <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">512</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  object_version       <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">128</span>),
</span></span><span style="display:flex;"><span>  original_filename    <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">255</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  declared_content_type <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">120</span>),
</span></span><span style="display:flex;"><span>  detected_content_type <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">120</span>),
</span></span><span style="display:flex;"><span>  declared_size_bytes  <span style="color:#8be9fd;font-style:italic">bigint</span>,
</span></span><span style="display:flex;"><span>  actual_size_bytes    <span style="color:#8be9fd;font-style:italic">bigint</span>,
</span></span><span style="display:flex;"><span>  sha256               <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">80</span>),
</span></span><span style="display:flex;"><span>  status               <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">32</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  policy_version       <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>) <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  scan_result          <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">64</span>),
</span></span><span style="display:flex;"><span>  rejection_reason     <span style="color:#8be9fd;font-style:italic">varchar</span>(<span style="color:#bd93f9">120</span>),
</span></span><span style="display:flex;"><span>  created_at           timestamptz <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  uploaded_at          timestamptz,
</span></span><span style="display:flex;"><span>  scanned_at           timestamptz,
</span></span><span style="display:flex;"><span>  published_at         timestamptz,
</span></span><span style="display:flex;"><span>  expires_at           timestamptz
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p><code>status</code>만 있으면 부족합니다. 나중에 왜 거절됐는지, 어떤 정책 버전에서 통과했는지, 파일 크기가 선언과 달랐는지 설명해야 합니다. 파일 업로드가 고객 지원, 법무, 보안 이슈와 연결되는 서비스라면 <code>policy_version</code>, <code>scan_engine_version</code>, <code>request_id</code>도 남기는 편이 좋습니다.</p>
<h3 id="2-공개-경로는-db-상태를-확인하게-만든다">2) 공개 경로는 DB 상태를 확인하게 만든다</h3>
<p>사용자가 파일을 보려고 할 때 object key를 그대로 조합해서 내려주면 안 됩니다. API는 DB에서 <code>status=CLEAN</code> 또는 <code>PUBLISHED</code>인지 확인하고, 권한을 확인한 뒤, 짧은 다운로드 URL이나 CDN URL을 반환합니다. 공개 파일이라면 CDN URL을 줄 수 있지만, 비공개 파일은 signed URL TTL을 1~10분 정도로 짧게 둡니다.</p>
<p>CDN을 붙일 때는 더 엄격해야 합니다. rejected 파일이 잠깐이라도 CDN에 캐시되면 DB 상태를 되돌려도 이미 퍼질 수 있습니다. 따라서 CDN origin은 clean 영역만 바라보게 하고, quarantine 영역은 origin 자체에서 제외합니다. 실수로 노출된 경우를 대비해 object key를 예측 불가능하게 만들고, purge 절차를 runbook에 넣습니다.</p>
<h3 id="3-정책을-파일-유형별로-나눈다">3) 정책을 파일 유형별로 나눈다</h3>
<p>모든 파일에 같은 정책을 적용하면 과하거나 부족해집니다. 처음에는 아래 정도의 tier로 나누면 운영 판단이 빨라집니다.</p>
<table>
  <thead>
      <tr>
          <th>파일 유형</th>
          <th>예시</th>
          <th style="text-align: right">공개 목표</th>
          <th>정책</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>작은 이미지</td>
          <td>아바타, 썸네일</td>
          <td style="text-align: right">p95 30~60초</td>
          <td>이미지 파서 검증, 재인코딩, EXIF 제거</td>
      </tr>
      <tr>
          <td>일반 문서</td>
          <td>PDF, txt, docx</td>
          <td style="text-align: right">p95 1~3분</td>
          <td>MIME signature, malware scan, macro 차단</td>
      </tr>
      <tr>
          <td>대용량 미디어</td>
          <td>동영상, 음성</td>
          <td style="text-align: right">p95 5~15분</td>
          <td>multipart 완료 검증, transcoding 후 공개</td>
      </tr>
      <tr>
          <td>압축 파일</td>
          <td>zip, tar</td>
          <td style="text-align: right">비권장</td>
          <td>필요 시 entry/depth/해제 크기 제한</td>
      </tr>
      <tr>
          <td>실행 가능 파일</td>
          <td>exe, sh, jar</td>
          <td style="text-align: right">기본 차단</td>
          <td>내부 배포 경로만 별도 승인</td>
      </tr>
  </tbody>
</table>
<p>정책은 비즈니스 요구와 보안 비용의 합의입니다. 예를 들어 고객이 계약서를 올리는 B2B SaaS라면 docx/PDF를 막을 수 없지만, 공개 커뮤니티 프로필 이미지만 받는 서비스라면 이미지 외 파일을 받을 이유가 거의 없습니다. 파일 유형을 넓히는 것은 기능 추가가 아니라 공격 표면 확대입니다.</p>
<h3 id="4-스캔-워커에는-처리량보다-중단-조건을-먼저-넣는다">4) 스캔 워커에는 처리량보다 중단 조건을 먼저 넣는다</h3>
<p>스캔 워커는 CPU, 메모리, 디스크 I/O를 많이 씁니다. 워커 수를 늘리면 빨라질 것 같지만, 압축 해제나 이미지 변환이 섞이면 노드 전체가 불안정해질 수 있습니다. 그래서 처리량 기준과 중단 조건을 같이 둬야 합니다.</p>
<p>권장 시작점은 아래와 같습니다.</p>
<ul>
<li>scanner worker 동시성: 노드 CPU 코어 수의 50~70% 이하</li>
<li>단일 파일 스캔 timeout: 작은 이미지 10초, 일반 문서 60초, 대용량 파일은 별도 job</li>
<li>scan queue lag p95: 이미지 60초 이하, 문서 3분 이하</li>
<li><code>PENDING_SCAN</code> 15분 초과: 경고</li>
<li><code>SCANNING</code> 30분 초과: stuck job으로 재시도 또는 격리</li>
<li>scanner error rate 1% 초과 5분 지속: 신규 공개 중지 또는 degraded mode</li>
</ul>
<p>중요한 것은 스캔 실패 시 &ldquo;일단 공개&quot;하지 않는 것입니다. 공개 커뮤니티 서비스라면 fail-closed가 기본입니다. 내부 분석 파일처럼 사용자가 직접 다시 받을 뿐인 경우에도, 스캔 실패 파일에는 명확한 warning과 다운로드 차단 정책을 둬야 합니다.</p>
<h3 id="5-운영-지표는-업로드-성공률보다-공개-지연과-오염-차단을-본다">5) 운영 지표는 업로드 성공률보다 공개 지연과 오염 차단을 본다</h3>
<p>대시보드에는 최소한 아래 지표를 올립니다.</p>
<ul>
<li><code>upload_initiated_total</code></li>
<li><code>upload_completed_total</code></li>
<li><code>upload_orphan_object_count</code></li>
<li><code>scan_queue_lag_seconds_p95</code></li>
<li><code>scan_duration_seconds_p95</code></li>
<li><code>pending_scan_age_max_seconds</code></li>
<li><code>scan_rejected_total{reason}</code></li>
<li><code>mime_mismatch_rate</code></li>
<li><code>zip_expansion_block_total</code></li>
<li><code>clean_publish_latency_seconds_p95</code></li>
<li><code>cdn_purge_required_total</code></li>
</ul>
<p>업로드 성공률만 보면 사용자 입장에서는 좋아 보일 수 있습니다. 하지만 실제 운영 지표는 &ldquo;업로드된 파일이 안전하게 공개되기까지 얼마나 걸렸는가&quot;와 &ldquo;정책 위반 파일을 얼마나 설명 가능하게 차단했는가&quot;입니다. <code>mime_mismatch_rate</code>가 0.5%를 넘으면 클라이언트 구현 문제나 공격 시도를 의심하고, <code>pending_scan_age_max</code>가 15분을 넘으면 스캐너 장애로 봅니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, 동기 스캔은 단순하지만 사용자 지연을 키웁니다. 앱 서버가 업로드를 직접 받고 요청 안에서 스캔한 뒤 응답하면 구현 흐름은 명확합니다. 하지만 50MB 문서, 대용량 이미지, 압축 파일이 들어오는 순간 요청 timeout과 서버 리소스 문제가 커집니다. 사용자에게 즉시 공개가 꼭 필요하지 않다면 비동기 스캔이 더 안전합니다.</p>
<p>둘째, 비동기 스캔은 상태 UX가 필요합니다. 사용자는 업로드가 끝났는데 파일이 아직 보이지 않는 상황을 만납니다. 이때 화면에는 &ldquo;처리 중&rdquo; 상태, 예상 지연, 실패 시 재업로드 안내가 있어야 합니다. 백엔드도 <code>GET /uploads/{fileId}</code> 같은 상태 API를 제공해야 합니다. 이 구조는 긴 작업을 operation resource로 다루는 패턴과 같습니다.</p>
<p>셋째, 외부 스캔 서비스는 개인정보 경계가 됩니다. 파일을 외부 SaaS로 보내 검사하면 운영은 쉬워질 수 있지만, 고객 문서나 개인정보가 제3자에게 전송됩니다. 계약, 지역, 보존 기간, 학습 사용 여부, 삭제 SLA를 확인해야 합니다. 민감 파일은 자체 스캐너 또는 격리된 VPC 경로를 우선 검토합니다.</p>
<p>넷째, false positive와 false negative를 모두 인정해야 합니다. 스캐너가 정상 파일을 막으면 고객 지원 비용이 생기고, 악성 파일을 놓치면 보안 사고가 됩니다. 그래서 거절 사유는 사용자에게 너무 자세히 노출하지 않되, 운영자는 <code>policy_version</code>, <code>signature</code>, <code>engine_version</code>, <code>sample hash</code>를 볼 수 있어야 합니다.</p>
<p>다섯째, &ldquo;이미지만 받는다&quot;는 말도 안전하지 않습니다. SVG는 스크립트와 외부 참조를 포함할 수 있고, 이미지 파서 취약점도 존재합니다. 공개 이미지 서비스라면 SVG를 금지하거나 sanitize하고, JPEG/PNG/WebP도 서버에서 재인코딩해 안전한 파생본만 공개하는 편이 낫습니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="운영-체크리스트">운영 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 업로드 완료 상태와 공개 가능 상태가 분리되어 있다.</li>
<li><input disabled="" type="checkbox"> quarantine 영역은 CDN이나 사용자 다운로드 경로에서 직접 접근할 수 없다.</li>
<li><input disabled="" type="checkbox"> presigned URL은 짧은 TTL, 정확한 object key, 크기 제한, overwrite 금지를 갖는다.</li>
<li><input disabled="" type="checkbox"> Content-Type과 확장자만 믿지 않고 magic byte와 파서 검증을 수행한다.</li>
<li><input disabled="" type="checkbox"> 스캔 실패, timeout, stuck job은 fail-closed로 처리된다.</li>
<li><input disabled="" type="checkbox"> <code>PENDING_SCAN</code>, <code>SCANNING</code>, <code>CLEAN</code>, <code>REJECTED</code> 상태 전이가 멱등하다.</li>
<li><input disabled="" type="checkbox"> 공개 URL 발급은 DB 상태와 사용자 권한을 확인한 뒤에만 이뤄진다.</li>
<li><input disabled="" type="checkbox"> scan lag, rejected reason, MIME mismatch, orphan object 지표가 있다.</li>
<li><input disabled="" type="checkbox"> rejected 파일의 보존 기간과 삭제 정책이 문서화되어 있다.</li>
<li><input disabled="" type="checkbox"> CDN purge와 공개 취소 절차가 runbook에 있다.</li>
</ul>
<h3 id="연습">연습</h3>
<ol>
<li>현재 서비스의 업로드 파일 유형을 5개 이하로 분류해 보세요. 이미지, 문서, 압축, 미디어, 기타로 나눴을 때 정말 받아야 하는 파일만 남기는 것이 목표입니다.</li>
<li>아바타 이미지 업로드를 예로 들어 <code>INITIATED -&gt; UPLOADED -&gt; SCANNING -&gt; CLEAN -&gt; PUBLISHED</code> 상태 전이를 API 응답과 함께 적어 보세요.</li>
<li>압축 파일을 허용해야 한다고 가정하고, 최대 entry 수, 최대 해제 크기, 최대 depth, timeout 기준을 숫자로 정해 보세요. 기준을 못 정하겠다면 아직 허용할 준비가 안 된 것입니다.</li>
<li><code>PENDING_SCAN</code> 상태가 30분 이상 쌓이는 장애를 가정해 보세요. 신규 업로드를 계속 받을지, 공개를 멈출지, 사용자에게 어떤 상태를 보여줄지 runbook으로 정리합니다.</li>
<li>CDN에 잘못 공개된 파일 1개를 취소하는 절차를 작성해 보세요. DB 상태 변경, object 이동/삭제, CDN purge, 감사 로그, 고객 안내가 모두 포함되어야 합니다.</li>
</ol>
<p>파일 업로드의 목표는 사용자가 올린 바이트를 빠르게 저장하는 데서 끝나지 않습니다. 운영 가능한 시스템은 &ldquo;이 파일이 언제, 어떤 정책으로, 어떤 근거로 공개됐는가&quot;를 설명할 수 있어야 합니다. direct upload는 서버 부하를 줄이는 좋은 기술이지만, 공개 판정까지 클라이언트와 스토리지 이벤트에 맡기면 위험합니다. 안전한 업로드 파이프라인은 업로드를 격리하고, 검증하고, 상태로 설명한 뒤, 필요한 파일만 공개합니다.</p>
]]></content:encoded></item></channel></rss>