Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@
**Vulnerability:** 정적 HTML 생성 도구에서 매번 다른 Nonce를 동적으로 생성하여 CSP에 적용하는 것은, 캐싱 효율을 저하시킬 뿐만 아니라 정적 배포 환경(예: GitHub Pages 등)에서 올바른 보안 정책 수립을 방해할 수 있는 안티 패턴입니다.
**Learning:** 정적으로 고정된 인라인 스타일이나 스크립트에는 난수화된 Nonce보다 콘텐츠 자체의 해시(SHA-256 등)를 사용하는 것이 안전하고 일관된 방식임을 배웠습니다.
**Prevention:** 자동 생성되는 정적 HTML의 콘텐츠 보안 정책(CSP)에는 `style-src 'sha256-<HASH>'` 방식을 적용하고, `<style>` 태그에서 불필요한 `nonce` 속성을 제거하여 브라우저의 무결성 검증 기능을 적극 활용하십시오.

## 2026-08-05 - [html4tree] CSP Hash 무효화 방지를 위한 인라인 스타일 여백 제거
**Vulnerability:** CSP 해시 불일치로 인한 인라인 스타일 차단
**Learning:** 브라우저는 인라인 스크립트와 스타일의 내부 텍스트(공백과 줄바꿈 포함)를 정확하게 해싱하여 Content-Security-Policy(CSP) 해시와 비교합니다. Kotlin의 멀티라인 문자열(`"""`)을 사용하여 템플릿에 콘텐츠를 주입할 때 암묵적인 여백이나 줄바꿈이 추가되면 최종 HTML 문자열이 변경되어 CSP 해시가 무효화됩니다.
**Prevention:** 콘텐츠를 해싱하기 전에 `.trimIndent()`를 적용하여 원본 문자열을 정규화하고, HTML 템플릿에 주입할 때 `<style>${exactContent}</style>`와 같이 공백 없이 주입하여 해시가 완벽하게 일치하도록 해야 합니다.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<style>` text.

### Documentation

- Record the CSP byte-identity decision, threat boundary, verification contract,
and current W3C Working Draft reference in `docs/doctoring`.
60 changes: 60 additions & 0 deletions docs/doctoring/csp-inline-style-byte-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Inline style CSP byte-identity contract

## Decision

html4tree emits one inline `<style>` block in every generated `index.html` file.
The stylesheet is normalized once, encoded as UTF-8, and hashed with SHA-256.
The exact same normalized string is then inserted between the `<style>` tags.
Template indentation, leading newlines, and trailing newlines are not added to the
hashed source.

This is a byte-identity requirement rather than a merely visual CSS-equivalence
requirement. Whitespace and capitalization changes can preserve CSS rendering
semantics while changing the Content Security Policy hash.

## Threat and failure model

The generated document uses a restrictive policy with `default-src 'none'` and a
single `style-src` hash-source. If the declared digest does not match the exact
inline style bytes, a conforming user agent blocks the stylesheet. The result is
an availability and integrity failure: users receive an unstyled directory index
and may lose focus, contrast, or reduced-motion behavior that the stylesheet was
intended to provide.

The fix does not add `unsafe-inline`, a nonce, a network stylesheet, or a broader
source expression. It also does not treat CSP as a substitute for HTML escaping,
path validation, or filesystem access controls.

## Standards interpretation

Content Security Policy Level 3 defines hash matching by UTF-8 encoding the
inline source, applying the selected digest algorithm, Base64-encoding the digest,
and comparing it with the hash-source value. Inline style is blocked when the
source list does not authorize the exact block. Therefore the implementation must
hash the style element's text content, excluding the `<style>` start and end tags,
without relying on CSS-equivalent normalization by the browser.

The cited CSP Level 3 document is a W3C Working Draft and may change. This project
uses its current matching algorithm as an engineering contract and makes no claim
of formal W3C conformance.

## Verification contract

`CspHashTest.emittedStyleBytesMatchTheDeclaredCspHash` performs a product-level
round trip:

1. create a real temporary directory;
2. generate its `index.html` through `process_dir`;
3. extract the emitted style text and declared SHA-256 source expression;
4. assert that the emitted style has no template padding;
5. hash the exact UTF-8 style text independently; and
6. require byte-for-byte digest equality.

The repository build additionally enforces the existing JaCoCo coverage threshold,
CI tests, SAST, and security checks on the exact pull-request head.

## Reference

West, M., & Sartori, A. (Eds.). (2026, July 29). *Content Security Policy Level 3*
(W3C Working Draft). World Wide Web Consortium.
https://www.w3.org/TR/2026/WD-CSP3-20260729/
151 changes: 73 additions & 78 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -244,94 +315,18 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array

val exclude: Set<String> = 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 = """
<style>
${cssContent} </style>
"""

val index_top = """<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<!-- 보안 향상: 인라인 스크립트 실행 방지 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src '${styleHash}'; base-uri 'none'; form-action 'none';">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src '${STYLE_HASH}'; base-uri 'none'; form-action 'none';">
<!-- 보안 향상: 리퍼러를 통한 디렉토리 경로 노출 방지 -->
<meta name="referrer" content="no-referrer">
<title>${curr_dir.getName().escapeHtml()}</title>
${css}
<style>${CSS_CONTENT}</style>
</head>
<body>
<main>
Expand Down
46 changes: 46 additions & 0 deletions src/test/kotlin/html4tree/CspHashTest.kt
Original file line number Diff line number Diff line change
@@ -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<File>())

val html = File(directory, "index.html").readText(Charsets.UTF_8)
val styleContent = Regex("""<style>([\s\S]*?)</style>""")
.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()
}
}
}
Loading