RSS듀오랩스
보안

Next.js 보안 헤더와 색인 차단: 새 프로젝트마다 같은 설정을 두는 이유

작성자
듀오랩스 대표·9분 읽기

Vercel 로 옮기고 나서 응답 헤더를 다시 봤더니 보안 헤더가 하나도 없었습니다. 예전에는 오리진 앞의 nginx 가 붙여 주고 있었는데, 그 nginx 가 경로에서 빠지면서 전 경로에서 같이 사라진 것입니다. 앱은 그대로였고 오류도 없었습니다. 헤더는 없어져도 화면이 멀쩡해서, 재보기 전에는 모릅니다.

그래서 지금은 헤더를 프록시가 아니라 next.config.ts 에 둡니다. 앱이 어디로 가든 헤더가 같이 갑니다. 오늘 공개 상태 페이지(ops.duolabs.co.kr)를 새로 만들면서 그 설정을 또 한 벌 넣었는데, 매번 같은 줄을 넣으면서도 왜 그 값인지는 여러 리포의 주석에 흩어져 있어서 한 번 모아 적습니다.

헤더가 앱과 함께 다녀야 하는 이유

프록시에 둔 설정은 프록시가 바뀌면 사라집니다. 저희는 그걸 실제로 겪었습니다. 오리진 앞에 nginx 가 있을 때 거기서 보안 헤더를 붙였고, Vercel 로 옮기면서 nginx 가 경로에서 빠졌습니다. 헤더가 빠졌다는 사실을 알려 주는 것은 아무것도 없었습니다.

next.config.tsheaders() 에 두면 배포 대상이 Docker 든 Vercel 이든 같은 헤더가 나갑니다. 프록시가 한 겹 더 붙어도 앱이 보내는 헤더는 그대로 지나갑니다. 한 벌만 관리하면 되고, 코드 리뷰에서 보입니다.

지금 새 프로젝트마다 들어가는 것은 이 다섯 줄과 CSP 한 덩어리입니다.

헤더
Content-Security-Policy 아래 절
X-Content-Type-Options nosniff
X-Permitted-Cross-Domain-Policies none
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera=(), microphone=(), geolocation=(), payment=(), usb=()

CSP 에 'unsafe-inline' 을 남긴 채로 시작하는 이유

script-src'unsafe-inline' 이 있으면 CSP 를 넣은 의미의 절반이 사라진다는 말을 많이 합니다. 맞는 말이고, 그래도 저희 기본값에는 들어 있습니다. Next.js 가 페이지를 띄울 때 인라인 스크립트로 부트스트랩을 하기 때문입니다.

정석은 요청마다 nonce 를 만들어 그 인라인 스크립트에 붙이는 것입니다. 그런데 그건 페이지가 요청마다 렌더될 때만 됩니다. 정적으로 만들어 CDN 에 굳혀 두는 페이지에는 요청마다 다른 nonce 를 넣을 수 없습니다. 오늘 만든 상태 페이지가 정확히 그런 페이지라, 거기서는 'unsafe-inline' 을 빼는 대신 나머지를 전부 좁혔습니다.

default-src 'self' 로 시작해서 frame-src 'none', object-src 'none', base-uri 'self' 를 두고, 밖으로 나가는 요청은 connect-src 'self' 로 막습니다. 스크립트 한 줄이 어디선가 끼어들어도 그 스크립트가 데이터를 밖으로 보낼 길은 없습니다. 'unsafe-inline' 하나 때문에 CSP 전체를 포기하는 것보다 이쪽이 낫다고 봅니다.

frame-ancestors 가 X-Frame-Options 를 대신하는 자리

클릭재킹을 막는 헤더로 X-Frame-Options 를 먼저 떠올리는데, 저희는 넣지 않습니다. CSP 의 frame-ancestors 가 같은 일을 하고 더 세밀하게 정할 수 있습니다. 둘 다 있으면 브라우저는 frame-ancestors 를 따르고 X-Frame-Options 를 무시하게 돼 있어서, 두 값을 관리할 이유가 없습니다.

값은 'self' https://duolabs.co.kr https://*.duolabs.co.kr 입니다. 회사 랜딩이 각 제품의 데모를 iframe 모달로 띄우기 때문에 랜딩 도메인을 열어 둬야 합니다. 이걸 'none' 으로 잠그면 랜딩에서 데모 버튼을 눌렀을 때 빈 창이 8초 동안 떠 있다가 「새 탭에서 열기」로 떨어집니다. 어느 쪽이 막았는지는 화면에 안 나옵니다. 새 프로젝트에서 이 줄을 빼먹으면 랜딩 쪽에서 먼저 알게 됩니다.

HSTS 를 넣지 않는 이유

Strict-Transport-Security 는 목록에 없습니다. Vercel 이 이미 max-age=63072000, 2년짜리를 보내고 있어서입니다. 같은 헤더가 둘 오면 브라우저는 첫 번째 것만 보게 돼 있고(RFC 6797 8.1), 어느 것이 첫 번째가 되는지는 플랫폼이 정합니다. 저희가 더 짧은 값을 하나 더 보내서 얻는 것은 없고, 그게 채택될 가능성만 생깁니다.

다만 이건 Vercel 에 있을 때만 참입니다. 같은 앱을 자체 프록시 뒤로 옮기면 그 프록시가 HSTS 를 보내는지 확인해야 하고, 안 보내면 그때는 목록에 들어갑니다.

설명이 한 줄로 끝나는 넷

X-Content-Type-Options: nosniff 는 브라우저가 응답의 Content-Type 을 믿게 합니다. 이게 없으면 텍스트로 올린 파일을 브라우저가 HTML 로 추측해서 실행할 수 있습니다.

Referrer-Policy: strict-origin-when-cross-origin 은 다른 도메인으로 갈 때 경로를 떼고 origin 만 보냅니다. 내부 URL 에 담긴 정보가 외부 링크의 로그에 남지 않게 하려는 것입니다.

Permissions-Policy 는 카메라, 마이크, 위치, 결제, USB 를 전부 끕니다. 쓰는 기능이 생기면 그 항목만 엽니다. 끄는 것이 기본이고 여는 것이 예외입니다.

X-Powered-BypoweredByHeader: false 로 끕니다. 「Next.js 입니다」를 응답마다 광고할 이유가 없습니다.

*.vercel.app 을 색인에서 빼는 방법이 canonical 이 아닌 이유

Vercel 에 올리면 운영 도메인 말고도 프로젝트.vercel.app 주소가 생기고, 프리뷰 배포마다 주소가 하나씩 더 생깁니다. 전부 같은 내용을 냅니다. 검색 엔진이 그걸 따로 색인하면 같은 글이 두 주소로 잡히고, 어느 쪽이 원본인지를 검색 엔진이 정하게 됩니다.

<link rel="canonical"> 로 원본을 알려 주는 방법이 먼저 떠오르는데, canonical 은 힌트이지 명령이 아닙니다. 검색 엔진이 다른 판단을 할 수 있습니다. 원 서버가 직접 거절해야 확실합니다.

그래서 next.config.tsheaders() 에 host 매처를 걸어, 요청의 host 가 .vercel.app 으로 끝나면 X-Robots-Tag: noindex, nofollow 를 붙입니다. 운영 도메인으로 들어온 요청에는 붙지 않습니다. 프리뷰 배포도 같은 규칙에 걸립니다.

{
  source: "/(.*)",
  has: [{ type: "host", value: ".*\\.vercel\\.app" }],
  headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
}

외부 리소스가 없는 앱을 더 좁히는 이유

오늘 만든 상태 페이지는 외부 이미지도, 폼도, 클라이언트에서 부르는 API 도 없습니다. 그래서 형제 프로젝트의 기본값에서 두 줄을 더 조였습니다. img-src 에서 https: 를 빼고, form-action'self' 에서 'none' 으로 바꿨습니다.

기본값을 복사해서 그대로 두는 편이 편합니다. 그런데 그 앱이 안 쓰는 권한을 열어 둔 것은 그냥 열려 있는 것입니다. 항목을 여는 일이 생기면 그때 그 한 줄만 열면 됩니다. 반대로 나중에 좁히려면 무엇이 어디서 쓰이는지 다시 다 봐야 합니다.

차단 규칙이 앱을 깨는 순간

헤더가 아니라 프록시의 차단 규칙 이야기인데, 교훈이 같아서 같이 적습니다. 2026-08-17 에 리버스 프록시에서 /vendor/* 경로를 404 로 끊는 규칙을 넣었습니다. PHP 앱의 vendor 디렉터리를 노리는 스캐너를 막으려는 것이었습니다.

넣자마자 지도가 깨졌습니다. 저희 앱 둘이 MapLibre 를 /vendor/maplibre/ 아래에서 서빙하고 있었습니다. 규칙을 뺐고, 같이 넣었던 wp-login.php 류의 워드프레스 규칙도 뺐습니다. 저희에게 PHP 앱이 하나도 없어서, 없는 것을 막아 얻는 이득은 없고 오탐 위험만 커진다는 것을 그때 알았습니다.

보안 설정은 넣는 순간에는 아무것도 안 깨지는 것처럼 보입니다. 깨지는 것은 그 경로를 실제로 쓰는 화면이고, 그 화면은 보통 다른 사람이 엽니다. 규칙을 넣기 전에 「우리 앱이 이 경로를 쓰는가」를 먼저 찾아보는 것이 지금은 순서입니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.