Skip to content
12 changes: 12 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,15 @@
**Vulnerability:** ์ •์  HTML ์ƒ์„ฑ ๋„๊ตฌ์—์„œ ๋งค๋ฒˆ ๋‹ค๋ฅธ Nonce๋ฅผ ๋™์ ์œผ๋กœ ์ƒ์„ฑํ•˜์—ฌ CSP์— ์ ์šฉํ•˜๋Š” ๊ฒƒ์€, ์บ์‹ฑ ํšจ์œจ์„ ์ €ํ•˜์‹œํ‚ฌ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์ •์  ๋ฐฐํฌ ํ™˜๊ฒฝ(์˜ˆ: GitHub Pages ๋“ฑ)์—์„œ ์˜ฌ๋ฐ”๋ฅธ ๋ณด์•ˆ ์ •์ฑ… ์ˆ˜๋ฆฝ์„ ๋ฐฉํ•ดํ•  ์ˆ˜ ์žˆ๋Š” ์•ˆํ‹ฐ ํŒจํ„ด์ž…๋‹ˆ๋‹ค.
**Learning:** ์ •์ ์œผ๋กœ ๊ณ ์ •๋œ ์ธ๋ผ์ธ ์Šคํƒ€์ผ์ด๋‚˜ ์Šคํฌ๋ฆฝํŠธ์—๋Š” ๋‚œ์ˆ˜ํ™”๋œ Nonce๋ณด๋‹ค ์ฝ˜ํ…์ธ  ์ž์ฒด์˜ ํ•ด์‹œ(SHA-256 ๋“ฑ)๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•˜๊ณ  ์ผ๊ด€๋œ ๋ฐฉ์‹์ž„์„ ๋ฐฐ์› ์Šต๋‹ˆ๋‹ค.
**Prevention:** ์ž๋™ ์ƒ์„ฑ๋˜๋Š” ์ •์  HTML์˜ ์ฝ˜ํ…์ธ  ๋ณด์•ˆ ์ •์ฑ…(CSP)์—๋Š” `style-src 'sha256-<HASH>'` ๋ฐฉ์‹์„ ์ ์šฉํ•˜๊ณ , `<style>` ํƒœ๊ทธ์—์„œ ๋ถˆํ•„์š”ํ•œ `nonce` ์†์„ฑ์„ ์ œ๊ฑฐํ•˜์—ฌ ๋ธŒ๋ผ์šฐ์ €์˜ ๋ฌด๊ฒฐ์„ฑ ๊ฒ€์ฆ ๊ธฐ๋Šฅ์„ ์ ๊ทน ํ™œ์šฉํ•˜์‹ญ์‹œ์˜ค.
## 2026-07-21 - Fix CSP hash mismatch in style tag
**Vulnerability:** The Content-Security-Policy style-src hash didn't match the actual inline style content because the Kotlin template added newlines and spaces around the injected CSS content inside the <style> tag.
**Learning:** The browser hashes the exact text inside the <style> and </style> tags. When using string interpolation in Kotlin multiline strings, any surrounding whitespace inside the tags alters the final output, rendering the CSP hash invalid and blocking the style.
**Prevention:** When injecting content into a <style> or <script> tag for CSP, ensure the exact string passed to the hashing function matches the innerHTML of the tag perfectly, without any implicit padding or whitespace.
## 2026-07-21 - ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋ฅผ ํ†ตํ•œ ๋ถ€๋ชจ/์กฐ์ƒ ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ ํƒ์ƒ‰ ์ทจ์•ฝ์  ์™„ํ™”
**Vulnerability:** ๋””๋ ‰ํ† ๋ฆฌ ํฌ๋กค๋Ÿฌ๊ฐ€ ํ˜„์žฌ ๊ฒฝ๋กœ์˜ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋งŒ ํ™•์ธ()ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ์ค‘๊ฐ„ ๊ฒฝ๋กœ(์กฐ์ƒ ๋””๋ ‰ํ† ๋ฆฌ)์— ํฌํ•จ๋œ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋ฅผ ๊ฑฐ์ณ์„œ ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ์™ธ๋ถ€ ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์ˆœํšŒํ•˜๊ณ  ์ธ๋ฑ์Šค๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ์ทจ์•ฝ์ ()๊ณผ ์ž„์‹œ ํŒŒ์ผ ์Šค์™‘ TOCTOU()์ด ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
**Learning:** ํŒŒ์ผ ๊ฒฝ๋กœ์˜ ๋งˆ์ง€๋ง‰ ์ปดํฌ๋„ŒํŠธ๋งŒ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋ฅผ ๊ฒ€์‚ฌํ•˜๋Š” ๊ฒƒ์€ ๋ถˆ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. ์ ˆ๋Œ€ ๊ฒฝ๋กœ ์ƒ์˜ ์กฐ์ƒ ๋””๋ ‰ํ† ๋ฆฌ ์ค‘ ํ•˜๋‚˜๋ผ๋„ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋ผ๋ฉด ์ƒŒ๋“œ๋ฐ•์Šค๋ฅผ ๋ฒ—์–ด๋‚  ์œ„ํ—˜์ด ์žˆ์œผ๋ฏ€๋กœ, ํ•ด๋‹น ๊ฒฝ๋กœ์— ํŒŒ์ผ์„ ์“ฐ๋Š” ๊ฒƒ์„ ์ฐจ๋‹จํ•ด์•ผ ํ•˜๋ฉฐ, ์ž„์‹œ ํŒŒ์ผ ์ƒ์„ฑ ๋ฐ ์“ฐ๊ธฐ์—๋„ ์˜ต์…˜์„ ์ ์šฉํ•ด ์Šค์™‘ ๊ณต๊ฒฉ์„ ๋ฐฉ์–ดํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
**Prevention:** ๊ฒฝ๋กœ ์ •๊ทœํ™” ํ›„ ๋ฃจํ”„๋ฅผ ํ†ตํ•ด ์กฐ์ƒ ๋””๋ ‰ํ† ๋ฆฌ ์ค‘ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๋กœ์ง์„ ์ถ”๊ฐ€ํ•˜๊ณ , ํŒŒ์ผ ์‹œ์Šคํ…œ IO ์˜คํผ๋ ˆ์ด์…˜()์—๋„ ๋งํฌ ์ถ”์ ์„ ์ œํ•œํ•˜์‹ญ์‹œ์˜ค.
Comment on lines +90 to +93
## 2026-07-21 - ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋ฅผ ํ†ตํ•œ ์กฐ์ƒ ๋””๋ ‰ํ† ๋ฆฌ ํƒ์ƒ‰ ์ทจ์•ฝ์ (vuln-0002) ์™„ํ™”
**Vulnerability:** ๋””๋ ‰ํ† ๋ฆฌ ํฌ๋กค๋Ÿฌ๊ฐ€ ๋งˆ์ง€๋ง‰ ๊ฒฝ๋กœ์˜ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋งŒ ํ™•์ธํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ์กฐ์ƒ ๋””๋ ‰ํ† ๋ฆฌ์— ์œ„์น˜ํ•œ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋ฅผ ๊ฑฐ์ณ์„œ ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ์™ธ๋ถ€ ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์ˆœํšŒํ•˜๊ณ  ์ธ๋ฑ์Šค๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ์ทจ์•ฝ์ ์ด ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
**Learning:** ํŒŒ์ผ ๊ฒฝ๋กœ์˜ ๋งˆ์ง€๋ง‰ ์ปดํฌ๋„ŒํŠธ๋งŒ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋ฅผ ๊ฒ€์‚ฌํ•˜๋Š” ๊ฒƒ์€ ๋ถˆ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. ์ ˆ๋Œ€ ๊ฒฝ๋กœ ์ƒ์˜ ์กฐ์ƒ ๋””๋ ‰ํ† ๋ฆฌ ์ค‘ ํ•˜๋‚˜๋ผ๋„ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋ผ๋ฉด ์ƒŒ๋“œ๋ฐ•์Šค๋ฅผ ๋ฒ—์–ด๋‚  ์œ„ํ—˜์ด ์žˆ์œผ๋ฏ€๋กœ ํƒ์ƒ‰์„ ์ฐจ๋‹จํ•ด์•ผ ํ•˜๋ฉฐ, ์ž„์‹œ ํŒŒ์ผ ์“ฐ๊ธฐ์—๋„ NOFOLLOW_LINKS ์˜ต์…˜์„ ์ ์šฉํ•ด ์Šค์™‘ ๋ ˆ์ด์Šค ์ปจ๋””์…˜์„ ๋ฐฉ์–ดํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
**Prevention:** ๊ฒฝ๋กœ ์ •๊ทœํ™” ํ›„ ๋ฃจํ”„๋ฅผ ํ†ตํ•ด ์กฐ์ƒ ๋””๋ ‰ํ† ๋ฆฌ ์ค‘ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๋กœ์ง์„ ์ถ”๊ฐ€ํ•˜๊ณ , ํŒŒ์ผ ์‹œ์Šคํ…œ IO ์˜คํผ๋ ˆ์ด์…˜(Files.write ๋“ฑ)์—๋„ ๋งํฌ ์ถ”์ ์„ ์ œํ•œํ•˜๋Š” ์˜ต์…˜์„ ์ถ”๊ฐ€ํ•˜์‹ญ์‹œ์˜ค.
21 changes: 17 additions & 4 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ internal fun read_file_identity(file: File): FileIdentity {
}
}

fun has_symlink_ancestor(file: File): Boolean {
var path = file.toPath().toAbsolutePath().normalize()
while (path != null) {
if (Files.isSymbolicLink(path)) {
return true
}
path = path.parent
}
return false
Comment on lines +40 to +48
}

fun go(topDir: String, maxLevel: Int) {
require(topDir.isNotBlank())
require(!topDir.contains("..")) { "Path traversal sequences are not allowed." }
Expand Down Expand Up @@ -94,7 +105,7 @@ internal fun crawl_directories(
dirFiles?.forEach {
// โšก Bolt Performance Optimization: Short-circuit OS stat calls (isDirectory/isSymbolicLink)
// by checking cheap in-memory string exclusion rules first
if(!it.name.startsWith(".") && it.name !in exclude && isDirectory(it) && !isSymbolicLink(it)) {
if(!it.name.startsWith(".") && it.name !in exclude && isDirectory(it) && !isSymbolicLink(it) && !has_symlink_ancestor(it)) {
val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key)
Comment on lines 106 to 109
ll.push(childEntry)
}
Expand Down Expand Up @@ -230,10 +241,13 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S
}

fun write_index_file(curr_dir: File, content: String) {
if (has_symlink_ancestor(curr_dir)) {
return
}
val indexPath = curr_dir.toPath().resolve("index.html")
val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html")
try {
Files.write(tempPath, content.toByteArray(Charsets.UTF_8))
Files.write(tempPath, content.toByteArray(Charsets.UTF_8), LinkOption.NOFOLLOW_LINKS)
Files.move(tempPath, indexPath, StandardCopyOption.REPLACE_EXISTING)
Comment on lines 248 to 251
} finally {
Files.deleteIfExists(tempPath)
Expand Down Expand Up @@ -313,8 +327,7 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array
val styleHash = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(cssContent.toByteArray(Charsets.UTF_8)))

val css = """
<style>
${cssContent} </style>
<style>${cssContent}</style>
"""

val index_top = """<!doctype html>
Expand Down
35 changes: 35 additions & 0 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,41 @@ class MainTest {
assertEquals("A1z", "A1z".urlEncodePath())
}

@Test
fun testSymlinkedAncestorRejection() {
val subdir = File(tempDir, "real_dir")
subdir.mkdir()
val symlinkDir = File(tempDir, "symlink_dir")
try {
Files.createSymbolicLink(symlinkDir.toPath(), subdir.toPath())
} catch (e: Exception) {
Assume.assumeTrue("Symlink creation not supported in this environment", false)
}
val targetInSymlink = File(symlinkDir, "target_dir")
targetInSymlink.mkdir()

process_dir(targetInSymlink)
val indexFile = File(targetInSymlink, "index.html")
assertFalse(indexFile.exists(), "Should not write index file when ancestor is a symlink")
}

@Test
fun testCspHashMatch() {
val tempDir2 = File(tempDir, "cspDir")
tempDir2.mkdir()
process_dir(tempDir2)
val indexFile = File(tempDir2, "index.html")
val content = indexFile.readText()
val hashRegex = Regex("style-src '([^']+)'")
val match = hashRegex.find(content)
val actualHash = match?.groupValues?.get(1)
val styleContentRegex = Regex("<style>([\\s\\S]*?)</style>")
val styleMatch = styleContentRegex.find(content)
val styleContent = styleMatch?.groupValues?.get(1)
val expectedHash = "sha256-" + java.util.Base64.getEncoder().encodeToString(java.security.MessageDigest.getInstance("SHA-256").digest(styleContent!!.toByteArray(Charsets.UTF_8)))
Comment on lines +561 to +565
assertEquals(expectedHash, actualHash)
}

@Test
fun testUrlEncodePathReservedHexCoverage() {
// Need characters that produce hex digit > 9 to hit the `else` branch of `if (hex1 < 10)` and `if (hex2 < 10)`.
Expand Down
Loading