diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..662c70a9 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-05 - [.html4ignore 심볼릭 링크 스왑(TOCTOU) 취약점 완화] +**Vulnerability:** 파일 속성 검사 시점과 내용 읽기 시점 사이에 사용자가 제공한 `.html4ignore` 파일이 심볼릭 링크로 교체될 경우, 시스템의 임의 파일이 무시(ignore) 목록으로 처리되어 간접적으로 내용이 노출되거나 서비스 거부(DoS)를 유발할 수 있는 TOCTOU 취약점이 발견되었습니다. +**Learning:** `ignore_file.isFile` 및 `!Files.isSymbolicLink`를 사용하여 안전한 파일임을 미리 확인했더라도, Kotlin의 `File.useLines`를 통해 최종적으로 파일을 읽을 때는 기본적으로 심볼릭 링크를 따라가게 되므로(Time-of-Use) 보안 검사가 무력화될 수 있습니다. +**Prevention:** `.html4ignore` 등 민감한 입력 파일을 읽을 때는 `java.nio.file.Files.newInputStream`에 `java.nio.file.StandardOpenOption.READ` 및 `java.nio.file.LinkOption.NOFOLLOW_LINKS` 옵션을 명시적으로 전달한 뒤 `bufferedReader().useLines`를 호출하여 심볼릭 링크 탐색을 완벽히 차단해야 합니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..2a41ed5b 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -303,24 +303,37 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S val files_to_exclude = mutableSetOf() - // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. + // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하여 DoS 및 경로 조작을 방지합니다. + // (TOCTOU 방지를 위해 최종 읽기 시점에서 NOFOLLOW_LINKS를 사용하므로, 중복된 isSymbolicLink 검사를 제거합니다) // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 - if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){ + if(ignore_file.isFile && 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) { + // 🛡️ Sentinel: Fix TOCTOU (Time-of-Check-Time-of-Use) vulnerability. + // Prevent symlink traversal by securely reading the file without following symlinks. + try { + val input = java.nio.file.Files.newInputStream(ignore_file.toPath(), java.nio.file.StandardOpenOption.READ, java.nio.file.LinkOption.NOFOLLOW_LINKS) + try { + val reader = java.io.InputStreamReader(input, Charsets.UTF_8) + val bufferedReader = java.io.BufferedReader(reader) + var lineIndex = 0 + while (lineIndex < 1000) { + val line = bufferedReader.readLine() ?: break + val pattern = line.trim() + if (pattern.isNotEmpty() && pattern.length <= 100) { + try { + ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")) + } catch (_: IllegalArgumentException) { + } } + lineIndex++ } + } finally { + input.close() } + } catch (e: Exception) { + // 파일 시스템 계층에서 심볼릭 링크 스왑(TOCTOU)이 감지되어 읽기가 실패한 경우 무시하고 계속 진행(Fail Securely) } // ⚡ 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..41b38492 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -49,6 +49,18 @@ class MainTest { } } + @Test + fun testProcessIgnoreFileWithTryCatchCoverage() { + val tempDir = Files.createTempDirectory("test-ignore-coverage").toFile() + try { + File(tempDir, ".html4ignore").writeText("test\n") + process_ignore_file(tempDir) + } finally { + tempDir.deleteRecursively() + } + } + + @Test fun testEscapeHtml() { assertEquals("&", "&".escapeHtml()) @@ -641,6 +653,25 @@ class MainTest { assertFalse(excluded.contains("test.txt")) } + @Test + fun testProcessIgnoreFileRejectsSymlink() { + val tempDir = Files.createTempDirectory("test-ignore-symlink").toFile() + try { + val ignoreTarget = File(tempDir, "target.txt") + ignoreTarget.writeText("secret.txt") + val ignoreLink = File(tempDir, ".html4ignore") + try { + Files.createSymbolicLink(ignoreLink.toPath(), ignoreTarget.toPath()) + } catch (e: UnsupportedOperationException) { + return + } + val excluded = process_ignore_file(tempDir) + assertFalse(excluded.contains("secret.txt")) + } finally { + tempDir.deleteRecursively() + } + } + @Test fun testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively() { val sensitiveNames = listOf("ID_RSA", "Secrets.YML", "CONFIG.JSON")