Skip to content

Repository files navigation

사용자별 비공개 미디어 서빙 + 직접 업로드 인프라

Next.js 16 App Router 위에서, CDN URLPrefix 서명 쿠키로 사용자별 트리에 인증을 걸고, V4 signed POST policy로 브라우저가 오브젝트 스토리지에 직접 업로드하며, imgproxy를 같은 prefix 아래 라우팅해 단일 쿠키로 원본/변환 이미지를 모두 인증한다.

비즈니스 도메인은 비어 있다. 어떤 사용자가 어떤 콘텐츠를 다루는지는 위에서 얹는 문제이고, 이 저장소는 그 밑판 — 권한 경계, 인증된 미디어 전송, 직접 업로드의 검증 — 에만 집중한다.


핵심 시나리오

1) 페이지 로드 → 서명 쿠키 발급

브라우저 → Next 미들웨어
  ├─ 세션에서 userId 확인
  ├─ 요청 호스트가 CDN 쿠키 도메인 안인지 검사 (eTLD+1 가드)
  ├─ 기존 Cloud-CDN-Cookie 파싱
  │   ├─ 만료까지 buffer 이상 남았는가?
  │   ├─ URLPrefix가 현재 사용자에 맞는 스코프인가?
  │   ├─ KeyName이 현재 키와 일치하는가?
  │   └─ 현재 키로 서명을 재계산했을 때 일치하는가?  ← 위조 쿠키 방어 핵심
  └─ 4개 모두 만족 → 그대로 두기
                  → 아니면 직접 조립한 Set-Cookie로 새 쿠키 발급

쿠키 한 개로 /<ns>/<userId>/file/... (스토리지 origin)과 /<ns>/<userId>/img/... (imgproxy origin)을 모두 인증한다.

2) 이미지 표시 → 같은 쿠키로 imgproxy까지

<Image src="/u/<userId>/file/.../p.jpg" />에 커스텀 loader가 붙어, 브라우저는 /u/<userId>/img/unsafe/preset:w_<W>:q_<Q>/<base64(gs://...)>.<ext> 형태의 URL을 그린다. 같은 prefix이므로 쿠키가 그대로 따라가고, imgproxy는 presets-only + UNSAFE 모드로 동작한다 — 서명 키를 클라이언트에 내릴 수 없는 상황에서, 변환 화이트리스트(presets)와 prefix 인증의 결합이 권한 경계가 된다.

3) 업로드 → 3단계 정합성

[1] /api/files/policy   인증된 세션 + Zod 검증 + 정책 매트릭스 검증
                        → V4 signed POST policy 발급
                        → destination key는 서버가 결정 (클라이언트 입력 없음)
                        → content-length-range, eq $Content-Type을 정책에 못 박음
[2] POST policy.url     브라우저 → GCS 직접 업로드
                        → 정책의 max보다 큰 파일은 GCS가 거절
[3] /api/files/complete objectKey가 users/<sessionUserId>/로 시작하는지 가드
                        → 스토리지에서 size/md5/generation을 읽어 DB에 박음
                        → 클라이언트가 신고한 값은 신뢰하지 않음

GCS Pub/Sub 같은 비동기 이벤트로 대체할 수도 있지만, 동기 3단계가 reasoning이 단순하고 실패 추적이 쉬워 이쪽을 선택했다.

4) 머신-투-머신 → 쿠키 없는 서명 URL

쿠키를 들고 다닐 수 없는 워커/외부 시스템용으로, 같은 알고리즘으로 URLPrefix 서명 URL을 발급한다. 만료 디폴트 15분, 상한 7일 (CDN 스펙).


의사결정

결정 이유 반례
URLPrefix 서명 쿠키 srcset/lazy-load와 호환. 한 번 발급, prefix 아래 자유 접근 매 URL 서명: srcset에서 폭발, lazy-load와 충돌
HMAC-SHA1 GCP Cloud CDN 스펙이 강제 다른 CDN(CloudFront RSA-SHA1, Akamai 토큰 등)이면 모듈 교체 필요
Web Crypto edge / node / browser / worker 모두 동작 Node crypto: edge runtime에서 실패
직접 조립한 Set-Cookie 프레임워크 helper가 값을 URL 인코딩 → CDN이 signed_request_invalid_format로 거절 NextResponse.cookies.set: 위 이유로 실패
갱신 시 서명 재검증 위조된 쿠키(서명 무효, 만료 미래)가 영구히 살아남는 것 차단 만료만 검사: 위조 쿠키 영구화
URLPrefix에서 포트 제거 CDN이 서명 검증 시 포트 무시. 클라이언트가 :443을 명시해도 일치해야 함 포트 포함: 비표준 포트 접근 시 invalid_format
imgproxy presets-only + UNSAFE 키를 브라우저에 내릴 수 없음. 변환 화이트리스트화로 보안 경계를 presets 카탈로그에 둠 매번 서명 URL: 서버 왕복 + lazy-load 충돌
imgproxy를 같은 prefix 아래 라우팅 단일 쿠키로 두 origin 인증 별도 도메인: 두 번째 인증 체계 필요
imgproxy → GCS 직접 fetch CDN 경유 시 자기 자신 fetch 순환 + 이중 egress CDN 경유: 비용/순환
V4 POST policy content-length-range를 정책에 박음. 정책 leak 시에도 무한 업로드 불가 PUT signed URL: 사이즈 제한 강제 불가
정책 매트릭스 새 종류 추가 = 한 줄. 검증/키빌드/정책 발급이 자동 일관 호출부 분기: 곧 누락됨
동기 complete 단계 reasoning 단순, 실패 추적 쉬움 Pub/Sub 비동기: 종속성/지연/eventual consistency
users/<id>/... 스토리지 키, <ns>/<id>/file/... CDN 경로 CDN 경로는 srcset에 박혀 페이지 무게에 영향 → 짧을수록 좋음 동일 경로: srcset 무게 손실
날짜 + 타임스탬프 + UUID 키 날짜 폴더로 listing/lifecycle, 타임스탬프로 정렬, UUID로 충돌 방지 단일 식별자 only: 운영 도구 부족
userId는 세션에서만 요청 바디에서 받으면 다른 사용자 namespace 침투 가능 바디 입력: cross-user 침투
1시간 TTL + 10분 buffer 헤더 잡음 최소 + 시계 drift 마진 짧은 TTL: drift에 취약 / 매 요청 발급

디렉토리 구조

.
├── app/
│   ├── (demo)/demo/page.tsx              데모 페이지
│   ├── api/auth/switch/route.ts          데모용 세션 전환
│   ├── api/files/policy/route.ts         POST policy 발급
│   ├── api/files/complete/route.ts       업로드 등록 + 메타 정규화
│   ├── api/files/list/route.ts           내 파일 목록만
│   └── api/files/m2m-url/route.ts        CDN signed URL
├── components/                           데모 UI
├── hooks/useDirectUpload.ts              브라우저 직접 업로드 3단계
├── lib/
│   ├── auth/session.ts                   세션 stub
│   ├── cdn/crypto.ts                     Web Crypto base64url + HMAC-SHA1
│   ├── cdn/signedCookie.ts               쿠키 발급 + 갱신 로직
│   ├── cdn/signedUrl.ts                  M2M용 서명 URL
│   ├── image/imgproxyLoader.ts           Next/Image 커스텀 loader
│   ├── storage/client.ts                 GCS 싱글톤
│   ├── storage/postPolicy.ts             V4 POST policy
│   ├── storage/metadata.ts               객체 메타데이터 + signed read URL
│   ├── storage/pathMap.ts                storage key ↔ CDN path 양방향 매핑
│   ├── upload/policies.ts                kind 매트릭스
│   ├── upload/keyBuilder.ts              sanitize + destination key
│   ├── upload/validation.ts              요청 검증
│   └── db.ts                             SQLite 단일 테이블
├── middleware.ts                         쿠키 발급 진입점
├── db/schema.sql
└── .env.example

각 파일 상단 주석에 왜 그 모양인지에 대한 근거를 적어두었다.


의도적으로 피한 것들

흔히 저지르는 실수의 정확한 형태를 인라인 주석에도 같이 남겼다.

  • response.cookies.set()로 CDN 서명 쿠키를 만들지 않는다 (값이 URL 인코딩되어 CDN이 거절).
  • 쿠키 갱신 스킵을 만료시간만 보고 결정하지 않는다 (서명 재계산 필수).
  • URLPrefix 서명 입력에 포트를 포함하지 않는다.
  • V4 POST policy에서 content-length-range를 빠뜨리지 않는다.
  • imgproxy 서명 키를 클라이언트로 내려보내지 않는다.
  • imgproxy URL이 Next의 image optimizer를 다시 거치지 않게 한다.
  • 업로드 destination key를 요청 바디 값으로 만들지 않는다.
  • 클라이언트가 보낸 size/md5/contentType을 DB에 그대로 저장하지 않는다.
  • 쿠키 도메인을 leading-dot 없이 설정하지 않는다 (cross-subdomain silent drop).
  • 쿠키 prefix 스코프를 CDN 전체 origin으로 두지 않는다.
  • 서명 키를 cookie / URL / imgproxy 간에 공유하지 않는다.
  • 정책 발급 엔드포인트를 인증 없이 두지 않는다.

검증 시나리오

설계가 의도대로 동작하는지 손으로 확인할 수 있는 13가지 시나리오 중 의미 있는 것들:

  1. 위조 쿠키 거절 — DevTools에서 Signature= 부분을 임의로 바꿔 요청하면, 다음 응답에 새 Set-Cookie가 강제로 발급된다 (서명 검증 실패가 갱신 트리거).
  2. 포트 정규화:443을 명시해서 접근해도 서명이 일치한다.
  3. destination 무결성 — 요청 바디에 다른 사용자 userIdobjectKey를 심어 보내도, 서버가 결정하는 destination에 영향을 주지 않는다.
  4. content-length-range 동작 — 정책의 max를 우회한 업로드 시도는 GCS가 직접 거절한다.
  5. complete의 namespace 가드 — 다른 사용자 트리의 objectKey/complete을 호출하면 403.
  6. imgproxy 변환 + 재포장 방지 — 썸네일이 unsafe/preset:w_*:q_*/...로 가져와지고, imgproxy URL을 다시 <Image>에 넣어도 nested wrapping이 발생하지 않는다.

단위 테스트(vitest)는 base64url round-trip, RFC 2202 HMAC-SHA1 test vector, URLPrefix 포트 정규화의 서명 불변성, sanitiser 엣지 케이스, storage key ↔ CDN path 매핑까지를 커버한다.


실행

cp .env.example .env.local        # CDN_*, STORAGE_*, NEXT_PUBLIC_* 채우기
npm install
npm run dev                       # https://localhost:3000/demo
npm run test                      # vitest
npm run typecheck                 # tsc --noEmit

Secure 쿠키와 cross-subdomain 흐름을 그대로 검증하려면 로컬에서도 HTTPS가 필요하다. next dev --experimental-https 또는 mkcert로 셋업하고, /etc/hostsmedia.example.test 같은 항목을 추가한다. imgproxy는 docker 한 줄로 띄울 수 있고, presets 이름은 loader와 정확히 일치해야 한다.


한계와 가정

  • CDN 패밀리: GCP Cloud CDN의 서명 알고리즘에 맞춰져 있다. CloudFront (RSA-SHA1)나 Akamai 토큰 등 다른 패밀리로 갈 경우 lib/cdn/{signedCookie, signedUrl}.ts 두 파일을 교체하면 된다 — 호출부는 그대로다.
  • DB: 데모는 SQLite. Postgres로 바꾸려면 lib/db.ts만 교체.
  • 세션: stub이다. NextAuth/Clerk 등으로 교체할 때 getSession()의 반환 시그니처({ userId } | null)만 맞추면 나머지 코드는 그대로다.
  • 콘텐츠 진위 검증: POST policy의 eq $Content-Type클라이언트 신고만 강제한다. 이미지의 magic byte까지 검증하려면 complete 단계에 후크 자리를 마련해두었다 (TODO 주석).

About

사용자별 비공개 미디어 서빙 + 브라우저 직접 업로드 인프라 — Next.js 16, CDN URLPrefix 서명 쿠키, imgproxy, V4 signed POST policy

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages