diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6ecf72f1..79d8fdfe 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -83,3 +83,18 @@ **Vulnerability:** 정적 HTML 생성 도구에서 매번 다른 Nonce를 동적으로 생성하여 CSP에 적용하는 것은, 캐싱 효율을 저하시킬 뿐만 아니라 정적 배포 환경(예: GitHub Pages 등)에서 올바른 보안 정책 수립을 방해할 수 있는 안티 패턴입니다. **Learning:** 정적으로 고정된 인라인 스타일이나 스크립트에는 난수화된 Nonce보다 콘텐츠 자체의 해시(SHA-256 등)를 사용하는 것이 안전하고 일관된 방식임을 배웠습니다. **Prevention:** 자동 생성되는 정적 HTML의 콘텐츠 보안 정책(CSP)에는 `style-src 'sha256-'` 방식을 적용하고, ``) 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., ``) 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. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 29eef0c4..80b892a5 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -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( @@ -64,7 +66,8 @@ internal fun crawl_directories( listFiles: (File) -> Array? = { 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() @@ -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() @@ -190,9 +198,18 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = 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) { + } } } } @@ -311,14 +328,11 @@ fun process_dir(curr_dir: File, excludeSet: Set? = 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 = """ - - """ + val css = "" val index_top = """ diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 83739c9c..94107d5c 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -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") @@ -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") + } }