diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6ecf72f1..cdf88010 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -83,3 +83,8 @@ **Vulnerability:** 정적 HTML 생성 도구에서 매번 다른 Nonce를 동적으로 생성하여 CSP에 적용하는 것은, 캐싱 효율을 저하시킬 뿐만 아니라 정적 배포 환경(예: GitHub Pages 등)에서 올바른 보안 정책 수립을 방해할 수 있는 안티 패턴입니다. **Learning:** 정적으로 고정된 인라인 스타일이나 스크립트에는 난수화된 Nonce보다 콘텐츠 자체의 해시(SHA-256 등)를 사용하는 것이 안전하고 일관된 방식임을 배웠습니다. **Prevention:** 자동 생성되는 정적 HTML의 콘텐츠 보안 정책(CSP)에는 `style-src 'sha256-'` 방식을 적용하고, ``와 같이 공백 없이 주입하여 해시가 완벽하게 일치하도록 해야 합니다. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..34310f4b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +### Fixed + +- Generate the inline-style Content Security Policy SHA-256 source expression + from the exact normalized UTF-8 stylesheet bytes emitted into each generated + `index.html` file, preventing template whitespace from invalidating the policy. + +### Tests + +- Add a real generated-file regression test that independently recomputes the + declared style hash from the emitted ` - """ - val index_top = """ @@ -327,11 +322,11 @@ ${cssContent} - + ${curr_dir.getName().escapeHtml()} - ${css} +
diff --git a/src/test/kotlin/html4tree/CspHashTest.kt b/src/test/kotlin/html4tree/CspHashTest.kt new file mode 100644 index 00000000..388e3155 --- /dev/null +++ b/src/test/kotlin/html4tree/CspHashTest.kt @@ -0,0 +1,46 @@ +package html4tree + +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CspHashTest { + @Test + fun emittedStyleBytesMatchTheDeclaredCspHash() { + val directory = Files.createTempDirectory("html4tree-csp-").toFile() + + try { + process_dir(directory, setOf("index.html"), emptyArray()) + + val html = File(directory, "index.html").readText(Charsets.UTF_8) + val styleContent = Regex("""""") + .find(html) + ?.groupValues + ?.get(1) + val declaredHash = Regex("""style-src 'sha256-([^']+)'""") + .find(html) + ?.groupValues + ?.get(1) + + assertNotNull(styleContent, "Generated HTML must contain one inline style block") + assertNotNull(declaredHash, "Generated HTML must declare a SHA-256 style source") + assertEquals(styleContent.trim(), styleContent, "Hashed style bytes must not gain template padding") + + val actualHash = Base64.getEncoder().encodeToString( + MessageDigest.getInstance("SHA-256") + .digest(styleContent.toByteArray(Charsets.UTF_8)) + ) + assertEquals(declaredHash, actualHash) + assertTrue(styleContent.startsWith("body {")) + assertTrue(styleContent.endsWith("}")) + } finally { + directory.listFiles()?.forEach { it.delete() } + directory.delete() + } + } +}