diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..7bd5e98e 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`). + +## 2024-09-01 - [HIGH] .html4ignore TOCTOU (Time-of-Check to Time-of-Use) 심볼릭 링크 스왑 취약점 수정 +**Vulnerability:** `.html4ignore` 파일이 일반 파일인지 검사(Time-of-Check)한 후, 실제로 파일을 읽는(Time-of-Use) 사이의 짧은 시간에 공격자가 해당 파일을 심볼릭 링크로 교체하여 임의의 파일이나 외부 파일을 읽을 수 있는 TOCTOU 취약점. +**Learning:** Kotlin의 `File.useLines`는 내부적으로 심볼릭 링크를 따라가기 때문에, 런타임에 심볼릭 링크로 교체될 가능성이 있는 환경에서는 안전하지 않습니다. 검사 시점과 사용 시점 간의 차이로 인해 검사가 우회될 수 있습니다. +**Prevention:** 파일을 안전하게 읽으면서 심볼릭 링크 순회를 방지하려면 `java.nio.file.Files.newInputStream(file.toPath(), java.nio.file.StandardOpenOption.READ, java.nio.file.LinkOption.NOFOLLOW_LINKS).bufferedReader().useLines { ... }`와 같이 `NOFOLLOW_LINKS` 옵션을 사용하여 파일을 직접 읽어야 합니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..1912d6cd 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -293,34 +293,48 @@ fun String.urlEncodePath(): String { return encoded?.toString() ?: this } -fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): Set { +fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): Set = + process_ignore_file(curr_dir, dirFilesNames) { path -> + Files.newInputStream( + path, + java.nio.file.StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS + ).bufferedReader() + } +internal fun process_ignore_file( + curr_dir: File, + dirFilesNames: Array?, + openIgnoreFile: (java.nio.file.Path) -> java.io.BufferedReader +): Set { val ignore_filename = ".html4ignore" - val ignore_file_path = curr_dir.getAbsolutePath()+"/"+ignore_filename - val ignore_file = File(ignore_file_path) - val files_to_exclude = mutableSetOf() - // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. - // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 - // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 + // Pre-open checks bound ordinary input size and reject an already-visible symlink. + // The opener below still uses NOFOLLOW_LINKS because an attacker can replace the + // directory entry after these checks and before the actual open. if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){ - val ignored_matchers = mutableListOf() - - ignore_file.useLines { lines -> - for ((lineIndex, it) in lines.withIndex()) { - // 줄 수 제한이 패턴 수도 함께 상한(줄당 최대 1개 패턴)하므로 별도 패턴 카운터는 불필요 - 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 (_: IllegalArgumentException) { + val ignored_matchers: List = try { + val parsed_matchers = mutableListOf() + openIgnoreFile(ignore_file.toPath()).useLines { lines -> + for ((lineIndex, it) in lines.withIndex()) { + // 줄 수 제한이 패턴 수도 함께 상한(줄당 최대 1개 패턴)하므로 별도 패턴 카운터는 불필요 + if (lineIndex >= 1000) break + val pattern = it.trim() + if (pattern.isNotEmpty() && pattern.length <= 100) { + try { + parsed_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")) + } catch (_: IllegalArgumentException) { + } } } } + parsed_matchers + } catch (_: java.io.IOException) { + // User patterns are committed only after a complete successful read. + emptyList() } // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. diff --git a/src/test/kotlin/html4tree/IgnoreFilePartialReadFailureTest.kt b/src/test/kotlin/html4tree/IgnoreFilePartialReadFailureTest.kt new file mode 100644 index 00000000..fd0a0920 --- /dev/null +++ b/src/test/kotlin/html4tree/IgnoreFilePartialReadFailureTest.kt @@ -0,0 +1,47 @@ +package html4tree + +import org.junit.Test +import java.io.BufferedReader +import java.io.File +import java.io.IOException +import java.io.StringReader +import java.nio.file.Files +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class IgnoreFilePartialReadFailureTest { + @Test + fun partialPatternsAreDiscardedWhenIgnoreReadFails() { + val tempDir = Files.createTempDirectory("html4tree-ignore-partial-read").toFile() + try { + File(tempDir, ".html4ignore").writeText("*.secret\n*.later\n") + File(tempDir, "leak.secret").writeText("candidate") + File(tempDir, "keep.txt").writeText("candidate") + val names = arrayOf(".html4ignore", "leak.secret", "keep.txt") + + val excluded = process_ignore_file(tempDir, names) { + object : BufferedReader(StringReader("")) { + private var calls = 0 + + override fun readLine(): String? { + calls += 1 + return when (calls) { + 1 -> "*.secret" + else -> throw IOException("simulated failure after one parsed policy line") + } + } + } + } + + assertFalse( + "leak.secret" in excluded, + "a failed read must not leave a partially parsed ignore policy active" + ) + assertFalse("keep.txt" in excluded) + assertTrue("index.html" in excluded) + assertTrue(".html4ignore" in excluded) + } finally { + tempDir.deleteRecursively() + } + } +} diff --git a/src/test/kotlin/html4tree/IgnoreFileRaceTest.kt b/src/test/kotlin/html4tree/IgnoreFileRaceTest.kt new file mode 100644 index 00000000..e41fcc32 --- /dev/null +++ b/src/test/kotlin/html4tree/IgnoreFileRaceTest.kt @@ -0,0 +1,63 @@ +package html4tree + +import org.junit.Test +import java.io.File +import java.nio.file.FileSystemException +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class IgnoreFileRaceTest { + @Test + fun racedOpenFailureIsContainedAfterPreOpenValidation() { + val tempDir = Files.createTempDirectory("html4tree-ignore-race").toFile() + try { + val ignoreFile = File(tempDir, ".html4ignore") + ignoreFile.writeText("*.initial\n") + File(tempDir, "victim.victim").writeText("candidate") + File(tempDir, "keep.txt").writeText("candidate") + val names = arrayOf(".html4ignore", "victim.victim", "keep.txt") + var openerCalled = false + + val excluded = process_ignore_file(tempDir, names) { path -> + openerCalled = true + assertEquals(ignoreFile.toPath(), path) + // The seam is reached only after the regular-file/readability/size + // checks. Replace that checked directory entry before the open and + // model the NOFOLLOW open rejecting the raced replacement. + Files.delete(path) + Files.writeString(path, "*.victim\n") + throw FileSystemException(path.toString(), null, "simulated raced replacement") + } + + assertTrue(openerCalled, "the injected opener must run after pre-open validation") + assertFalse("victim.victim" in excluded, "raced replacement patterns must not be applied") + assertFalse("keep.txt" in excluded) + assertTrue("index.html" in excluded, "mandatory default exclusions must survive the race") + assertTrue(".html4ignore" in excluded, "the ignore file itself remains excluded") + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun regularIgnoreFileStillAppliesValidPatterns() { + val tempDir = Files.createTempDirectory("html4tree-ignore-positive").toFile() + try { + File(tempDir, ".html4ignore").writeText("*.artifact\n") + File(tempDir, "plan.artifact").writeText("candidate") + File(tempDir, "keep.txt").writeText("candidate") + val names = arrayOf(".html4ignore", "plan.artifact", "keep.txt") + + val excluded = process_ignore_file(tempDir, names) + + assertTrue("plan.artifact" in excluded) + assertFalse("keep.txt" in excluded) + assertTrue("index.html" in excluded) + assertTrue(".html4ignore" in excluded) + } finally { + tempDir.deleteRecursively() + } + } +} diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 5b76cc5d..d448fd0d 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -768,6 +768,32 @@ class MainTest { assertTrue(excluded.contains("index.html")) } + @Test + fun testProcessIgnoreFileToctouSymlinkSwapRejection() { + // Mock a Time-of-Check-Time-of-Use window where the file is replaced by a symlink + val ignoreFile = File(tempDir, ".html4ignore") + val targetFile = File(tempDir, "target.ignore") + targetFile.writeText("*.log") + + try { + Files.createSymbolicLink(ignoreFile.toPath(), targetFile.toPath()) + } catch (e: Exception) { + Assume.assumeTrue("Symlink creation not supported in this environment", false) + } + + File(tempDir, "test.log").createNewFile() + + var readSuccess = false + try { + java.nio.file.Files.newInputStream(ignoreFile.toPath(), java.nio.file.StandardOpenOption.READ, java.nio.file.LinkOption.NOFOLLOW_LINKS).bufferedReader().useLines { _ -> + readSuccess = true + } + } catch (_: java.io.IOException) { + // Expected + } + assertFalse(readSuccess, "Symlinks should be rejected by NOFOLLOW_LINKS throwing an exception") + } + @Test fun testProcessIgnoreFileLargeSize() { val ignoreFile = File(tempDir, ".html4ignore")