Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4ac82b0
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 1, 2026
28d9943
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 2, 2026
1db0e9d
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 2, 2026
f61ab7b
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 2, 2026
b372119
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 2, 2026
b9760ec
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 3, 2026
37fe422
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 3, 2026
ba14d07
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 3, 2026
6499ee7
fix(ignore): make raced open deterministic and contained
seonghobae Sep 3, 2026
1772fe7
test(ignore): cover deterministic raced-open containment
seonghobae Sep 3, 2026
3f710ec
test(ignore): replace checked entry inside race seam
seonghobae Sep 3, 2026
47182b4
chore: adopt protected-branch CI queue policy before restack
seonghobae Sep 3, 2026
8e9c1fd
chore: non-force restack ignore-file repair onto protected master
seonghobae Sep 3, 2026
dd2c45a
🛡️ Sentinel: [HIGH] Fix TOCTOU vulnerability in .html4ignore reading
seonghobae Sep 3, 2026
4798bae
repair(ignore): restore deterministic race contract after stale desce…
seonghobae Sep 3, 2026
32da58c
test(ignore): discard partial policy on read failure
seonghobae Sep 4, 2026
e7722b2
fix(ignore): discard partial patterns after read failure
seonghobae Sep 4, 2026
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-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` 옵션을 사용하여 파일을 직접 읽어야 합니다.
50 changes: 32 additions & 18 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -293,34 +293,48 @@ fun String.urlEncodePath(): String {
return encoded?.toString() ?: this
}

fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): Set<String> {
fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): Set<String> =
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<String>?,
openIgnoreFile: (java.nio.file.Path) -> java.io.BufferedReader
): Set<String> {
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<String>()

// 보안 향상: .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<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) {
val ignored_matchers: List<java.nio.file.PathMatcher> = try {
val parsed_matchers = mutableListOf<java.nio.file.PathMatcher>()
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) 오버헤드를 방지합니다.
Expand Down
47 changes: 47 additions & 0 deletions src/test/kotlin/html4tree/IgnoreFilePartialReadFailureTest.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
63 changes: 63 additions & 0 deletions src/test/kotlin/html4tree/IgnoreFileRaceTest.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
26 changes: 26 additions & 0 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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")
}
Comment thread
seonghobae marked this conversation as resolved.

@Test
fun testProcessIgnoreFileLargeSize() {
val ignoreFile = File(tempDir, ".html4ignore")
Expand Down
Loading