정적 사이트에 나만 보는 페이지 붙이기

서버 런타임 없이, 등록한 기기에서만 열리는 페이지를 정적 사이트에 붙였습니다. 클라이언트 인증서(mTLS)로 막고 산출물 자체를 갈라 둔 과정과 실제로 걸렸던 것들입니다.

이 사이트는 서버에서 페이지를 만들지 않습니다. 빌드 결과인 정적 파일을 올려 두고 웹서버가 그대로 내보냅니다. 그런데 내 기기에서만 보이는 페이지가 필요해졌습니다. 서버 런타임을 들이지 않고 이걸 어떻게 할지 정리한 글입니다.

화면에서 감추는 것은 인증이 아니다

가장 먼저 떠오르는 방법은 화면 쪽에서 조건부로 감추는 것입니다. localStorage 에 표시를 남기거나, 주소에 파라미터를 붙이거나, 특정 조건에서만 그리는 식입니다. 이건 인증이 아닙니다. 정적 사이트에서 조건부 렌더는 내용이 이미 나간 뒤에 가리는 것이라서, 브라우저 개발자 도구는 물론이고 curl 한 번이면 그대로 보입니다.

감춰야 할 것이 HTML 이나 JS 번들 안에 들어 있다면, 그 시점에 이미 공개된 것입니다.

그래서 두 가지를 따로 해결하기로 했습니다. 하나는 비공개 내용이 공개 산출물에 애초에 들어가지 않게 하는 것, 다른 하나는 그 산출물을 아무나 못 받아 가게 하는 것입니다.

산출물을 통째로 갈랐다

같은 빌드에 페이지만 추가하는 방식은 쓸 수 없습니다. 번들러가 코드를 청크로 묶으면서 비공개 문구가 공개 JS 에 섞여 나갈 수 있기 때문입니다. 설정 파일부터 갈라 두는 편이 확실합니다.

공개비공개
문서 셸index.htmlindex-private.html
빌드 설정vite.config.tsvite.config.private.ts
앱 루트App.tsxPrivateApp.tsx
콘텐츠content/content/private/
산출물out/out-private/
색인sitemap 등록noindex · Disallow: / · no-store

경로가 아니라 서브도메인인 이유

웹서버에서 클라이언트 인증서를 요구하는 설정은 TLS 핸드셰이크 단위로 걸립니다. 경로별로 켜고 끌 수 있는 것이 아닙니다. 공개 사이트에 그대로 걸면 모든 방문자에게 인증서를 내놓으라고 요구하게 됩니다. 그래서 비공개 트리는 별도 호스트로 뺐습니다.

private.example.dev {
	tls {
		issuer acme {
			email you@example.dev
			# mTLS 사이트에서는 TLS-ALPN-01 챌린지가 실패한다. HTTP-01 로 받는다.
			disable_tlsalpn_challenge
		}
		client_auth {
			mode require_and_verify
			trust_pool file /etc/caddy/private-ca.pem
		}
	}

	header {
		X-Robots-Tag "noindex, nofollow"
		Cache-Control "no-store"
	}

	root * /srv/private/current
	try_files {path} {path}/
	file_server
}

CA 를 믿는 것만으로는 부족하다

trust_pool 에 내 사설 CA 를 넣으면 그 CA 가 발급한 모든 인증서가 통과합니다. 기기를 한 대 잃어버렸을 때 그 한 대만 끊으려면 관문이 하나 더 필요합니다. 인증서 지문 허용목록을 두었습니다.

@denied not expression `{env.CLIENT_FINGERPRINTS}.contains({http.request.tls.client.fingerprint})`
respond @denied "not authorized" 403

지문은 환경 변수에 쉼표로 잇습니다. 목록이 비어 있으면 contains 가 항상 거짓이라 아무도 통과하지 못합니다. 설정을 빠뜨렸을 때 열리는 쪽이 아니라 닫히는 쪽으로 기우는 것이 맞습니다. 기기를 회수할 때는 그 지문 한 줄만 빼면 됩니다.

인증서를 만들면서 걸린 것들

  • Ed25519 는 쓸 수 없습니다. 서버는 받아들이지만 브라우저와 운영체제 인증서 저장소가 클라이언트 인증서로 지원하지 않습니다. ECDSA P-256 으로 만들었습니다.
  • extendedKeyUsage=clientAuth 가 없으면 거부됩니다. 발급할 때 확장을 빠뜨리면 핸드셰이크 단계에서 막힙니다.
  • 개인키는 내보낼 수 없게 설치합니다. 윈도우라면 가져오기 마법사에서 내보내기 허용을 끄거나, Import-PfxCertificate-Exportable 없이 실행합니다. 그래야 "그 기기"라는 조건이 실제로 성립합니다.
  • CA 인증서는 기기 신뢰 저장소에 넣지 않습니다. 검증은 서버가 합니다. 넣으면 그 기기의 신뢰 경계만 넓어집니다.

서버에서 걸린 것들

  • 인증서 발급이 막힙니다. 자동 발급이 TLS-ALPN-01 챌린지를 쓰면 클라이언트 인증서 요구에 걸려 실패합니다. HTTP-01 로 고정해야 합니다.
  • 오류 응답에는 사이트 레벨 헤더가 붙지 않습니다. 404 문서에도 noindex 를 실으려면 오류 처리 블록 안에서 헤더를 다시 선언해야 합니다.
  • 클라이언트 인증을 켜면 그 리스너 전체에 SNI 와 Host 일치 강제가 붙습니다. 같은 포트를 쓰는 공개 사이트도 영향을 받습니다. 브라우저는 항상 일치시키므로 실사용 문제는 없지만, SNI 없이 붙는 도구는 실패합니다.
  • 지문 목록을 고쳐도 설정 다시 읽기로는 반영되지 않습니다. 환경 변수는 프로세스가 시작할 때 주입되므로 컨테이너를 다시 만들어야 합니다.

정말 안 새는지 확인하는 장치

게이트를 걸어도 공개 번들에 문구가 남아 있으면 아무 의미가 없습니다. 사람이 매번 확인하는 대신 세 겹으로 막았습니다.

  1. 공개 코드가 비공개 콘텐츠를 가져오면 린터가 막습니다(no-restricted-imports).
  2. 비공개 콘텐츠에 표식 문자열을 하나 심어 두고, 점검 스크립트가 공개 산출물 전체를 뒤져 그 문자열을 찾습니다.
  3. 공개 배포 스크립트가 압축 전에 그 점검을 자동으로 돌리고, 한 건이라도 걸리면 배포를 멈춥니다.

검증

설정을 믿지 않고 실제로 붙어 봤습니다. 로컬 컨테이너에서 한 번, 실제 서버에서 한 번입니다.

시도결과
인증서 없이 접속TLS 거부(certificate required)
등록하지 않은 CA 의 인증서TLS 거부(unknown ca)
등록한 기기200, noindex·no-store 헤더 확인
같은 CA 지만 지문 목록 밖403
없는 경로404(상태 코드 유지)
지문 목록을 비운 상태등록 기기도 403

윈도우 기본 curl 은 Schannel 백엔드라 PEM 파일을 클라이언트 인증서로 받지 않습니다. 인증서 저장소 경로(CurrentUser\MY\<지문>)를 넘기거나, PEM 을 쓰려면 다른 도구로 확인해야 합니다.

정리

서버 런타임을 하나도 늘리지 않고, 웹서버 설정과 빌드 분리만으로 끝났습니다. 비밀번호를 기억할 필요도 없고, 로그인 화면을 만들 필요도 없습니다. 대신 기기를 잃어버렸을 때 지문을 빼는 절차를 잊지 않는 것이 이 방식의 유지 비용입니다.

정적 사이트라고 해서 공개 페이지만 둘 수 있는 것은 아닙니다. 다만 가리는 위치가 화면이 아니라 전송 단이어야 하고, 애초에 나가지 않게 산출물을 갈라 두어야 합니다.