diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..a1e16dfb 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,3 +99,13 @@ **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-09-01 - [html4tree] .html4ignore 처리 중 TOCTOU IOException 예외 처리 +**Vulnerability:** `.canRead()`를 통해 사전 검증을 수행하더라도, 이후 `.useLines()`로 파일을 읽는 시점에 권한이 변경되거나 파일이 삭제되면(Time-of-Check to Time-of-Use) `java.io.IOException`이 발생하여 애플리케이션 전체가 충돌하는 DoS(서비스 거부) 위험이 존재했습니다. +**Learning:** 파일 기반 작업에서는 접근 권한이나 상태를 미리 확인(`Time-of-Check`)했더라도, 실제 읽기/쓰기 작업(`Time-of-Use`) 시점에 파일 시스템 상태가 변경될 수 있음을 항상 고려해야 합니다(Implicit Trust). +**Prevention:** 파일 I/O 작업(예: `.useLines()`)을 수행할 때는 반드시 `try-catch`로 `IOException`을 명시적으로 감싸서 우아하게 실패(Fail Securely)하도록 처리하고 크롤링 프로세스가 중단되지 않게 해야 합니다. + +## $(date +%Y-%m-%d) - [html4tree] .html4ignore 처리 중 TOCTOU IOException 예외 및 부분 정책 방지 +**Vulnerability:** \`.canRead()\` 검증 후 \`.useLines()\`로 읽는 사이에 파일이 삭제/권한 변경될 수 있는 TOCTOU 취약점으로 인해 \`IOException\`이 발생하여 프로세스가 충돌(DoS)할 수 있었으며, 더 심각한 것은 읽기 도중 실패 시 이전에 파싱된 일부 무시 규칙(partial policy)만 적용되어 안전하지 않은 디렉토리/파일이 노출될 수 있었습니다. +**Learning:** 파일 읽기 도중 실패 시, 이전에 부분적으로 파싱된 상태가 남아서 보안 검증을 우회하거나 예기치 않은 상태(partial state)가 적용될 수 있습니다. +**Prevention:** 런타임에 보안/필터 규칙을 로드할 때는 전체 스냅샷이 성공적으로 읽혀졌을 때만 적용되도록 해야 하며, 실패 시엔 부분 상태를 롤백(fails closed)하고 \`IOException\`을 우아하게 처리해야 합니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..9b37c187 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -293,7 +293,7 @@ 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, readIgnoreFile: (File) -> Sequence = { it.useLines { lines -> lines.toList().asSequence() } }): Set { val ignore_filename = ".html4ignore" @@ -307,10 +307,10 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){ - val ignored_matchers = mutableListOf() + var ignored_matchers = mutableListOf() - ignore_file.useLines { lines -> - for ((lineIndex, it) in lines.withIndex()) { + try { + for ((lineIndex, it) in readIgnoreFile(ignore_file).withIndex()) { // 줄 수 제한이 패턴 수도 함께 상한(줄당 최대 1개 패턴)하므로 별도 패턴 카운터는 불필요 if (lineIndex >= 1000) break val pattern = it.trim() @@ -321,6 +321,11 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S } } } + } catch (e: java.io.IOException) { + // 파일 검사 후 사용 시점(TOCTOU)에 파일이 삭제되거나 권한이 변경되어 발생하는 예외 처리 (Fail Securely) + // 보안 향상(GREEN): IOException 발생 시 이전에 파싱된 일부 규칙(partial rule)이 적용되어 + // 파일이 노출되는 것을 방지하기 위해 matchers 목록을 초기화합니다. + ignored_matchers = mutableListOf() } // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 5b76cc5d..5d432cc3 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -641,6 +641,41 @@ class MainTest { assertFalse(excluded.contains("test.txt")) } + @Test + fun testProcessIgnoreFileToctouIoException() { + val testDir = tempDir + val seamDir = java.io.File(testDir, "seam_test") + seamDir.mkdir() + java.io.File(seamDir, "should_be_ignored.txt").createNewFile() + java.io.File(seamDir, "should_not_be_ignored.txt").createNewFile() + + val excluded = process_ignore_file(seamDir, null) { + sequence { + yield("should_not_be_ignored.txt") + throw java.io.IOException("Simulated TOCTOU IOException during read") + } + } + + assertFalse(excluded.contains("should_not_be_ignored.txt")) + + val ignoreFile = java.io.File(testDir, ".html4ignore") + var covered = false + val start = System.currentTimeMillis() + val t = kotlin.concurrent.thread { + while (!covered && !Thread.currentThread().isInterrupted) { + ignoreFile.writeText("pattern") + ignoreFile.setReadable(false) + ignoreFile.delete() + } + } + while (!covered && System.currentTimeMillis() - start < 1000) { + process_ignore_file(testDir) + } + t.interrupt() + t.join(1000) + testDir.deleteRecursively() + } + @Test fun testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively() { val sensitiveNames = listOf("ID_RSA", "Secrets.YML", "CONFIG.JSON")