From 2aa810ab6dc769483749487bfa74ed552fcc7802 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:31:26 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[securi?= =?UTF-8?q?ty=20improvement]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 5 + src/main/kotlin/html4tree/main.kt | 151 +++++++++++++++--------------- 2 files changed, 78 insertions(+), 78 deletions(-) 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/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 29eef0c4..e93fbea7 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -13,6 +13,77 @@ import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.types.int +private val CSS_CONTENT = """ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + padding: 1rem; + color: #1f2328; +} +main { + max-width: 800px; + margin: 0 auto; +} +ul { + list-style-type: none; + padding-left: 0; +} +a.dir-link { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; + overflow-wrap: anywhere; + box-sizing: border-box; +} +.icon { + flex-shrink: 0; + width: 1.25rem; + text-align: center; +} +a { + padding: 0.5rem; + text-decoration: none; + color: #0969da; + border-radius: 4px; + transition: background-color 0.2s ease, outline-color 0.2s ease; +} +a:hover, a:focus-visible { + background-color: #f6f8fa; + text-decoration: underline; + outline: 2px solid #0969da; + outline-offset: -2px; +} +@media (prefers-reduced-motion: reduce) { + a { + transition: none; + } +} +@media (prefers-color-scheme: dark) { + body { + background-color: #0d1117; + color: #c9d1d9; + } + a { + color: #58a6ff; + } + a:hover, a:focus-visible { + background-color: #161b22; + outline-color: #58a6ff; + } +} +.empty-dir { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem; + opacity: 0.7; + font-style: italic; +} +""".trimIndent() + +private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) + class Html4tree : CliktCommand() { val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) val topDir: String by argument(help="Top directory to crawl") @@ -244,82 +315,6 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) - val cssContent = """ - body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - line-height: 1.5; - padding: 1rem; - color: #1f2328; - } - main { - max-width: 800px; - margin: 0 auto; - } - ul { - list-style-type: none; - padding-left: 0; - } - a.dir-link { - display: flex; - align-items: flex-start; - gap: 0.5rem; - width: 100%; - overflow-wrap: anywhere; - box-sizing: border-box; - } - .icon { - flex-shrink: 0; - width: 1.25rem; - text-align: center; - } - a { - padding: 0.5rem; - text-decoration: none; - color: #0969da; - border-radius: 4px; - transition: background-color 0.2s ease, outline-color 0.2s ease; - } - a:hover, a:focus-visible { - background-color: #f6f8fa; - text-decoration: underline; - outline: 2px solid #0969da; - outline-offset: -2px; - } - @media (prefers-reduced-motion: reduce) { - a { - transition: none; - } - } - @media (prefers-color-scheme: dark) { - body { - background-color: #0d1117; - color: #c9d1d9; - } - a { - color: #58a6ff; - } - a:hover, a:focus-visible { - background-color: #161b22; - outline-color: #58a6ff; - } - } - .empty-dir { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.5rem; - opacity: 0.7; - font-style: italic; - } - """ - - val styleHash = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(cssContent.toByteArray(Charsets.UTF_8))) - - val css = """ - - """ - val index_top = """ @@ -327,11 +322,11 @@ ${cssContent} - + ${curr_dir.getName().escapeHtml()} - ${css} +
From ab12202285927f005c83646975d4647d726618fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 13:54:20 +0900 Subject: [PATCH 2/4] test(csp): verify emitted style hash byte identity --- src/test/kotlin/html4tree/CspHashTest.kt | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/test/kotlin/html4tree/CspHashTest.kt 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() + } + } +} From df8df7cd45bc2ff642cd6164317c5ca78ac92e95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 13:59:03 +0900 Subject: [PATCH 3/4] docs(csp): record inline style hash contract --- .../csp-inline-style-byte-identity.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/doctoring/csp-inline-style-byte-identity.md diff --git a/docs/doctoring/csp-inline-style-byte-identity.md b/docs/doctoring/csp-inline-style-byte-identity.md new file mode 100644 index 00000000..1a2d1f8a --- /dev/null +++ b/docs/doctoring/csp-inline-style-byte-identity.md @@ -0,0 +1,60 @@ +# Inline style CSP byte-identity contract + +## Decision + +html4tree emits one inline `