이 블로그는 마크다운 글을 빌드할 때 정적 페이지로 구워서 올리는 Next.js 사이트입니다. 로그인도, DB 도, 사용자가 무언가를 올리는 기능도 없어서 보안 걱정은 적어 보이는 구조예요.
그러다 같은 저장소의 다른 앱이 보안 패치를 하는 걸 보고, 블로그도 한번 점검해 봤어요. 결과는 생각과 달랐습니다. 치명(critical) 4건을 포함해 41건이 나왔어요.
186건 중에서 내 앱 것만 걸러 읽기
먼저 의존성 취약점을 확인했습니다.
pnpm audit --prod
결과는 186건(치명 11 · 높음 79 · 보통 80 · 낮음 16). 숫자만 보면 당황스럽지만, 이 저장소는 웹 앱·API·데스크탑 앱이 여러 개 들어 있는 모노레포라 이 숫자는 저장소 전체의 합이에요. pnpm audit 은 앱 하나만 골라 보여 주지 않습니다.
그래서 각 항목의 의존성 경로를 봤어요. 항목마다 어느 앱에서 시작해 어떤 패키지를 거쳐 들어왔는지 경로가 나옵니다.
apps/blog > next@16.2.3
apps/blog > @vercel/analytics@2.0.1 > next@16.2.3
경로가 apps/blog 로 시작하는 것만 모으니 41건(치명 4 · 높음 20 · 보통 14 · 낮음 3)이 남았습니다. 186건을 다 볼 필요 없이, 내 앱에 해당하는 것부터 보면 됩니다.
41건 중 대부분은 Next 하나
41건을 패키지별로 보니 거의 전부가 next@16.2.3 이었어요. 치명 4건은 모두 Next 의 원격 코드 실행(RCE) 계열이었고, 나머지도 서버 요청 위조(SSRF), 미들웨어 우회, 서비스 거부(DoS), 캐시 오염 같은 Next 자체 취약점이 대부분이었습니다. 나머지 몇 건은 Next 가 안에서 쓰는 sharp · postcss · nanoid 였고요.
왜 이렇게 오래된 버전에 머물러 있었나 보니, package.json 에 버전이 정확히 고정돼 있었어요.
"next": "16.2.3"
^16.2.3 이 아니라 16.2.3 이라 의존성을 다시 설치해도 패치 버전이 올라가지 않습니다. 고정은 빌드가 갑자기 깨지는 걸 막아 주지만, 그만큼 보안 패치도 손으로 챙겨야 한다는 뜻이었어요.
정적 블로그인데 왜 해당될까
"정적 페이지만 있는데 서버 취약점이 무슨 상관일까?" 싶을 수 있어요.
하지만 정적으로 굽는다고 Next 의 서버 쪽이 완전히 사라지는 건 아니에요. 배포된 사이트에는 요청이 올 때 Next 가 처리하는 부분(이미지 최적화, 라우팅 같은)이 여전히 남아 있습니다. 어느 취약점이 이 블로그에서 실제로 공격 가능한지 하나하나 따지는 것보다, 위험 목록에 오른 버전이면 올리는 게 훨씬 싸고 확실했어요.
Next 16.4.3 로 올리니 2건
next 를 16.4.3 으로 올렸습니다. 같은 저장소의 다른 웹 앱도 이미 같은 버전으로 올린 상태라, 버전을 맞추는 의미도 있었어요.
- "next": "16.2.3"
+ "next": "16.4.3"
테스트와 운영 빌드가 그대로 통과했고, audit 을 다시 돌리니 블로그 경로의 취약점이 41건 → 2건이 됐습니다.
남은 2건은 source-map-js 와 baseline-browser-mapping 이에요. 둘 다 Next 안쪽에서 빌드할 때만 쓰는 도구라, 블로그를 방문하는 사람의 요청이 닿는 경로가 없습니다. 고치려면 저장소 전체의 의존성을 강제로 올려야 해서 이번엔 그대로 두고, 다음 Next 업데이트 때 다시 확인하기로 했어요.
보안 헤더 다섯 줄
의존성과 별개로, 응답에 보안 헤더가 하나도 없었어요. 그래서 next.config.js 에 모든 페이지에 붙는 헤더를 넣었습니다.
const SECURITY_HEADERS = [
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()' },
]
const nextConfig = {
poweredByHeader: false,
async headers() {
return [{ source: '/:path*', headers: SECURITY_HEADERS }]
},
}
| 헤더 | 하는 일 |
|---|---|
frame-ancestors 'none' · X-Frame-Options: DENY | 다른 사이트가 이 블로그를 틀(iframe) 안에 넣지 못하게 — 겉을 씌워 속이는 클릭 유도를 막는다 |
X-Content-Type-Options: nosniff | 브라우저가 파일 종류를 멋대로 추측하지 않게 |
Referrer-Policy | 다른 사이트로 나갈 때 주소 전체가 아니라 도메인만 넘기게 |
Permissions-Policy | 카메라·마이크·위치·결제 기능을 쓰지 않는다고 못 박기 |
poweredByHeader: false | 응답에 x-powered-by: Next.js 를 안 붙여 쓰는 기술을 굳이 알리지 않게 |
CSP 전체(어떤 스크립트를 허용할지까지 정하는 것)는 이번엔 걸지 않았어요. 이 블로그에는 구글 애널리틱스, 애드센스, 유튜브 재생 창, 검색엔진용 구조 데이터 같은 스크립트가 섞여 있어서 하나하나 허용 목록을 맞춰야 하거든요. 대신 틀 안에 넣는 것만 막는 frame-ancestors 부터 걸었습니다. HSTS(항상 HTTPS 로 접속하게 하는 헤더)는 Vercel 이 알아서 붙여 줘요.
배포한 뒤 실제 응답에 붙는지도 확인했습니다.
curl -sI https://www.yangtinomad.com/
content-security-policy: frame-ancestors 'none'
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=63072000
x-content-type-options: nosniff
x-frame-options: DENY
x-powered-by 는 사라졌어요.
문제없었던 것들
의존성과 헤더 말고도 코드 쪽을 같이 훑었는데, 다행히 손볼 곳은 없었습니다.
- 검색엔진용 구조 데이터(JSON-LD) — 글 제목 같은 값을
<script>안에 넣을 때<를 이스케이프해서, 제목에</script>가 들어가도 스크립트가 끼어들 수 없어요. - 마크다운 본문 — HTML 을 그대로 렌더링하지 않습니다. 글에 HTML 태그를 써도 글자로 나와요.
- 외부 링크 — 새 탭으로 열리는 링크에는
noopener를 붙여, 열린 페이지가 이 블로그 창을 건드리지 못해요. - 글 이미지 경로 — 글 폴더 밖(
../)을 가리키면 빌드가 멈춥니다. - 비밀값 — 저장소에 키나 토큰이 없어요. 이미지 업로드용 권한은 내 PC 의 설정에만 있습니다.
- 분석·광고 스크립트 — 운영 배포에서만 켜지고, 로컬과 미리보기에서는 꺼져 있어요.
정리 — 정적 사이트도 해 볼 것
| 할 일 | 한 줄 |
|---|---|
| 의존성 점검 | pnpm audit --prod, 모노레포면 경로로 내 앱 것만 걸러 읽기 |
| 프레임워크 버전 | 버전을 고정해 뒀다면 패치는 손으로 — 위험 목록 버전이면 올린다 |
| 보안 헤더 | frame-ancestors · nosniff · Referrer-Policy · Permissions-Policy, x-powered-by 숨기기 |
| 확인 | 배포 뒤 curl -sI 로 실제 응답 헤더 보기 |
로그인도 DB 도 없는 정적 블로그였지만, 가장 큰 구멍은 내가 쓴 코드가 아니라 고정해 둔 프레임워크 버전에 있었어요. 정적 사이트라도 가끔은 audit 한 번 돌려 볼 만합니다.