bgyoonsContact

이미지 url 네트워크 탭에서 감추기

문제해결 | | 8분 읽기

문제 정의

로그인 후 OTP 설정 시 백엔드가 반환하는 { qrImageUrl } 를 클라이언트에 노출하면 안 됨.

브라우저 네트워크 탭에서 보이면 보안 위협


1. 서버사이드 마이그레이션 필요 여부 검토

OTP 정보를 서버에서만 처리하려면 서버사이드가 필요한데, 로그인 자체를 서버 컴포넌트로 마이그레이션해야 하는지 확인

→ 불필요. 백엔드가 로그인 성공 시 임시 세션 쿠키를 심어주고, Next.js Route Handler는 이 쿠키에 접근할 수 있어서 로그인 마이그레이션 없이 서버사이드 처리 가능.


2. API 라우트 핸들러 이용

Route Handler에서 백엔드 응답의 qrImageUrl로 이미지를 fetch하고, base64로 변환해 JSON으로 반환.

// Route Handler
const response = await fetch(`/auth/otp/registration`, {
  method: 'POST',
  headers: {
    Cookie: `service.sid=${authToken}`,
  },
});
 
const data = await response.json();
 
return NextResponse.json({ qrImage: data.qrImageUrl });

→ 미채택**.** 네트워크 탭의 /api/auth/otp/registration 응답에 qrImage 필드(base64 문자열)가 JSON으로 그대로 노출되는 구조. qrImageUrl 자체가 클라이언트에 전달되므로 근본적인 해결이 안 됨. 클라이언트에서 호출하는 것과 API 라우트를 사용한 것에 대한 큰 이점이 없음


3. 서버 컴포넌트 검토

서버 컴포넌트에서 렌더링하면 qrImageUrl을 숨길 수 있는지 검토.

→ 불가. 서버 컴포넌트가 base64로 변환해도 RSC payload에 직렬화되어 네트워크 탭에 노출됨. <img src={qrImageUrl}> URL 자체가 HTML에 포함됨.


4. blob을 이용하여 이미지 요청을 클라이언트 → 서버로 위임

// Route Handler
const response = await fetch(`/auth/otp/registration`, {
  method: 'POST',
  headers: {
    Cookie: `service.sid=${authToken}`,
  },
});
const data = await response.json();
const qrImageResponse = await fetch(data.qrImageUrl);
const qrImageBuffer = await qrImageResponse.arrayBuffer();
 
const base64 = Buffer.from(qrImageBuffer).toString('base64');
return NextResponse.json({ qrImage: `data:image/png;base64,${base64}` });

→ 미채택. arrayBuffer()로 전체를 메모리에 올린 뒤 base64로 변환하면 불필요한 메모리 사용이 생기고, 결국 JSON 응답에 base64 문자열이 포함되는 구조는 2단계와 동일하게 값이 네트워크 탭에 노출됨.

→ 버퍼링 없이 바이너리를 바로 스트리밍하는 5단계로 개선.


5. PNG 바이너리 스트림 반환 (확정)

Route Handler에서 qrImageUrl을 서버에서만 소비하고, 이미지 데이터를 PNG 바이너리 그대로 응답으로 흘려보냄

// Route Handler (서버)
 
const qrImageResponse = await fetch(data.qrImageUrl); // qrImageUrl은 서버에서만 소비
 
return new NextResponse(qrImageResponse.body, {       // PNG 바이너리만 전달
  headers: { 
    'Content-Type': 'image/png', 
    'Cache-Control': 'no-store' 
  },
});
 
// 클라이언트
 
const res = await fetch('/api/auth/otp/registration', { method: 'POST' });
const blob = await res.blob();
 
setQrImage(URL.createObjectURL(blob)); // PNG 바이너리 → Blob → ObjectURL

네트워크 탭에 /api/auth/otp/registration 응답이 image/png 바이너리로만 찍히고, qrImageUrl은 서버 메모리에서만 처리되고 사라짐.

<요약>

[문제]

  • OTP 등록을 위한 QR 이미지 요청 시, 클라이언트가 이미지 원본 URL을 직접 전달받는 경우 네트워크 탭에 해당 URL이 그대로 노출되어 보안상 이슈 발생
  • Route Handler를 사용하더라도 클라이언트에서 이미지 요청을 수행하는 구조에서는 여전히 네트워크 응답이나 img src를 통해 URL이 그대로 노출되는 문제 발생

[해결]

  • Route Handler를 BFF(Backend for Frontend) 프록시로 활용하여, 클라이언트가 원본 이미지 URL을 직접 호출하지 않도록 구조를 변경
// Route Handler
 
const data = await response.json();
 
/** qrImageUrl을 서버에서 fetch → body 스트림을 브라우저로 직접 전달
 * qrImageUrl은 서버에서만 소비되며 클라이언트에 노출되지 않음
 */
const qrImageResponse = await fetch(data.qrImageUrl);
 
return new NextResponse(qrImageResponse.body, {
  headers: {
    'Content-Type': qrImageResponse.headers.get('content-type') ?? 'image/png',
    'Cache-Control': 'no-store',
  },
});

Route Handler가 백엔드로부터 qrImageUrl을 받아 클라이언트 대신 원본 이미지 URL을 요청하고, 이를 PNG 바이너리 형태로 클라이언트에 응답합니다.

이 과정에서 클라이언트 네트워크 탭에는 image/png 바이너리만 노출되고, 실제 이미지 URL은 서버에서만 처리됩니다.

// 클라이언트
 
fetch('/api/auth/otp/registration', { method: 'POST' })
  .then((res) => res.blob())
  .then((blob) => {
    const objectUrl = URL.createObjectURL(blob);
    setQrImage(objectUrl);
 })

클라이언트에서는 응답받은 바이너리 데이터를 Blob으로 변환한 뒤, URL.createObjectURL()을 통해 임시 URL(blob:...)을 img 요소에 주입하야 렌더링합니다.

사용이 끝난 후에는 URL.revokeObjectURL()을 호출하여 메모리 누수를 방지하도록 처리했습니다.