Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`를 호출하여 심볼릭 링크 탐색을 완벽히 차단해야 합니다.
35 changes: 24 additions & 11 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -303,24 +303,37 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S

val files_to_exclude = mutableSetOf<String>()

// 보안 향상: .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<java.nio.file.PathMatcher>()

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) 오버헤드를 방지합니다.
Expand Down
31 changes: 31 additions & 0 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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("&amp;", "&".escapeHtml())
Expand Down Expand Up @@ -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")
Expand Down
Loading