diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..e259a895 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,3 +99,8 @@ **Root cause:** The protected implementation added canonical names to the exclusion set but did not compare each observed directory entry through a locale-stable normalized key. **Prevention:** Build one `Locale.ROOT` lowercase set from the canonical sensitive names, compare every observed name against it, and add the original spelling to the exclusion set so downstream exact membership remains correct. **Evidence:** `testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively` failed on test-only commit `472b916cd40f70693c4e1eb48956042a25353feb` (CI run `31469596932`) and passed with the source fix at `bb113d858ccfc42ddaecf6729749b238e5ade2d0` (CI run `31469921661`). + +## 2026-08-11 - [CRITICAL] 정책 파일(.html4ignore) TOCTOU 실패 시 Fail-Closed 처리 누락 방지 +**Vulnerability:** 파일 디렉토리 스냅샷에 `.html4ignore`가 존재하지만 실제로 읽으려 할 때 접근 불가, 심볼릭 링크 변경, 또는 디렉토리인 경우, 이를 단순히 무시(Fail-Open)하고 모든 파일을 노출하는 TOCTOU (Time-of-Check to Time-of-Use) 취약점. +**Learning:** 보안 및 무시 규칙이 명시된 파일이 존재함에도 불구하고 이를 읽지 못할 때 기본적으로 무시하고 계속 진행하면, 민감한 파일이 의도치 않게 인덱싱될 수 있습니다. (Implicit Trust) +**Prevention:** 정책 파일이 디렉토리 스냅샷에 포함되어 있다면 이를 파싱하기 전 검증 실패나 접근 오류 시 반드시 예외(`IgnoreFileReadException`)를 던지고, 상위 디렉토리 크롤링 로직에서 이 예외를 잡아 해당 디렉토리 전체에 대한 렌더링 및 하위 탐색을 즉시 중단(Fail-Closed)해야 합니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..37e39590 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -110,6 +110,8 @@ li + li { private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) private val FILE_NAME_COMPARATOR = compareBy { it.name } +class IgnoreFileReadException(message: String) : java.io.IOException(message) + 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") @@ -200,7 +202,12 @@ internal fun crawl_directories( val dirFilesNames = dirFiles?.let { files -> Array(files.size) { index -> files[index].name } } - val exclude = processIgnoreFile(lle.file, dirFilesNames) + val exclude = try { + processIgnoreFile(lle.file, dirFilesNames) + } catch (e: IgnoreFileReadException) { + lle = ll.pull() + continue + } if(maxLevel == -1 || currentLevel <= maxLevel) processDirectory(lle.file, exclude, dirFiles) @@ -303,6 +310,14 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S val files_to_exclude = mutableSetOf() + val list = dirFilesNames ?: curr_dir.list() + + if (list?.contains(ignore_filename) == true) { + if (!ignore_file.isFile || Files.isSymbolicLink(ignore_file.toPath()) || !ignore_file.canRead()) { + throw IgnoreFileReadException("Policy file $ignore_filename is present in directory snapshot but inaccessible or invalid") + } + } + // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 @@ -324,7 +339,6 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S } // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. - val list = dirFilesNames ?: curr_dir.list() list?.forEach { val current = it val pathCurrent = try { @@ -350,7 +364,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S files_to_exclude.addAll(Constants.defaultSensitiveFiles) // 보안 향상: dot-like prefixes and case variants of known sensitive names are excluded. - (dirFilesNames ?: curr_dir.list())?.forEach { + list?.forEach { val normalizedName = it.toLowerCase(java.util.Locale.ROOT) if ( it.isHiddenFile() || diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 5b76cc5d..1a878956 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -718,9 +718,12 @@ class MainTest { val ignoreDir = File(tempDir, ".html4ignore") ignoreDir.mkdir() - // This should not crash or parse the directory - val excluded = process_ignore_file(tempDir, null) - assertTrue(excluded.contains("index.html")) + try { + process_ignore_file(tempDir, null) + org.junit.Assert.fail("Expected IgnoreFileReadException") + } catch (e: IgnoreFileReadException) { + // Expected + } } @Test @@ -762,10 +765,12 @@ class MainTest { File(tempDir, "test.txt").createNewFile() - // Should ignore the symlink and NOT parse it - val excluded = process_ignore_file(tempDir, null) - assertFalse(excluded.contains("test.txt")) - assertTrue(excluded.contains("index.html")) + try { + process_ignore_file(tempDir, null) + org.junit.Assert.fail("Expected IgnoreFileReadException") + } catch (e: IgnoreFileReadException) { + // Expected + } } @Test @@ -946,4 +951,33 @@ class MainTest { assertTrue(content.contains("

Root

")) } + @Test + fun testProcessIgnoreFileThrowsWhenInaccessible() { + val subdir = File(tempDir, "toctou_ignore_test") + subdir.mkdir() + val ll = LinkedList() + val entry = LinkedListEntry(subdir, 0) + entry.fileKey = "mock-key" + ll.push(entry) + + var processed = false + var listed = false + + crawl_directories( + ll, + -1, + processDirectory = { _, _, _ -> processed = true }, + processIgnoreFile = { _, _ -> throw IgnoreFileReadException("mock") }, + listFiles = { + listed = true + arrayOf(File(subdir, ".html4ignore")) + }, + readAttributes = { _ -> createMockAttributes(isDir = true, isSymlink = false) }, + readIdentity = { FileIdentity("mock-key", true) } + ) + + assertTrue(listed, "listFiles must be called before processIgnoreFile") + assertFalse(processed, "Directory with inaccessible ignore file must not be processed") + } + }