콘텐츠로 이동

정적 호스팅에서 nonce 없이 CSP 세우기

CSP 를 제대로 세우려면 인라인 스크립트마다 요청별 nonce 를 붙여야 합니다. 그런데 정적 사이트에는 요청을 받는 서버가 없습니다. 그래서 대부분 'unsafe-inline' 을 넣고 끝내는데, 그러면 CSP 가 사실상 없는 것과 같습니다.

정적 호스팅에서 nonce 에 해당하는 것은 해시입니다. 빌드 결과의 인라인 스크립트를 전부 찾아 sha256 을 계산해 정책에 넣으면 됩니다.

빌드가 끝난 뒤 dist/ 를 훑습니다.

const INLINE_SCRIPT = /<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g
const INLINE_STYLE = /<style[^>]*>([\s\S]*?)<\/style>/g
const sha256 = (src) =>
`'sha256-${createHash('sha256').update(src, 'utf8').digest('base64')}'`
for (const file of htmlFiles) {
const html = await readFile(file, 'utf8')
for (const [, body] of html.matchAll(INLINE_SCRIPT)) scripts.add(sha256(body))
for (const [, body] of html.matchAll(INLINE_STYLE)) styles.add(sha256(body))
}

해시는 태그 사이의 내용 그대로를 대상으로 합니다. 앞뒤 공백과 줄바꿈까지 포함해서요. 한 글자만 달라도 정책이 그 스크립트를 막습니다.

그리고 _headers 를 만듭니다.

/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-...' 'sha256-...'; ...

이 파일은 빌드마다 다시 생성돼야 합니다. 손으로 고치면 다음 빌드에 지워지고, 더 나쁘게는 낡은 해시가 남아 스크립트가 막힙니다.

문서 사이트에 검색을 붙이면(Pagefind 같은 것) 정책을 조금 열어야 합니다.

script-src 'self' 'wasm-unsafe-eval' 'sha256-...';
worker-src 'self' blob:;

색인을 WebAssembly 로 돌리고 워커를 띄우기 때문입니다. 'wasm-unsafe-eval' 은 'unsafe-eval' 과 다릅니다 — WebAssembly 컴파일만 허용하고 문자열을 코드로 실행하는 것은 여전히 막습니다.

이게 가장 좋은 상태입니다. 이 위키의 자매 사이트인 작업일지는 인라인 스크립트가 하나도 없어서 script-src 가 'self' 뿐입니다. 해시조차 필요 없습니다.

CSP 가 잘못되면 화면은 멀쩡하게 그려지고 기능만 조용히 죽습니다. 문서가 안 보이는 게 아니라 검색 상자에 입력해도 결과가 안 나오는 식입니다. 눈으로는 알아채기 어렵습니다.

그래서 실제 브라우저로 확인해야 합니다.

// 헤더를 실제로 붙인 채 띄우고
page.on('console', (m) => m.type() === 'error' && problems.push(m.text()))
await page.addInitScript(() => {
window.__csp = []
document.addEventListener('securitypolicyviolation', (e) =>
window.__csp.push(`${e.violatedDirective} blocked ${e.blockedURI}`),
)
})
// 테마 토글을 눌러 보고, 검색창에 입력해 결과가 나오는지 본다

securitypolicyviolation 이벤트를 듣는 것이 핵심입니다. 콘솔 오류만 보면 놓치는 것이 있습니다.

배포 검증에도 넣어 두십시오. 라이브 응답의 script-src 에 'unsafe-inline' 이 들어갔는지만 봐도 대부분의 사고를 잡습니다.

외부에서 받아오는 것이 없다면 전부 'self' 로 닫을 수 있습니다.

default-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'none';
object-src 'none';

서체를 자체 호스팅하면 font-src 에 외부 출처를 열 필요가 없습니다. 구글 폰트를 쓰면 fonts.googleapis.com 과 fonts.gstatic.com 을 열어야 하고, 그만큼 정책이 넓어집니다.

style-src 는 조금 다릅니다. 요소에 직접 붙는 style 속성은 해시로 잡을 수 없어서 style-src-attr 'unsafe-inline' 을 따로 두게 됩니다. 스크립트 실행과는 무관하므로 위험도가 다릅니다.