From d5f332311e199e3517272851e8cc3835d51a277b Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Wed, 22 Jul 2026 03:54:30 +0000
Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]=20?=
=?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20CSP?=
=?UTF-8?q?=20=ED=95=B4=EC=8B=9C=20=EA=B3=84=EC=82=B0=20=EC=98=A4=EB=A5=98?=
=?UTF-8?q?=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- `cssContent`에 `.trimIndent()`를 적용하여 선행/후행 공백을 제거함
- HTML 템플릿에 `cssContent`를 주입할 때 `` 형식으로 수정함
- CSP 해시 계산 시 `cssContent.toByteArray()`가 브라우저가 인식하는 실제 인라인 스타일 문자열과 정확히 일치하게 됨
- CSP 해시 계산 및 검증을 위한 단위 테스트(`CspTest.kt`) 추가
---
.jules/sentinel.md | 4 ++
src/main/kotlin/html4tree/main.kt | 7 +--
src/test/kotlin/html4tree/CspTest.kt | 85 ++++++++++++++++++++++++++++
3 files changed, 91 insertions(+), 5 deletions(-)
create mode 100644 src/test/kotlin/html4tree/CspTest.kt
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index 6ecf72f..5db89ac 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -83,3 +83,7 @@
**Vulnerability:** 정적 HTML 생성 도구에서 매번 다른 Nonce를 동적으로 생성하여 CSP에 적용하는 것은, 캐싱 효율을 저하시킬 뿐만 아니라 정적 배포 환경(예: GitHub Pages 등)에서 올바른 보안 정책 수립을 방해할 수 있는 안티 패턴입니다.
**Learning:** 정적으로 고정된 인라인 스타일이나 스크립트에는 난수화된 Nonce보다 콘텐츠 자체의 해시(SHA-256 등)를 사용하는 것이 안전하고 일관된 방식임을 배웠습니다.
**Prevention:** 자동 생성되는 정적 HTML의 콘텐츠 보안 정책(CSP)에는 `style-src 'sha256-'` 방식을 적용하고, `
- """
+ val css = ""
val index_top = """
diff --git a/src/test/kotlin/html4tree/CspTest.kt b/src/test/kotlin/html4tree/CspTest.kt
new file mode 100644
index 0000000..c097671
--- /dev/null
+++ b/src/test/kotlin/html4tree/CspTest.kt
@@ -0,0 +1,85 @@
+package html4tree
+
+import org.junit.Test
+import java.security.MessageDigest
+import java.util.Base64
+import kotlin.test.assertTrue
+
+class CspTest {
+ @Test
+ fun testCspHashMatchesInjectedStyle() {
+ 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 {
+ padding: 0.5rem;
+ opacity: 0.7;
+ font-style: italic;
+ }
+ """.trimIndent()
+
+ val styleHash = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(cssContent.toByteArray(Charsets.UTF_8)))
+
+ val css = ""
+ val insideStyle = css.substringAfter("")
+ val styleHash2 = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(insideStyle.toByteArray(Charsets.UTF_8)))
+
+ assertTrue(styleHash == styleHash2, "The CSP hash must exactly match the injected inline style content.")
+ }
+}