Skip to content
Closed
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
15 changes: 15 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,18 @@
**Vulnerability:** 정적 HTML 생성 λ„κ΅¬μ—μ„œ 맀번 λ‹€λ₯Έ Nonceλ₯Ό λ™μ μœΌλ‘œ μƒμ„±ν•˜μ—¬ CSP에 μ μš©ν•˜λŠ” 것은, 캐싱 νš¨μœ¨μ„ μ €ν•˜μ‹œν‚¬ 뿐만 μ•„λ‹ˆλΌ 정적 배포 ν™˜κ²½(예: GitHub Pages λ“±)μ—μ„œ μ˜¬λ°”λ₯Έ λ³΄μ•ˆ μ •μ±… μˆ˜λ¦½μ„ λ°©ν•΄ν•  수 μžˆλŠ” μ•ˆν‹° νŒ¨ν„΄μž…λ‹ˆλ‹€.
**Learning:** μ •μ μœΌλ‘œ κ³ μ •λœ 인라인 μŠ€νƒ€μΌμ΄λ‚˜ μŠ€ν¬λ¦½νŠΈμ—λŠ” λ‚œμˆ˜ν™”λœ Nonce보닀 μ½˜ν…μΈ  자체의 ν•΄μ‹œ(SHA-256 λ“±)λ₯Ό μ‚¬μš©ν•˜λŠ” 것이 μ•ˆμ „ν•˜κ³  μΌκ΄€λœ λ°©μ‹μž„μ„ λ°°μ› μŠ΅λ‹ˆλ‹€.
**Prevention:** μžλ™ μƒμ„±λ˜λŠ” 정적 HTML의 μ½˜ν…μΈ  λ³΄μ•ˆ μ •μ±…(CSP)μ—λŠ” `style-src 'sha256-<HASH>'` 방식을 μ μš©ν•˜κ³ , `<style>` νƒœκ·Έμ—μ„œ λΆˆν•„μš”ν•œ `nonce` 속성을 μ œκ±°ν•˜μ—¬ λΈŒλΌμš°μ €μ˜ 무결성 검증 κΈ°λŠ₯을 적극 ν™œμš©ν•˜μ‹­μ‹œμ˜€.
## 2024-07-28 - [html4tree] CSP Hash Mismatch Fix
## 2024-07-28 - [html4tree] CSP Hash Mismatch Fix
**Vulnerability:** The calculated CSP hash for the inline `<style>` block did not match the actual injected content.
**Learning:** When using Kotlin multiline strings for injecting content (like CSS) and calculating its hash, any surrounding whitespace or implicit padding added during interpolation (e.g., in `<style>${cssContent}</style>`) will alter the final string. This causes the browser to reject the style due to a CSP violation, effectively breaking the styling.
**Prevention:** Always apply `.trimIndent()` to multiline string literals used for CSP hashing, and inject them without any additional padding (e.g., `<style>${exactContent}</style>`) to ensure the calculated hash matches the rendered output perfectly.
## 2026-08-04 - [html4tree] Strix Security Fixes: Path Traversal, ReDoS, and TOCTOU
**Vulnerability:**
1. `Files.isDirectory` followed symlinks partially, but `canonicalFile` resolved them completely, leading to canonical path boundary mismatches.
2. Glob patterns could trigger ReDoS by nesting alternations (`{`...`}`) and character classes (`[`...`]`).
3. TOCTOU via fileKey fallback was missing canonical path checks on file systems that lack fileKey support.
**Learning:** Canonical checks need to establish the canonical root before parsing the initial directory to prevent boundary escapes when symlinks are provided as inputs. Glob compilation needs explicit complexity limits in untrusted inputs. TOCTOU prevention must fall back to canonical path containment checks when OS primitives like fileKey fail.
**Prevention:**
1. Save `File(topDir).canonicalFile` and enforce it across all queue iterations using `canonicalPath.startsWith(canonicalRootPath)`.
2. Inspect untrusted pattern strings for curly brackets and brackets and enforce a hard limit (e.g. max 5 alternations and 10 classes) before calling `getPathMatcher`.
3. Use canonical bounds check as a fallback TOCTOU defense mechanism.
40 changes: 27 additions & 13 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,21 @@ fun go(topDir: String, maxLevel: Int) {
require(topDir.isNotBlank())
require(!topDir.contains("..")) { "Path traversal sequences are not allowed." }
// λ³΄μ•ˆ μˆ˜μ •: symlink 검사λ₯Ό μš°νšŒν•˜λŠ” canonicalFile λŒ€μ‹  absoluteFile을 μ‚¬μš©
// canonicalFile은 symlinkλ₯Ό λŒ€μƒ 경둜둜 ν•΄μ„ν•˜μ—¬ μ΄μ–΄μ§€λŠ” NOFOLLOW_LINKS 검사λ₯Ό 무λ ₯ν™”ν•©λ‹ˆλ‹€.
val top_dir = File(topDir).absoluteFile.toPath().normalize().toFile()
val initialFile = File(topDir)
val canonicalRoot = initialFile.canonicalFile
val top_dir = canonicalRoot

// λ³΄μ•ˆ ν–₯상: μ‹œμŠ€ν…œ 전체 정보 λ…ΈμΆœ 및 λ¦¬μ†ŒμŠ€ 고갈(DoS) λ°©μ§€λ₯Ό μœ„ν•΄ 크둜슀 ν”Œλž«νΌ λ°©μ‹μœΌλ‘œ 루트 디렉토리 크둀링을 μ œν•œν•©λ‹ˆλ‹€.
require(top_dir.parentFile != null) { "Crawling the root directory is not allowed for security reasons" }

require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" }
require(!Files.isSymbolicLink(initialFile.absoluteFile.toPath().normalize())) { "Top directory must be an existing non-symlink directory" }
require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing directory" }

val ll = LinkedList()

val topEntry = LinkedListEntry(top_dir,0, read_file_identity(top_dir).key)
ll.push(topEntry)
crawl_directories(ll, maxLevel)
crawl_directories(ll, maxLevel, canonicalRootPath = canonicalRoot.absolutePath)
}

internal fun crawl_directories(
Expand All @@ -64,7 +66,8 @@ internal fun crawl_directories(
listFiles: (File) -> Array<File>? = { it.listFiles() },
isDirectory: (File) -> Boolean = { Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS) },
isSymbolicLink: (File) -> Boolean = { Files.isSymbolicLink(it.toPath()) },
readIdentity: (File) -> FileIdentity = ::read_file_identity
readIdentity: (File) -> FileIdentity = ::read_file_identity,
canonicalRootPath: String = ""
) {
var lle: LinkedListEntry? = ll.pull()

Expand All @@ -74,6 +77,11 @@ internal fun crawl_directories(
continue
}

if (canonicalRootPath.isNotEmpty() && !lle.file.canonicalPath.startsWith(canonicalRootPath)) {
lle = ll.pull()
continue
}

val currentIdentity = readIdentity(lle.file)
if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) {
lle = ll.pull()
Expand Down Expand Up @@ -190,9 +198,18 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S
if (lineIndex >= 1000) break
val pattern = it.trim()
if (pattern.isNotEmpty() && pattern.length <= 100) {
try {
ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern"))
} catch (_: java.util.regex.PatternSyntaxException) {
// λ³΄μ•ˆ μˆ˜μ •: ReDoS λ°©μ§€λ₯Ό μœ„ν•œ Glob νŒ¨ν„΄ λ³΅μž‘μ„± 검증
var alternationCount = 0
var charClassCount = 0
for (i in 0 until pattern.length) {
if (pattern[i] == '{') alternationCount++
if (pattern[i] == '[') charClassCount++
}
if (alternationCount <= 5 && charClassCount <= 10) {
try {
ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern"))
} catch (_: java.util.regex.PatternSyntaxException) {
}
}
}
}
Expand Down Expand Up @@ -311,14 +328,11 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array
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 = """
<style>
${cssContent} </style>
"""
val css = "<style>${cssContent}</style>"

val index_top = """<!doctype html>
<html lang="ko">
Expand Down
49 changes: 49 additions & 0 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,23 @@ class MainTest {
assertTrue(excluded.contains("index.html"))
}

@Test
fun testProcessIgnoreFileReDosProtection() {
val ignoreFile = File(tempDir, ".html4ignore")
val tooManyAlternations = "{a,b,c,d,e,f,g}"
val tooManyCharClasses = "[a][b][c][d][e][f][g][h][i][j][k]"
val goodPattern = "*.kt"

ignoreFile.writeText("$tooManyAlternations\n$tooManyCharClasses\n$goodPattern\n")

File(tempDir, "test.kt").createNewFile()
File(tempDir, tooManyAlternations).createNewFile()

val excluded = process_ignore_file(tempDir, null)
assertTrue(excluded.contains("test.kt"))
assertFalse(excluded.contains(tooManyAlternations))
}

@Test
fun testIgnoreFileIsSymlink() {
val targetFile = File(tempDir, "target.ignore")
Expand Down Expand Up @@ -706,4 +723,36 @@ class MainTest {
assertFalse(processed, "fileKey mismatch should skip directory processing")
assertFalse(listed, "fileKey mismatch should skip child listing")
}

@Test
fun testCanonicalPathRejection() {
val rootDir = File(tempDir, "canonical_root")
rootDir.mkdir()
val ll = LinkedList()

// Mock a file object whose canonical path jumps outside the root
val mockOutofBoundsDir = object : File(rootDir, "sneaky") {
override fun getCanonicalPath(): String {
return "/tmp/sneaky"
}
}

val entry = LinkedListEntry(mockOutofBoundsDir, 0)
entry.fileKey = "matched-key"
ll.push(entry)

var processed = false
crawl_directories(
ll,
-1,
processDirectory = { _, _, _ -> processed = true },
processIgnoreFile = { _, _ -> emptySet() },
listFiles = { emptyArray() },
isDirectory = { true },
isSymbolicLink = { false },
readIdentity = { FileIdentity("matched-key", true) },
canonicalRootPath = rootDir.canonicalPath
)
assertFalse(processed, "Canonical path outside root should be rejected")
}
}
Loading