- 분야: 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개도 공개 디렉터리와 대조했다.
| 검사 대상 | 수량 |
|---|---|
| 수정 직전 공개 SVG | 39개: SKCT 20개 + IPE 19개 |
| 현재 공개 SVG | 0개 |
| 현재 SKCT 옛 경로 매핑 | 58개 |
| 현재 IPE 옛 경로 매핑 | 19개 |
| 현재 제공 목록 | SKCT 58개, IPE 0개 |
| 매핑 경로의 공개 파일 잔존 | 0/77개 |
Node.js, SQLite의 로컬 빌드 Worker에 합성 서명 세션을 전달해 각 조건을 1회 요청했다. 옛 정적 경로 요청에는 404를 반환하는 로컬 ASSETS 대역을 사용했다.
| 요청 조건 | HTTP | 도형 본문 |
|---|---|---|
| SKCT, 로그인 없음 | 401 | 없음 |
| SKCT, 서명 학습자 | 200 | SVG |
| 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 200으로 합성 SVG를 반환했다. 캐시 정책은 private, no-store였다.
관리자 IPE
합성 서명 관리자 요청은 HTTP 410과 HTTP_410 JSON을 반환했으며 도형 본문은 없었다.
응답 캐시 정책은 no-store였다.