비공개 시험 SVG의 정적 경로 노출 방지: 인증 라우트와 산출물 분리

이 글의 목차 14개 항목
  • 분야: Security
  • 기술 스택: Cloudflare Workers · TypeScript · SHA-256 · SVG

문제와 원인

SKCT·IPE 도형을 프런트엔드 public에 두면서 로그인 검사와 별개로 정적 URL에서 파일을 제공하게 됐다. 기존 IPE 이미지 매핑도 공개 SVG 경로를 전달했다.

도형을 서버 전용 자원으로 옮기고 서명 세션과 요청자 권한을 검사하는 라우트로 제공하도록 바꿨다.

수정 전 코드

generate-ipe-practical-diagrams.mjs

생성 경로를 프런트엔드 공개 디렉터리로 지정했다.

const publicRoot = path.join(root, 'apps/frontend/public');
// 개별 자료의 공개 경로 값은 생략했습니다.

ipe-practical-images.mjs

이미지 매핑도 공개 SVG 경로를 전달했다.

export const PRACTICAL_REDRAWN_IMAGES = Object.freeze({
  // 개별 자료의 경로와 해시 값은 생략했습니다.
  // 나머지 매핑 생략
});

수정 후 코드

generate-private-diagrams.mjs

생성기는 서버 자원의 해시 경로와 제공 목록을 만들고, 옛 파일이 공개 디렉터리에 남아 있으면 실패한다. SKCT 도형만 제공 목록에 포함하며 IPE는 보존 자원으로 관리한다.

if (fs.existsSync(path.join(publicRoot, legacy.slice(1)))) throw Error(`Private diagram is public: ${legacy}`);
const hash = crypto.createHash('sha256').update(data).digest('hex');
const url = `/api/private-diagrams/${inventory.scope}/${hash}.svg`;
if (inventory.scope === 'skct') diagrams[url] = { scope: inventory.scope, svg };

ipe-practical-images.mjs

매핑은 인증 라우트의 해시 경로를 전달한다.

export const PRACTICAL_REDRAWN_IMAGES = Object.freeze({
  // 개별 자료의 경로와 해시 값은 생략했습니다.
  // 나머지 매핑 생략
});

GET

공개 라우트에서 withSiteIdentity()로 서명 세션을 검증한 다음 서비스에 전달한다.

export const GET = (request: Request) => withSiteIdentity(request, authenticated =>
  withApiErrorBoundary(authenticated, () => handleGet(authenticated), failure));

GET

서비스는 SKCT에 학습자 권한을 요구한다. 종료된 IPE 경로는 관리자 권한을 확인한 뒤 410을 반환한다.

const retiredIpe = url.pathname.startsWith('/api/private-diagrams/ipe/');
const asset = PRIVATE_DIAGRAMS[url.pathname];
if ((!asset && !retiredIpe) || request.method !== 'GET') {
  return new Response(null, { status: 404, headers });
}
const authorization = retiredIpe
  ? await authorizeAdminRequest(request)
  : await authorizeLearnerRequest(request);
// !authorization.ok이면 해당 401/403 응답 반환
if (retiredIpe) return new Response(null, { status: 410, headers });
if (!asset) return new Response(null, { status: 404, headers });
return new Response(asset.svg, {
  status: 200,
  headers: { ...headers, 'Content-Type': 'image/svg+xml; charset=utf-8' },
});

공통 헤더는 private, no-store, Vary: Cookie, nosniff, noindex, nofollow, noarchive와 SVG 콘텐츠 보안 정책을 지정한다. 공개 라우트의 오류 응답은 no-store를 반환한다.

검증 결과

공개 파일과 접근 권한

소스 트리에서 수정 직전 공개 SVG 39개가 현재 0개로 줄었다. 현재 옛 경로 매핑 77개도 공개 디렉터리와 대조했다.

검사 대상수량
수정 직전 공개 SVG39개: SKCT 20개 + IPE 19개
현재 공개 SVG0개
현재 SKCT 옛 경로 매핑58개
현재 IPE 옛 경로 매핑19개
현재 제공 목록SKCT 58개, IPE 0개
매핑 경로의 공개 파일 잔존0/77개

Node.js, SQLite의 로컬 빌드 Worker에 합성 서명 세션을 전달해 각 조건을 1회 요청했다. 옛 정적 경로 요청에는 404를 반환하는 로컬 ASSETS 대역을 사용했다.

요청 조건HTTP도형 본문
SKCT, 로그인 없음401없음
SKCT, 서명 학습자200SVG
IPE, 로그인 없음401없음
IPE, 학습자403없음
IPE, 관리자410없음
옛 SKCT / IPE 정적 경로, 각 1회404 / 404없음
서명 쿠키 없이 내부 이메일 헤더만 전달401없음

서명 검증·권한·캐시 코드에 합성 사각형 SVG 1개를 연결한 로컬 서비스에서도 Swagger UI로 세 조건을 각 1회 요청했다. IAB 화면 조건은 1280×720 CSS px, DPR 2였다.

비회원 SKCT

비회원 요청은 HTTP 401과 AUTHENTICATION_REQUIRED를 반환했으며, 응답 캐시는 no-store였다.

비회원 SKCT: HTTP 401과 인증 오류, no-store원본 보기 (새 창)

비회원 SKCT: HTTP 401과 인증 오류, no-store

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

학습자 SKCT

합성 서명 학습자 요청은 HTTP 200으로 합성 SVG를 반환했다. 캐시 정책은 private, no-store였다.

학습자 SKCT: HTTP 200과 SVG, private no-store원본 보기 (새 창)

학습자 SKCT: HTTP 200과 SVG, private no-store

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

관리자 IPE

합성 서명 관리자 요청은 HTTP 410과 HTTP_410 JSON을 반환했으며 도형 본문은 없었다.

관리자 IPE: HTTP 410과 제공 종료 오류원본 보기 (새 창)

관리자 IPE: HTTP 410과 제공 종료 오류

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

응답 캐시 정책은 no-store였다.

관리자 IPE의 no-store 응답 헤더원본 보기 (새 창)

관리자 IPE의 no-store 응답 헤더

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.