diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..2efe1b50 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,3 +99,7 @@ **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`). +## 2024-09-24 - [CRITICAL] Fail-closed Security Policy for .html4ignore Files +**Vulnerability:** The application failed to properly enforce security policies defined in `.html4ignore`. If the file was unreadable due to restrictive permissions or due to a TOCTOU race condition after enumeration, the system failed-open. This could lead to information exposure by indexing and publishing directories containing sensitive files that were intended to be ignored. +**Learning:** Checking for `.html4ignore` existence without verifying read access and intentionally failing if access is denied violates the principle of failing securely. Security controls must enforce a fail-closed behavior to avoid bypassing intended protections. +**Prevention:** Always employ fail-closed logic when evaluating security boundaries or access control lists (like `.html4ignore`). Ensure that unreadable policy files throw a dedicated exception (`IgnoreFileReadException`) to stop the operation, instead of silently continuing. Also, avoid TOCTOU by ensuring the presence of the policy file from the directory snapshot is strictly verified and safely read. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..c468eb64 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -200,20 +200,26 @@ internal fun crawl_directories( val dirFilesNames = dirFiles?.let { files -> Array(files.size) { index -> files[index].name } } - val exclude = processIgnoreFile(lle.file, dirFilesNames) - - if(maxLevel == -1 || currentLevel <= maxLevel) - processDirectory(lle.file, exclude, dirFiles) - - if(maxLevel == -1 || currentLevel < maxLevel) { - dirFiles?.forEach { - // ⚡ Bolt Performance Optimization: Short-circuit OS stat calls - // by checking cheap in-memory string exclusion rules first - if(!it.name.isHiddenFile() && it.name !in exclude) { - val childAttrs = readAttributes(it) - if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) { - val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) - ll.push(childEntry) + val exclude = try { + processIgnoreFile(lle.file, dirFilesNames) + } catch (e: IgnoreFileReadException) { + null + } + + if(exclude != null) { + if(maxLevel == -1 || currentLevel <= maxLevel) + processDirectory(lle.file, exclude, dirFiles) + + if(maxLevel == -1 || currentLevel < maxLevel) { + dirFiles?.forEach { + // ⚡ Bolt Performance Optimization: Short-circuit OS stat calls + // by checking cheap in-memory string exclusion rules first + if(!it.name.isHiddenFile() && it.name !in exclude) { + val childAttrs = readAttributes(it) + if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) { + val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) + ll.push(childEntry) + } } } } @@ -293,6 +299,12 @@ fun String.urlEncodePath(): String { return encoded?.toString() ?: this } +/** + * Exception thrown when a policy file like .html4ignore exists but cannot be safely read. + * Enforces a fail-closed security contract to prevent TOCTOU bypasses and information exposure. + */ +class IgnoreFileReadException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): Set { val ignore_filename = ".html4ignore" @@ -303,10 +315,13 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S val files_to_exclude = mutableSetOf() - // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. - // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 - // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 - if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){ + // 보안 향상: TOCTOU(Time-of-check to time-of-use) 취약점 방지 및 Fail-closed 정책 적용 + val hasIgnoreFile = (dirFilesNames != null && dirFilesNames.contains(ignore_filename)) || ignore_file.exists() || Files.isSymbolicLink(ignore_file.toPath()) + + if(hasIgnoreFile){ + if(!ignore_file.isFile || Files.isSymbolicLink(ignore_file.toPath()) || !ignore_file.canRead() || ignore_file.length() > 1048576){ + throw IgnoreFileReadException("Policy file cannot be safely read: fail closed.") + } val ignored_matchers = mutableListOf() ignore_file.useLines { lines -> diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 5b76cc5d..f983aa1c 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -718,9 +718,13 @@ 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")) + var thrown = false + try { + process_ignore_file(tempDir, null) + } catch (e: IgnoreFileReadException) { + thrown = true + } + assertTrue(thrown) } @Test @@ -762,10 +766,13 @@ 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")) + var thrown = false + try { + process_ignore_file(tempDir, null) + } catch (e: IgnoreFileReadException) { + thrown = true + } + assertTrue(thrown) } @Test @@ -777,10 +784,13 @@ class MainTest { File(tempDir, "test.txt").createNewFile() - // Should ignore the file because it's too large - val excluded = process_ignore_file(tempDir, null) - assertFalse(excluded.contains("test.txt")) - assertTrue(excluded.contains("index.html")) + var thrown = false + try { + process_ignore_file(tempDir, null) + } catch (e: IgnoreFileReadException) { + thrown = true + } + assertTrue(thrown) } @Test @@ -946,4 +956,32 @@ class MainTest { assertTrue(content.contains("

Root

")) } + @Test + fun testCrawlDirectoriesThrowsIgnoreFileReadException() { + val subdir = File(tempDir, "ignore_exception_test") + subdir.mkdir() + val ll = LinkedList() + val entry = LinkedListEntry(subdir, 0) + entry.fileKey = "test-key" + ll.push(entry) + + var processed = false + var listed = false + + crawl_directories( + ll, + -1, + processDirectory = { _, _, _ -> processed = true }, + processIgnoreFile = { _, _ -> throw IgnoreFileReadException("mock") }, + listFiles = { + listed = true + emptyArray() + }, + readAttributes = { _ -> createMockAttributes(isDir = true, isSymlink = false) }, + readIdentity = { FileIdentity("test-key", true) } + ) + + assertFalse(processed, "Directory must not be processed if IgnoreFileReadException is thrown") + } + }