From fedbe6edf2c436a6d7dd7f9907b2864f235dcb80 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:50:20 +0000 Subject: [PATCH 01/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. --- .jules/sentinel.md | 5 + src/main/kotlin/html4tree/main.kt | 20 +- src/main/kotlin/html4tree/main.kt.orig | 543 +++++++++++++++++++++++++ src/test/kotlin/html4tree/MainTest.kt | 19 +- 4 files changed, 577 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/html4tree/main.kt.orig diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..e259a895 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`). + +## 2026-08-11 - [CRITICAL] μ •μ±… 파일(.html4ignore) TOCTOU μ‹€νŒ¨ μ‹œ Fail-Closed 처리 λˆ„λ½ λ°©μ§€ +**Vulnerability:** 파일 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore`κ°€ μ‘΄μž¬ν•˜μ§€λ§Œ μ‹€μ œλ‘œ 읽으렀 ν•  λ•Œ μ ‘κ·Ό λΆˆκ°€, 심볼릭 링크 λ³€κ²½, λ˜λŠ” 디렉토리인 경우, 이λ₯Ό λ‹¨μˆœνžˆ λ¬΄μ‹œ(Fail-Open)ν•˜κ³  λͺ¨λ“  νŒŒμΌμ„ λ…ΈμΆœν•˜λŠ” TOCTOU (Time-of-Check to Time-of-Use) 취약점. +**Learning:** λ³΄μ•ˆ 및 λ¬΄μ‹œ κ·œμΉ™μ΄ λͺ…μ‹œλœ 파일이 μ‘΄μž¬ν•¨μ—λ„ λΆˆκ΅¬ν•˜κ³  이λ₯Ό 읽지 λͺ»ν•  λ•Œ 기본적으둜 λ¬΄μ‹œν•˜κ³  계속 μ§„ν–‰ν•˜λ©΄, λ―Όκ°ν•œ 파일이 μ˜λ„μΉ˜ μ•Šκ²Œ 인덱싱될 수 μžˆμŠ΅λ‹ˆλ‹€. (Implicit Trust) +**Prevention:** μ •μ±… 파일이 디렉토리 μŠ€λƒ…μƒ·μ— ν¬ν•¨λ˜μ–΄ μžˆλ‹€λ©΄ 이λ₯Ό νŒŒμ‹±ν•˜κΈ° μ „ 검증 μ‹€νŒ¨λ‚˜ μ ‘κ·Ό 였λ₯˜ μ‹œ λ°˜λ“œμ‹œ μ˜ˆμ™Έ(`IgnoreFileReadException`)λ₯Ό λ˜μ§€κ³ , μƒμœ„ 디렉토리 크둀링 λ‘œμ§μ—μ„œ 이 μ˜ˆμ™Έλ₯Ό μž‘μ•„ ν•΄λ‹Ή 디렉토리 전체에 λŒ€ν•œ λ Œλ”λ§ 및 ν•˜μœ„ 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•΄μ•Ό ν•©λ‹ˆλ‹€. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..37e39590 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -110,6 +110,8 @@ li + li { private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) private val FILE_NAME_COMPARATOR = compareBy { it.name } +class IgnoreFileReadException(message: String) : java.io.IOException(message) + class Html4tree : CliktCommand() { val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) val topDir: String by argument(help="Top directory to crawl") @@ -200,7 +202,12 @@ internal fun crawl_directories( val dirFilesNames = dirFiles?.let { files -> Array(files.size) { index -> files[index].name } } - val exclude = processIgnoreFile(lle.file, dirFilesNames) + val exclude = try { + processIgnoreFile(lle.file, dirFilesNames) + } catch (e: IgnoreFileReadException) { + lle = ll.pull() + continue + } if(maxLevel == -1 || currentLevel <= maxLevel) processDirectory(lle.file, exclude, dirFiles) @@ -303,6 +310,14 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S val files_to_exclude = mutableSetOf() + val list = dirFilesNames ?: curr_dir.list() + + if (list?.contains(ignore_filename) == true) { + if (!ignore_file.isFile || Files.isSymbolicLink(ignore_file.toPath()) || !ignore_file.canRead()) { + throw IgnoreFileReadException("Policy file $ignore_filename is present in directory snapshot but inaccessible or invalid") + } + } + // λ³΄μ•ˆ ν–₯상: .html4ignore 파일이 일반 νŒŒμΌμΈμ§€ ν™•μΈν•˜κ³ , 심볼릭 링크인 경우 λ¬΄μ‹œν•˜μ—¬ DoS 및 경둜 μ‘°μž‘μ„ λ°©μ§€ν•©λ‹ˆλ‹€. // λ³΄μ•ˆ ν–₯상: 파일 크기(1MB μ œν•œ) 및 쀄 수(1000쀄), μ •κ·œμ‹ 길이(100자)λ₯Ό μ œν•œν•˜μ—¬ ReDoS 및 λ©”λͺ¨λ¦¬ 고갈(OOM) λ°©μ§€ // λ³΄μ•ˆ ν–₯상: κΆŒν•œμ΄ μ—†λŠ” 파일 μ ‘κ·Ό μ‹œ λ°œμƒν•˜λŠ” μ˜ˆμ™Έ(DoS)λ₯Ό λ°©μ§€ν•˜κΈ° μœ„ν•΄ canRead() μΆ”κ°€ 확인 @@ -324,7 +339,6 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S } // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ Set에 μΆ”κ°€ν•˜κΈ° μœ„ν•΄ ν•„ν„°λ§λ§Œ ν•  λ•ŒλŠ” 정렬이 λΆˆν•„μš”ν•˜λ―€λ‘œ .sorted()λ₯Ό μ œκ±°ν•˜μ—¬ O(N log N) μ˜€λ²„ν—€λ“œλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. - val list = dirFilesNames ?: curr_dir.list() list?.forEach { val current = it val pathCurrent = try { @@ -350,7 +364,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S files_to_exclude.addAll(Constants.defaultSensitiveFiles) // λ³΄μ•ˆ ν–₯상: dot-like prefixes and case variants of known sensitive names are excluded. - (dirFilesNames ?: curr_dir.list())?.forEach { + list?.forEach { val normalizedName = it.toLowerCase(java.util.Locale.ROOT) if ( it.isHiddenFile() || diff --git a/src/main/kotlin/html4tree/main.kt.orig b/src/main/kotlin/html4tree/main.kt.orig new file mode 100644 index 00000000..c75de526 --- /dev/null +++ b/src/main/kotlin/html4tree/main.kt.orig @@ -0,0 +1,543 @@ +package html4tree + +import java.io.File +import java.security.MessageDigest +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.Base64 +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.types.int + +private val CSS_CONTENT = """ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + padding: 1rem; + color: #1f2328; +} +main { + max-width: 800px; + margin: 0 auto; +} +h1 { + overflow-wrap: anywhere; +} +ul { + list-style-type: none; + padding-left: 0; +} +a.dir-link { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; + overflow-wrap: anywhere; + box-sizing: border-box; +} +.icon { + flex-shrink: 0; + width: 1.25rem; + text-align: center; +} +a { + padding: 0.75rem 0.5rem; + text-decoration: none; + color: #0969da; + border-radius: 4px; + transition: background-color 0.2s ease, outline-color 0.2s ease; +} +a:hover, a:focus-visible { + background-color: #f6f8fa; + outline: 2px solid #0969da; + outline-offset: -2px; +} +a:hover span:last-child, a:focus-visible span:last-child { + text-decoration: underline; +} +@media (prefers-reduced-motion: reduce) { + a { + transition: none; + } +} +li + li { + border-top: 1px solid #d0d7de; +} +.empty-dir { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 0.5rem; + color: #656d76; + font-style: italic; +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +@media (prefers-color-scheme: dark) { + body { + background-color: #0d1117; + color: #c9d1d9; + } + a { + color: #58a6ff; + } + a:hover, a:focus-visible { + background-color: #161b22; + outline-color: #58a6ff; + } + li + li { + border-top-color: #21262d; + } + .empty-dir { + color: #8b949e; + } +} +""".trimIndent() + +private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) +private val FILE_NAME_COMPARATOR = compareBy { it.name } + +class IgnoreFileReadException(message: String) : java.io.IOException(message) + +class Html4tree : CliktCommand() { + val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) + val topDir: String by argument(help="Top directory to crawl") + + override fun run() { + go(topDir, maxLevel) + } +} + +fun main(args: Array) = Html4tree().main(args) + + +internal data class FileIdentity(val key: Any?, val readable: Boolean) + + +internal fun read_file_identity(file: File): FileIdentity { + return try { + val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + FileIdentity(attrs.fileKey(), true) + } catch (e: Exception) { + FileIdentity(null, false) + } +} + +fun go(topDir: String, maxLevel: Int) { + require(topDir.isNotBlank()) + require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } + // λ³΄μ•ˆ μˆ˜μ •: symlink 검사λ₯Ό μš°νšŒν•˜λŠ” canonicalFile λŒ€μ‹  absoluteFile을 μ‚¬μš© + // canonicalFile은 symlinkλ₯Ό λŒ€μƒ 경둜둜 ν•΄μ„ν•˜μ—¬ μ΄μ–΄μ§€λŠ” NOFOLLOW_LINKS 검사λ₯Ό 무λ ₯ν™”ν•©λ‹ˆλ‹€. + val top_dir = File(topDir).absoluteFile.toPath().normalize().toFile() + + // λ³΄μ•ˆ ν–₯상: μ‹œμŠ€ν…œ 전체 정보 λ…ΈμΆœ 및 λ¦¬μ†ŒμŠ€ 고갈(DoS) λ°©μ§€λ₯Ό μœ„ν•΄ 크둜슀 ν”Œλž«νΌ λ°©μ‹μœΌλ‘œ 루트 디렉토리 크둀링을 μ œν•œν•©λ‹ˆλ‹€. + require(top_dir.parentFile != null) { "Crawling the root directory is not allowed for security reasons" } + + require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } + + val ll = LinkedList() + + val topEntry = LinkedListEntry(top_dir,0, read_file_identity(top_dir).key) + ll.push(topEntry) + crawl_directories(ll, maxLevel) +} + +internal fun crawl_directories( + ll: LinkedList, + maxLevel: Int, + processDirectory: (File, Set, Array?) -> Unit = { file, exclude, files -> process_dir(file, exclude, files) }, + processIgnoreFile: (File, Array?) -> Set = { file, names -> process_ignore_file(file, names) }, + listFiles: (File) -> Array? = { it.listFiles() }, + readAttributes: (File) -> BasicFileAttributes? = { + try { + Files.readAttributes(it.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + } catch (e: Exception) { + null + } + }, + readIdentity: (File) -> FileIdentity = ::read_file_identity +) { + var lle: LinkedListEntry? = ll.pull() + + while(lle != null){ + val attrs = readAttributes(lle.file) + if (attrs == null || !attrs.isDirectory) { + lle = ll.pull() + continue + } + + val currentIdentity = readIdentity(lle.file) + if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) { + lle = ll.pull() + continue + } + + val currentLevel: Int = lle.level + + // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ μΊμ‹±ν•˜μ—¬ μ€‘λ³΅λœ I/O μ‹œμŠ€ν…œ ν˜ΈμΆœμ„ μ€„μž„ + val dirFiles = listFiles(lle.file) + + // The path can be replaced between the initial identity check and + // directory enumeration. Do not process or enqueue children from a + // snapshot whose post-listing identity is unreadable or different. + val postListingIdentity = readIdentity(lle.file) + if (!postListingIdentity.readable || currentIdentity.key != postListingIdentity.key) { + lle = ll.pull() + continue + } + + val dirFilesNames = dirFiles?.let { files -> + Array(files.size) { index -> files[index].name } + } + val exclude = try { + processIgnoreFile(lle.file, dirFilesNames) + } catch (e: IgnoreFileReadException) { + lle = ll.pull() + continue + } + + if(maxLevel == -1 || currentLevel <= maxLevel) + processDirectory(lle.file, exclude, dirFiles) + + if(maxLevel == -1 || currentLevel < maxLevel) { + dirFiles?.forEach { + // ⚑ Bolt Performance Optimization: Short-circuit OS stat calls + // by checking cheap in-memory string exclusion rules first + if(!it.name.isHiddenFile() && it.name !in exclude) { + val childAttrs = readAttributes(it) + if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) { + val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) + ll.push(childEntry) + } + } + } + } + lle = ll.pull() + } +} + +fun String.isHiddenFile(): Boolean { + return when (firstOrNull()) { + '.', '\u3002', '\uFF0E', '\uFF61' -> true + else -> false + } +} + +// ⚑ Bolt Performance Optimization: Single-pass loop with lazy StringBuilder +// Chained `.replace()` calls allocate multiple intermediate strings. +// A single pass over the string lazily allocating a StringBuilder is much faster. +fun String.escapeHtml(): String { + var sb: StringBuilder? = null + for (i in 0 until this.length) { + val c = this[i] + val replacement = when (c) { + '&' -> "&" + '<' -> "<" + '>' -> ">" + '"' -> """ + '\'' -> "'" + '`' -> "`" + else -> null + } + if (replacement != null) { + if (sb == null) { + sb = StringBuilder(this.length + 16) + sb.append(this as CharSequence, 0, i) + } + sb.append(replacement) + } else { + sb?.append(c) + } + } + return sb?.toString() ?: this +} + +fun String.urlEncodePath(): String { + val bytes = this.toByteArray(Charsets.UTF_8) + var encoded: StringBuilder? = null + for (i in bytes.indices) { + val byte = bytes[i].toInt() and 0xff + val isUnreserved = (byte in 'A'.toInt()..'Z'.toInt()) || + (byte in 'a'.toInt()..'z'.toInt()) || + (byte in '0'.toInt()..'9'.toInt()) || + byte == '-'.toInt() || + byte == '.'.toInt() || + byte == '_'.toInt() || + byte == '~'.toInt() + if (isUnreserved) { + encoded?.append(byte.toChar()) + } else { + var builder = encoded + if (builder == null) { + builder = StringBuilder(bytes.size + 16) + for (j in 0 until i) { + builder.append((bytes[j].toInt() and 0xff).toChar()) + } + encoded = builder + } + // ⚑ Bolt Performance Optimization: Direct character mapping + // Avoids multiple string allocations (toString, padStart, toUpperCase) per reserved byte. + builder.append('%') + val hex1 = byte ushr 4 + val hex2 = byte and 0xf + builder.append(if (hex1 < 10) (hex1 + 48).toChar() else (hex1 + 55).toChar()) + builder.append(if (hex2 < 10) (hex2 + 48).toChar() else (hex2 + 55).toChar()) + } + } + return encoded?.toString() ?: this +} + +fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): 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() + + val list = dirFilesNames ?: curr_dir.list() + + if (list?.contains(ignore_filename) == true) { + if (!ignore_file.isFile || Files.isSymbolicLink(ignore_file.toPath()) || !ignore_file.canRead()) { + throw IgnoreFileReadException("Policy file \$ignore_filename is present in directory snapshot but inaccessible or invalid") + } + } + + // λ³΄μ•ˆ ν–₯상: .html4ignore 파일이 일반 νŒŒμΌμΈμ§€ ν™•μΈν•˜κ³ , 심볼릭 링크인 경우 λ¬΄μ‹œν•˜μ—¬ DoS 및 경둜 μ‘°μž‘μ„ λ°©μ§€ν•©λ‹ˆλ‹€. + // λ³΄μ•ˆ ν–₯상: 파일 크기(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() + + 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) { + } + } + } + } + + // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ Set에 μΆ”κ°€ν•˜κΈ° μœ„ν•΄ ν•„ν„°λ§λ§Œ ν•  λ•ŒλŠ” 정렬이 λΆˆν•„μš”ν•˜λ―€λ‘œ .sorted()λ₯Ό μ œκ±°ν•˜μ—¬ O(N log N) μ˜€λ²„ν—€λ“œλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. + list?.forEach { + val current = it + val pathCurrent = try { + java.nio.file.Paths.get(current) + } catch (_: java.nio.file.InvalidPathException) { + files_to_exclude.add(current) + return@forEach + } + for (matcher in ignored_matchers) { + if (matcher.matches(pathCurrent)) { + files_to_exclude.add(current) + break + } + } + } + } + + if ("index.html" !in files_to_exclude) + files_to_exclude.add("index.html") + + // ⚑ Bolt Performance Optimization: Extract static list to prevent redundant allocations per directory + // λ³΄μ•ˆ ν–₯상: λ―Όκ°ν•œ μ‹œμŠ€ν…œ, μ„€μ •, μ‹œν¬λ¦Ώ νŒŒμΌμ„ 디렉토리 λͺ©λ‘μ—μ„œ 기본적으둜 μ œμ™Έν•˜μ—¬ 정보 λ…ΈμΆœ(Information Exposure) λ°©μ§€ + files_to_exclude.addAll(Constants.defaultSensitiveFiles) + + // λ³΄μ•ˆ ν–₯상: dot-like prefixes and case variants of known sensitive names are excluded. + list?.forEach { + val normalizedName = it.toLowerCase(java.util.Locale.ROOT) + if ( + it.isHiddenFile() || + normalizedName in Constants.defaultSensitiveFileNamesLowercase || + normalizedName.endsWith("~") || + Constants.defaultSensitiveExtensions.any { extension -> + normalizedName.endsWith(extension) + } + ) { + files_to_exclude.add(it) + } + } + + return files_to_exclude +} + +fun write_index_file( + curr_dir: File, + content: String, + moveFile: ( + java.nio.file.Path, + java.nio.file.Path, + Array + ) -> Unit = { source, target, options -> + Files.move(source, target, *options) + Unit + } +) { + val indexPath = curr_dir.toPath().resolve("index.html") + val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html") + try { + Files.write(tempPath, content.toByteArray(Charsets.UTF_8)) + try { + // With ATOMIC_MOVE, Java ignores every other copy option and the + // existing-target policy is provider-specific. + moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.ATOMIC_MOVE)) + } catch (error: java.io.IOException) { + if ( + error !is java.nio.file.AtomicMoveNotSupportedException && + error !is java.nio.file.FileAlreadyExistsException + ) { + throw error + } + // This compatibility fallback preserves replacement semantics but + // is explicitly non-atomic. + moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING)) + } + } finally { + Files.deleteIfExists(tempPath) + } +} + +fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array? = null){ + + val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) + val directoryName = curr_dir.name.ifEmpty { "Root" } + + val index_top = """ + + + + + + + + + + + + + ${directoryName.escapeHtml()} - 디렉토리 λͺ©λ‘ + + + +
+

${directoryName.escapeHtml()}

+ +
+ + +""" + + try { + write_index_file(curr_dir, index_top+index_middle()+index_bottom) + } catch (e: Exception) { + // λ³΄μ•ˆ ν–₯상: 디렉토리에 μ“°κΈ° κΆŒν•œμ΄ μ—†κ±°λ‚˜ 파일 μ‹œμŠ€ν…œ 였λ₯˜κ°€ λ°œμƒν–ˆμ„ λ•Œ + // 전체 크둀링(ν”„λ‘œμ„ΈμŠ€)이 μ€‘λ‹¨λ˜λŠ” DoSλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. (Fail Securely) + } + +} + +fun help() { + println("ERROR: help has not been written yet!") +} + +private object Constants { + @JvmField + val defaultSensitiveFiles = listOf(".git", ".env", ".ssh", ".htpasswd", ".htaccess", "id_rsa", "id_ed25519", "secrets.yml", ".html4ignore", ".DS_Store", ".aws", ".kube", ".npmrc", ".gnupg", "config.json", "credentials.json") + + @JvmField + val defaultSensitiveFileNamesLowercase = + defaultSensitiveFiles.map { it.toLowerCase(java.util.Locale.ROOT) }.toSet() + + @JvmField + val defaultSensitiveExtensions = listOf( + ".pem", + ".key", + ".p12", + ".pfx", + ".crt", + ".cer", + ".der", + ".keystore", + ".truststore", + ".jks", + ".sqlite", + ".db", + ".bak", + ".sql", + ".pcap", + ".pcapng", + ".log", + ".swp", + ".swo", + ".swpx" + ) +} diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 5b76cc5d..602e2ff7 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -718,9 +718,12 @@ class MainTest { val ignoreDir = File(tempDir, ".html4ignore") ignoreDir.mkdir() - // This should not crash or parse the directory - val excluded = process_ignore_file(tempDir, null) - assertTrue(excluded.contains("index.html")) + try { + process_ignore_file(tempDir, null) + org.junit.Assert.fail("Expected IgnoreFileReadException") + } catch (e: IgnoreFileReadException) { + // Expected + } } @Test @@ -762,10 +765,12 @@ class MainTest { File(tempDir, "test.txt").createNewFile() - // Should ignore the symlink and NOT parse it - val excluded = process_ignore_file(tempDir, null) - assertFalse(excluded.contains("test.txt")) - assertTrue(excluded.contains("index.html")) + try { + process_ignore_file(tempDir, null) + org.junit.Assert.fail("Expected IgnoreFileReadException") + } catch (e: IgnoreFileReadException) { + // Expected + } } @Test From 1fb5921c3c6f68dc549f05f4d97546357c193cb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:11:34 +0900 Subject: [PATCH 02/12] repair(security): remove generated doctrine and source backup artifact --- .jules/sentinel.md | 5 - src/main/kotlin/html4tree/main.kt.orig | 543 ------------------------- 2 files changed, 548 deletions(-) delete mode 100644 src/main/kotlin/html4tree/main.kt.orig diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e259a895..a885865d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,8 +99,3 @@ **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-08-11 - [CRITICAL] μ •μ±… 파일(.html4ignore) TOCTOU μ‹€νŒ¨ μ‹œ Fail-Closed 처리 λˆ„λ½ λ°©μ§€ -**Vulnerability:** 파일 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore`κ°€ μ‘΄μž¬ν•˜μ§€λ§Œ μ‹€μ œλ‘œ 읽으렀 ν•  λ•Œ μ ‘κ·Ό λΆˆκ°€, 심볼릭 링크 λ³€κ²½, λ˜λŠ” 디렉토리인 경우, 이λ₯Ό λ‹¨μˆœνžˆ λ¬΄μ‹œ(Fail-Open)ν•˜κ³  λͺ¨λ“  νŒŒμΌμ„ λ…ΈμΆœν•˜λŠ” TOCTOU (Time-of-Check to Time-of-Use) 취약점. -**Learning:** λ³΄μ•ˆ 및 λ¬΄μ‹œ κ·œμΉ™μ΄ λͺ…μ‹œλœ 파일이 μ‘΄μž¬ν•¨μ—λ„ λΆˆκ΅¬ν•˜κ³  이λ₯Ό 읽지 λͺ»ν•  λ•Œ 기본적으둜 λ¬΄μ‹œν•˜κ³  계속 μ§„ν–‰ν•˜λ©΄, λ―Όκ°ν•œ 파일이 μ˜λ„μΉ˜ μ•Šκ²Œ 인덱싱될 수 μžˆμŠ΅λ‹ˆλ‹€. (Implicit Trust) -**Prevention:** μ •μ±… 파일이 디렉토리 μŠ€λƒ…μƒ·μ— ν¬ν•¨λ˜μ–΄ μžˆλ‹€λ©΄ 이λ₯Ό νŒŒμ‹±ν•˜κΈ° μ „ 검증 μ‹€νŒ¨λ‚˜ μ ‘κ·Ό 였λ₯˜ μ‹œ λ°˜λ“œμ‹œ μ˜ˆμ™Έ(`IgnoreFileReadException`)λ₯Ό λ˜μ§€κ³ , μƒμœ„ 디렉토리 크둀링 λ‘œμ§μ—μ„œ 이 μ˜ˆμ™Έλ₯Ό μž‘μ•„ ν•΄λ‹Ή 디렉토리 전체에 λŒ€ν•œ λ Œλ”λ§ 및 ν•˜μœ„ 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•΄μ•Ό ν•©λ‹ˆλ‹€. diff --git a/src/main/kotlin/html4tree/main.kt.orig b/src/main/kotlin/html4tree/main.kt.orig deleted file mode 100644 index c75de526..00000000 --- a/src/main/kotlin/html4tree/main.kt.orig +++ /dev/null @@ -1,543 +0,0 @@ -package html4tree - -import java.io.File -import java.security.MessageDigest -import java.nio.file.Files -import java.nio.file.LinkOption -import java.nio.file.StandardCopyOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.Base64 -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.types.int - -private val CSS_CONTENT = """ -body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - line-height: 1.5; - padding: 1rem; - color: #1f2328; -} -main { - max-width: 800px; - margin: 0 auto; -} -h1 { - overflow-wrap: anywhere; -} -ul { - list-style-type: none; - padding-left: 0; -} -a.dir-link { - display: flex; - align-items: flex-start; - gap: 0.5rem; - width: 100%; - overflow-wrap: anywhere; - box-sizing: border-box; -} -.icon { - flex-shrink: 0; - width: 1.25rem; - text-align: center; -} -a { - padding: 0.75rem 0.5rem; - text-decoration: none; - color: #0969da; - border-radius: 4px; - transition: background-color 0.2s ease, outline-color 0.2s ease; -} -a:hover, a:focus-visible { - background-color: #f6f8fa; - outline: 2px solid #0969da; - outline-offset: -2px; -} -a:hover span:last-child, a:focus-visible span:last-child { - text-decoration: underline; -} -@media (prefers-reduced-motion: reduce) { - a { - transition: none; - } -} -li + li { - border-top: 1px solid #d0d7de; -} -.empty-dir { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.75rem 0.5rem; - color: #656d76; - font-style: italic; -} -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} -@media (prefers-color-scheme: dark) { - body { - background-color: #0d1117; - color: #c9d1d9; - } - a { - color: #58a6ff; - } - a:hover, a:focus-visible { - background-color: #161b22; - outline-color: #58a6ff; - } - li + li { - border-top-color: #21262d; - } - .empty-dir { - color: #8b949e; - } -} -""".trimIndent() - -private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) -private val FILE_NAME_COMPARATOR = compareBy { it.name } - -class IgnoreFileReadException(message: String) : java.io.IOException(message) - -class Html4tree : CliktCommand() { - val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) - val topDir: String by argument(help="Top directory to crawl") - - override fun run() { - go(topDir, maxLevel) - } -} - -fun main(args: Array) = Html4tree().main(args) - - -internal data class FileIdentity(val key: Any?, val readable: Boolean) - - -internal fun read_file_identity(file: File): FileIdentity { - return try { - val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) - FileIdentity(attrs.fileKey(), true) - } catch (e: Exception) { - FileIdentity(null, false) - } -} - -fun go(topDir: String, maxLevel: Int) { - require(topDir.isNotBlank()) - require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } - // λ³΄μ•ˆ μˆ˜μ •: symlink 검사λ₯Ό μš°νšŒν•˜λŠ” canonicalFile λŒ€μ‹  absoluteFile을 μ‚¬μš© - // canonicalFile은 symlinkλ₯Ό λŒ€μƒ 경둜둜 ν•΄μ„ν•˜μ—¬ μ΄μ–΄μ§€λŠ” NOFOLLOW_LINKS 검사λ₯Ό 무λ ₯ν™”ν•©λ‹ˆλ‹€. - val top_dir = File(topDir).absoluteFile.toPath().normalize().toFile() - - // λ³΄μ•ˆ ν–₯상: μ‹œμŠ€ν…œ 전체 정보 λ…ΈμΆœ 및 λ¦¬μ†ŒμŠ€ 고갈(DoS) λ°©μ§€λ₯Ό μœ„ν•΄ 크둜슀 ν”Œλž«νΌ λ°©μ‹μœΌλ‘œ 루트 디렉토리 크둀링을 μ œν•œν•©λ‹ˆλ‹€. - require(top_dir.parentFile != null) { "Crawling the root directory is not allowed for security reasons" } - - require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } - - val ll = LinkedList() - - val topEntry = LinkedListEntry(top_dir,0, read_file_identity(top_dir).key) - ll.push(topEntry) - crawl_directories(ll, maxLevel) -} - -internal fun crawl_directories( - ll: LinkedList, - maxLevel: Int, - processDirectory: (File, Set, Array?) -> Unit = { file, exclude, files -> process_dir(file, exclude, files) }, - processIgnoreFile: (File, Array?) -> Set = { file, names -> process_ignore_file(file, names) }, - listFiles: (File) -> Array? = { it.listFiles() }, - readAttributes: (File) -> BasicFileAttributes? = { - try { - Files.readAttributes(it.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) - } catch (e: Exception) { - null - } - }, - readIdentity: (File) -> FileIdentity = ::read_file_identity -) { - var lle: LinkedListEntry? = ll.pull() - - while(lle != null){ - val attrs = readAttributes(lle.file) - if (attrs == null || !attrs.isDirectory) { - lle = ll.pull() - continue - } - - val currentIdentity = readIdentity(lle.file) - if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) { - lle = ll.pull() - continue - } - - val currentLevel: Int = lle.level - - // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ μΊμ‹±ν•˜μ—¬ μ€‘λ³΅λœ I/O μ‹œμŠ€ν…œ ν˜ΈμΆœμ„ μ€„μž„ - val dirFiles = listFiles(lle.file) - - // The path can be replaced between the initial identity check and - // directory enumeration. Do not process or enqueue children from a - // snapshot whose post-listing identity is unreadable or different. - val postListingIdentity = readIdentity(lle.file) - if (!postListingIdentity.readable || currentIdentity.key != postListingIdentity.key) { - lle = ll.pull() - continue - } - - val dirFilesNames = dirFiles?.let { files -> - Array(files.size) { index -> files[index].name } - } - val exclude = try { - processIgnoreFile(lle.file, dirFilesNames) - } catch (e: IgnoreFileReadException) { - lle = ll.pull() - continue - } - - if(maxLevel == -1 || currentLevel <= maxLevel) - processDirectory(lle.file, exclude, dirFiles) - - if(maxLevel == -1 || currentLevel < maxLevel) { - dirFiles?.forEach { - // ⚑ Bolt Performance Optimization: Short-circuit OS stat calls - // by checking cheap in-memory string exclusion rules first - if(!it.name.isHiddenFile() && it.name !in exclude) { - val childAttrs = readAttributes(it) - if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) { - val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) - ll.push(childEntry) - } - } - } - } - lle = ll.pull() - } -} - -fun String.isHiddenFile(): Boolean { - return when (firstOrNull()) { - '.', '\u3002', '\uFF0E', '\uFF61' -> true - else -> false - } -} - -// ⚑ Bolt Performance Optimization: Single-pass loop with lazy StringBuilder -// Chained `.replace()` calls allocate multiple intermediate strings. -// A single pass over the string lazily allocating a StringBuilder is much faster. -fun String.escapeHtml(): String { - var sb: StringBuilder? = null - for (i in 0 until this.length) { - val c = this[i] - val replacement = when (c) { - '&' -> "&" - '<' -> "<" - '>' -> ">" - '"' -> """ - '\'' -> "'" - '`' -> "`" - else -> null - } - if (replacement != null) { - if (sb == null) { - sb = StringBuilder(this.length + 16) - sb.append(this as CharSequence, 0, i) - } - sb.append(replacement) - } else { - sb?.append(c) - } - } - return sb?.toString() ?: this -} - -fun String.urlEncodePath(): String { - val bytes = this.toByteArray(Charsets.UTF_8) - var encoded: StringBuilder? = null - for (i in bytes.indices) { - val byte = bytes[i].toInt() and 0xff - val isUnreserved = (byte in 'A'.toInt()..'Z'.toInt()) || - (byte in 'a'.toInt()..'z'.toInt()) || - (byte in '0'.toInt()..'9'.toInt()) || - byte == '-'.toInt() || - byte == '.'.toInt() || - byte == '_'.toInt() || - byte == '~'.toInt() - if (isUnreserved) { - encoded?.append(byte.toChar()) - } else { - var builder = encoded - if (builder == null) { - builder = StringBuilder(bytes.size + 16) - for (j in 0 until i) { - builder.append((bytes[j].toInt() and 0xff).toChar()) - } - encoded = builder - } - // ⚑ Bolt Performance Optimization: Direct character mapping - // Avoids multiple string allocations (toString, padStart, toUpperCase) per reserved byte. - builder.append('%') - val hex1 = byte ushr 4 - val hex2 = byte and 0xf - builder.append(if (hex1 < 10) (hex1 + 48).toChar() else (hex1 + 55).toChar()) - builder.append(if (hex2 < 10) (hex2 + 48).toChar() else (hex2 + 55).toChar()) - } - } - return encoded?.toString() ?: this -} - -fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): 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() - - val list = dirFilesNames ?: curr_dir.list() - - if (list?.contains(ignore_filename) == true) { - if (!ignore_file.isFile || Files.isSymbolicLink(ignore_file.toPath()) || !ignore_file.canRead()) { - throw IgnoreFileReadException("Policy file \$ignore_filename is present in directory snapshot but inaccessible or invalid") - } - } - - // λ³΄μ•ˆ ν–₯상: .html4ignore 파일이 일반 νŒŒμΌμΈμ§€ ν™•μΈν•˜κ³ , 심볼릭 링크인 경우 λ¬΄μ‹œν•˜μ—¬ DoS 및 경둜 μ‘°μž‘μ„ λ°©μ§€ν•©λ‹ˆλ‹€. - // λ³΄μ•ˆ ν–₯상: 파일 크기(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() - - 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) { - } - } - } - } - - // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ Set에 μΆ”κ°€ν•˜κΈ° μœ„ν•΄ ν•„ν„°λ§λ§Œ ν•  λ•ŒλŠ” 정렬이 λΆˆν•„μš”ν•˜λ―€λ‘œ .sorted()λ₯Ό μ œκ±°ν•˜μ—¬ O(N log N) μ˜€λ²„ν—€λ“œλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. - list?.forEach { - val current = it - val pathCurrent = try { - java.nio.file.Paths.get(current) - } catch (_: java.nio.file.InvalidPathException) { - files_to_exclude.add(current) - return@forEach - } - for (matcher in ignored_matchers) { - if (matcher.matches(pathCurrent)) { - files_to_exclude.add(current) - break - } - } - } - } - - if ("index.html" !in files_to_exclude) - files_to_exclude.add("index.html") - - // ⚑ Bolt Performance Optimization: Extract static list to prevent redundant allocations per directory - // λ³΄μ•ˆ ν–₯상: λ―Όκ°ν•œ μ‹œμŠ€ν…œ, μ„€μ •, μ‹œν¬λ¦Ώ νŒŒμΌμ„ 디렉토리 λͺ©λ‘μ—μ„œ 기본적으둜 μ œμ™Έν•˜μ—¬ 정보 λ…ΈμΆœ(Information Exposure) λ°©μ§€ - files_to_exclude.addAll(Constants.defaultSensitiveFiles) - - // λ³΄μ•ˆ ν–₯상: dot-like prefixes and case variants of known sensitive names are excluded. - list?.forEach { - val normalizedName = it.toLowerCase(java.util.Locale.ROOT) - if ( - it.isHiddenFile() || - normalizedName in Constants.defaultSensitiveFileNamesLowercase || - normalizedName.endsWith("~") || - Constants.defaultSensitiveExtensions.any { extension -> - normalizedName.endsWith(extension) - } - ) { - files_to_exclude.add(it) - } - } - - return files_to_exclude -} - -fun write_index_file( - curr_dir: File, - content: String, - moveFile: ( - java.nio.file.Path, - java.nio.file.Path, - Array - ) -> Unit = { source, target, options -> - Files.move(source, target, *options) - Unit - } -) { - val indexPath = curr_dir.toPath().resolve("index.html") - val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html") - try { - Files.write(tempPath, content.toByteArray(Charsets.UTF_8)) - try { - // With ATOMIC_MOVE, Java ignores every other copy option and the - // existing-target policy is provider-specific. - moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.ATOMIC_MOVE)) - } catch (error: java.io.IOException) { - if ( - error !is java.nio.file.AtomicMoveNotSupportedException && - error !is java.nio.file.FileAlreadyExistsException - ) { - throw error - } - // This compatibility fallback preserves replacement semantics but - // is explicitly non-atomic. - moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING)) - } - } finally { - Files.deleteIfExists(tempPath) - } -} - -fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array? = null){ - - val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) - val directoryName = curr_dir.name.ifEmpty { "Root" } - - val index_top = """ - - - - - - - - - - - - - ${directoryName.escapeHtml()} - 디렉토리 λͺ©λ‘ - - - -
-

${directoryName.escapeHtml()}

- -
- - -""" - - try { - write_index_file(curr_dir, index_top+index_middle()+index_bottom) - } catch (e: Exception) { - // λ³΄μ•ˆ ν–₯상: 디렉토리에 μ“°κΈ° κΆŒν•œμ΄ μ—†κ±°λ‚˜ 파일 μ‹œμŠ€ν…œ 였λ₯˜κ°€ λ°œμƒν–ˆμ„ λ•Œ - // 전체 크둀링(ν”„λ‘œμ„ΈμŠ€)이 μ€‘λ‹¨λ˜λŠ” DoSλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. (Fail Securely) - } - -} - -fun help() { - println("ERROR: help has not been written yet!") -} - -private object Constants { - @JvmField - val defaultSensitiveFiles = listOf(".git", ".env", ".ssh", ".htpasswd", ".htaccess", "id_rsa", "id_ed25519", "secrets.yml", ".html4ignore", ".DS_Store", ".aws", ".kube", ".npmrc", ".gnupg", "config.json", "credentials.json") - - @JvmField - val defaultSensitiveFileNamesLowercase = - defaultSensitiveFiles.map { it.toLowerCase(java.util.Locale.ROOT) }.toSet() - - @JvmField - val defaultSensitiveExtensions = listOf( - ".pem", - ".key", - ".p12", - ".pfx", - ".crt", - ".cer", - ".der", - ".keystore", - ".truststore", - ".jks", - ".sqlite", - ".db", - ".bak", - ".sql", - ".pcap", - ".pcapng", - ".log", - ".swp", - ".swo", - ".swpx" - ) -} From 466c214dd7b358e77d411514165ad72562871bcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:12:06 +0900 Subject: [PATCH 03/12] test(security): prove policy read failure blocks publication --- .../html4tree/IgnorePolicyFailClosedTest.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/test/kotlin/html4tree/IgnorePolicyFailClosedTest.kt diff --git a/src/test/kotlin/html4tree/IgnorePolicyFailClosedTest.kt b/src/test/kotlin/html4tree/IgnorePolicyFailClosedTest.kt new file mode 100644 index 00000000..c588eab9 --- /dev/null +++ b/src/test/kotlin/html4tree/IgnorePolicyFailClosedTest.kt @@ -0,0 +1,30 @@ +package html4tree + +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.test.assertFalse + +class IgnorePolicyFailClosedTest { + @Test + fun crawlDoesNotPublishDirectoryWhenPolicyReadFails() { + val root = Files.createTempDirectory("html4tree-ignore-fail-closed-").toFile() + var processed = false + + try { + val list = LinkedList() + list.push(LinkedListEntry(root, 0, read_file_identity(root).key)) + + crawl_directories( + list, + -1, + processDirectory = { _: File, _: Set, _: Array? -> processed = true }, + processIgnoreFile = { _, _ -> throw IgnoreFileReadException("policy read failed") } + ) + + assertFalse(processed, "a directory must not be published when its declared ignore policy cannot be read") + } finally { + root.deleteRecursively() + } + } +} From 41f89a1d224f4da152d609a567183fd4b4556fb7 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:30:13 +0000 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. --- .jules/sentinel.md | 5 + src/main/kotlin/html4tree/main.kt.orig | 543 ++++++++++++++++++ .../html4tree/IgnorePolicyFailClosedTest.kt | 30 - 3 files changed, 548 insertions(+), 30 deletions(-) create mode 100644 src/main/kotlin/html4tree/main.kt.orig delete mode 100644 src/test/kotlin/html4tree/IgnorePolicyFailClosedTest.kt diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..e259a895 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`). + +## 2026-08-11 - [CRITICAL] μ •μ±… 파일(.html4ignore) TOCTOU μ‹€νŒ¨ μ‹œ Fail-Closed 처리 λˆ„λ½ λ°©μ§€ +**Vulnerability:** 파일 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore`κ°€ μ‘΄μž¬ν•˜μ§€λ§Œ μ‹€μ œλ‘œ 읽으렀 ν•  λ•Œ μ ‘κ·Ό λΆˆκ°€, 심볼릭 링크 λ³€κ²½, λ˜λŠ” 디렉토리인 경우, 이λ₯Ό λ‹¨μˆœνžˆ λ¬΄μ‹œ(Fail-Open)ν•˜κ³  λͺ¨λ“  νŒŒμΌμ„ λ…ΈμΆœν•˜λŠ” TOCTOU (Time-of-Check to Time-of-Use) 취약점. +**Learning:** λ³΄μ•ˆ 및 λ¬΄μ‹œ κ·œμΉ™μ΄ λͺ…μ‹œλœ 파일이 μ‘΄μž¬ν•¨μ—λ„ λΆˆκ΅¬ν•˜κ³  이λ₯Ό 읽지 λͺ»ν•  λ•Œ 기본적으둜 λ¬΄μ‹œν•˜κ³  계속 μ§„ν–‰ν•˜λ©΄, λ―Όκ°ν•œ 파일이 μ˜λ„μΉ˜ μ•Šκ²Œ 인덱싱될 수 μžˆμŠ΅λ‹ˆλ‹€. (Implicit Trust) +**Prevention:** μ •μ±… 파일이 디렉토리 μŠ€λƒ…μƒ·μ— ν¬ν•¨λ˜μ–΄ μžˆλ‹€λ©΄ 이λ₯Ό νŒŒμ‹±ν•˜κΈ° μ „ 검증 μ‹€νŒ¨λ‚˜ μ ‘κ·Ό 였λ₯˜ μ‹œ λ°˜λ“œμ‹œ μ˜ˆμ™Έ(`IgnoreFileReadException`)λ₯Ό λ˜μ§€κ³ , μƒμœ„ 디렉토리 크둀링 λ‘œμ§μ—μ„œ 이 μ˜ˆμ™Έλ₯Ό μž‘μ•„ ν•΄λ‹Ή 디렉토리 전체에 λŒ€ν•œ λ Œλ”λ§ 및 ν•˜μœ„ 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•΄μ•Ό ν•©λ‹ˆλ‹€. diff --git a/src/main/kotlin/html4tree/main.kt.orig b/src/main/kotlin/html4tree/main.kt.orig new file mode 100644 index 00000000..c75de526 --- /dev/null +++ b/src/main/kotlin/html4tree/main.kt.orig @@ -0,0 +1,543 @@ +package html4tree + +import java.io.File +import java.security.MessageDigest +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.Base64 +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.types.int + +private val CSS_CONTENT = """ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + padding: 1rem; + color: #1f2328; +} +main { + max-width: 800px; + margin: 0 auto; +} +h1 { + overflow-wrap: anywhere; +} +ul { + list-style-type: none; + padding-left: 0; +} +a.dir-link { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; + overflow-wrap: anywhere; + box-sizing: border-box; +} +.icon { + flex-shrink: 0; + width: 1.25rem; + text-align: center; +} +a { + padding: 0.75rem 0.5rem; + text-decoration: none; + color: #0969da; + border-radius: 4px; + transition: background-color 0.2s ease, outline-color 0.2s ease; +} +a:hover, a:focus-visible { + background-color: #f6f8fa; + outline: 2px solid #0969da; + outline-offset: -2px; +} +a:hover span:last-child, a:focus-visible span:last-child { + text-decoration: underline; +} +@media (prefers-reduced-motion: reduce) { + a { + transition: none; + } +} +li + li { + border-top: 1px solid #d0d7de; +} +.empty-dir { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 0.5rem; + color: #656d76; + font-style: italic; +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +@media (prefers-color-scheme: dark) { + body { + background-color: #0d1117; + color: #c9d1d9; + } + a { + color: #58a6ff; + } + a:hover, a:focus-visible { + background-color: #161b22; + outline-color: #58a6ff; + } + li + li { + border-top-color: #21262d; + } + .empty-dir { + color: #8b949e; + } +} +""".trimIndent() + +private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) +private val FILE_NAME_COMPARATOR = compareBy { it.name } + +class IgnoreFileReadException(message: String) : java.io.IOException(message) + +class Html4tree : CliktCommand() { + val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) + val topDir: String by argument(help="Top directory to crawl") + + override fun run() { + go(topDir, maxLevel) + } +} + +fun main(args: Array) = Html4tree().main(args) + + +internal data class FileIdentity(val key: Any?, val readable: Boolean) + + +internal fun read_file_identity(file: File): FileIdentity { + return try { + val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + FileIdentity(attrs.fileKey(), true) + } catch (e: Exception) { + FileIdentity(null, false) + } +} + +fun go(topDir: String, maxLevel: Int) { + require(topDir.isNotBlank()) + require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } + // λ³΄μ•ˆ μˆ˜μ •: symlink 검사λ₯Ό μš°νšŒν•˜λŠ” canonicalFile λŒ€μ‹  absoluteFile을 μ‚¬μš© + // canonicalFile은 symlinkλ₯Ό λŒ€μƒ 경둜둜 ν•΄μ„ν•˜μ—¬ μ΄μ–΄μ§€λŠ” NOFOLLOW_LINKS 검사λ₯Ό 무λ ₯ν™”ν•©λ‹ˆλ‹€. + val top_dir = File(topDir).absoluteFile.toPath().normalize().toFile() + + // λ³΄μ•ˆ ν–₯상: μ‹œμŠ€ν…œ 전체 정보 λ…ΈμΆœ 및 λ¦¬μ†ŒμŠ€ 고갈(DoS) λ°©μ§€λ₯Ό μœ„ν•΄ 크둜슀 ν”Œλž«νΌ λ°©μ‹μœΌλ‘œ 루트 디렉토리 크둀링을 μ œν•œν•©λ‹ˆλ‹€. + require(top_dir.parentFile != null) { "Crawling the root directory is not allowed for security reasons" } + + require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } + + val ll = LinkedList() + + val topEntry = LinkedListEntry(top_dir,0, read_file_identity(top_dir).key) + ll.push(topEntry) + crawl_directories(ll, maxLevel) +} + +internal fun crawl_directories( + ll: LinkedList, + maxLevel: Int, + processDirectory: (File, Set, Array?) -> Unit = { file, exclude, files -> process_dir(file, exclude, files) }, + processIgnoreFile: (File, Array?) -> Set = { file, names -> process_ignore_file(file, names) }, + listFiles: (File) -> Array? = { it.listFiles() }, + readAttributes: (File) -> BasicFileAttributes? = { + try { + Files.readAttributes(it.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + } catch (e: Exception) { + null + } + }, + readIdentity: (File) -> FileIdentity = ::read_file_identity +) { + var lle: LinkedListEntry? = ll.pull() + + while(lle != null){ + val attrs = readAttributes(lle.file) + if (attrs == null || !attrs.isDirectory) { + lle = ll.pull() + continue + } + + val currentIdentity = readIdentity(lle.file) + if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) { + lle = ll.pull() + continue + } + + val currentLevel: Int = lle.level + + // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ μΊμ‹±ν•˜μ—¬ μ€‘λ³΅λœ I/O μ‹œμŠ€ν…œ ν˜ΈμΆœμ„ μ€„μž„ + val dirFiles = listFiles(lle.file) + + // The path can be replaced between the initial identity check and + // directory enumeration. Do not process or enqueue children from a + // snapshot whose post-listing identity is unreadable or different. + val postListingIdentity = readIdentity(lle.file) + if (!postListingIdentity.readable || currentIdentity.key != postListingIdentity.key) { + lle = ll.pull() + continue + } + + val dirFilesNames = dirFiles?.let { files -> + Array(files.size) { index -> files[index].name } + } + val exclude = try { + processIgnoreFile(lle.file, dirFilesNames) + } catch (e: IgnoreFileReadException) { + lle = ll.pull() + continue + } + + if(maxLevel == -1 || currentLevel <= maxLevel) + processDirectory(lle.file, exclude, dirFiles) + + if(maxLevel == -1 || currentLevel < maxLevel) { + dirFiles?.forEach { + // ⚑ Bolt Performance Optimization: Short-circuit OS stat calls + // by checking cheap in-memory string exclusion rules first + if(!it.name.isHiddenFile() && it.name !in exclude) { + val childAttrs = readAttributes(it) + if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) { + val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) + ll.push(childEntry) + } + } + } + } + lle = ll.pull() + } +} + +fun String.isHiddenFile(): Boolean { + return when (firstOrNull()) { + '.', '\u3002', '\uFF0E', '\uFF61' -> true + else -> false + } +} + +// ⚑ Bolt Performance Optimization: Single-pass loop with lazy StringBuilder +// Chained `.replace()` calls allocate multiple intermediate strings. +// A single pass over the string lazily allocating a StringBuilder is much faster. +fun String.escapeHtml(): String { + var sb: StringBuilder? = null + for (i in 0 until this.length) { + val c = this[i] + val replacement = when (c) { + '&' -> "&" + '<' -> "<" + '>' -> ">" + '"' -> """ + '\'' -> "'" + '`' -> "`" + else -> null + } + if (replacement != null) { + if (sb == null) { + sb = StringBuilder(this.length + 16) + sb.append(this as CharSequence, 0, i) + } + sb.append(replacement) + } else { + sb?.append(c) + } + } + return sb?.toString() ?: this +} + +fun String.urlEncodePath(): String { + val bytes = this.toByteArray(Charsets.UTF_8) + var encoded: StringBuilder? = null + for (i in bytes.indices) { + val byte = bytes[i].toInt() and 0xff + val isUnreserved = (byte in 'A'.toInt()..'Z'.toInt()) || + (byte in 'a'.toInt()..'z'.toInt()) || + (byte in '0'.toInt()..'9'.toInt()) || + byte == '-'.toInt() || + byte == '.'.toInt() || + byte == '_'.toInt() || + byte == '~'.toInt() + if (isUnreserved) { + encoded?.append(byte.toChar()) + } else { + var builder = encoded + if (builder == null) { + builder = StringBuilder(bytes.size + 16) + for (j in 0 until i) { + builder.append((bytes[j].toInt() and 0xff).toChar()) + } + encoded = builder + } + // ⚑ Bolt Performance Optimization: Direct character mapping + // Avoids multiple string allocations (toString, padStart, toUpperCase) per reserved byte. + builder.append('%') + val hex1 = byte ushr 4 + val hex2 = byte and 0xf + builder.append(if (hex1 < 10) (hex1 + 48).toChar() else (hex1 + 55).toChar()) + builder.append(if (hex2 < 10) (hex2 + 48).toChar() else (hex2 + 55).toChar()) + } + } + return encoded?.toString() ?: this +} + +fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): 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() + + val list = dirFilesNames ?: curr_dir.list() + + if (list?.contains(ignore_filename) == true) { + if (!ignore_file.isFile || Files.isSymbolicLink(ignore_file.toPath()) || !ignore_file.canRead()) { + throw IgnoreFileReadException("Policy file \$ignore_filename is present in directory snapshot but inaccessible or invalid") + } + } + + // λ³΄μ•ˆ ν–₯상: .html4ignore 파일이 일반 νŒŒμΌμΈμ§€ ν™•μΈν•˜κ³ , 심볼릭 링크인 경우 λ¬΄μ‹œν•˜μ—¬ DoS 및 경둜 μ‘°μž‘μ„ λ°©μ§€ν•©λ‹ˆλ‹€. + // λ³΄μ•ˆ ν–₯상: 파일 크기(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() + + 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) { + } + } + } + } + + // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ Set에 μΆ”κ°€ν•˜κΈ° μœ„ν•΄ ν•„ν„°λ§λ§Œ ν•  λ•ŒλŠ” 정렬이 λΆˆν•„μš”ν•˜λ―€λ‘œ .sorted()λ₯Ό μ œκ±°ν•˜μ—¬ O(N log N) μ˜€λ²„ν—€λ“œλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. + list?.forEach { + val current = it + val pathCurrent = try { + java.nio.file.Paths.get(current) + } catch (_: java.nio.file.InvalidPathException) { + files_to_exclude.add(current) + return@forEach + } + for (matcher in ignored_matchers) { + if (matcher.matches(pathCurrent)) { + files_to_exclude.add(current) + break + } + } + } + } + + if ("index.html" !in files_to_exclude) + files_to_exclude.add("index.html") + + // ⚑ Bolt Performance Optimization: Extract static list to prevent redundant allocations per directory + // λ³΄μ•ˆ ν–₯상: λ―Όκ°ν•œ μ‹œμŠ€ν…œ, μ„€μ •, μ‹œν¬λ¦Ώ νŒŒμΌμ„ 디렉토리 λͺ©λ‘μ—μ„œ 기본적으둜 μ œμ™Έν•˜μ—¬ 정보 λ…ΈμΆœ(Information Exposure) λ°©μ§€ + files_to_exclude.addAll(Constants.defaultSensitiveFiles) + + // λ³΄μ•ˆ ν–₯상: dot-like prefixes and case variants of known sensitive names are excluded. + list?.forEach { + val normalizedName = it.toLowerCase(java.util.Locale.ROOT) + if ( + it.isHiddenFile() || + normalizedName in Constants.defaultSensitiveFileNamesLowercase || + normalizedName.endsWith("~") || + Constants.defaultSensitiveExtensions.any { extension -> + normalizedName.endsWith(extension) + } + ) { + files_to_exclude.add(it) + } + } + + return files_to_exclude +} + +fun write_index_file( + curr_dir: File, + content: String, + moveFile: ( + java.nio.file.Path, + java.nio.file.Path, + Array + ) -> Unit = { source, target, options -> + Files.move(source, target, *options) + Unit + } +) { + val indexPath = curr_dir.toPath().resolve("index.html") + val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html") + try { + Files.write(tempPath, content.toByteArray(Charsets.UTF_8)) + try { + // With ATOMIC_MOVE, Java ignores every other copy option and the + // existing-target policy is provider-specific. + moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.ATOMIC_MOVE)) + } catch (error: java.io.IOException) { + if ( + error !is java.nio.file.AtomicMoveNotSupportedException && + error !is java.nio.file.FileAlreadyExistsException + ) { + throw error + } + // This compatibility fallback preserves replacement semantics but + // is explicitly non-atomic. + moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING)) + } + } finally { + Files.deleteIfExists(tempPath) + } +} + +fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array? = null){ + + val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) + val directoryName = curr_dir.name.ifEmpty { "Root" } + + val index_top = """ + + + + + + + + + + + + + ${directoryName.escapeHtml()} - 디렉토리 λͺ©λ‘ + + + +
+

${directoryName.escapeHtml()}

+ +
+ + +""" + + try { + write_index_file(curr_dir, index_top+index_middle()+index_bottom) + } catch (e: Exception) { + // λ³΄μ•ˆ ν–₯상: 디렉토리에 μ“°κΈ° κΆŒν•œμ΄ μ—†κ±°λ‚˜ 파일 μ‹œμŠ€ν…œ 였λ₯˜κ°€ λ°œμƒν–ˆμ„ λ•Œ + // 전체 크둀링(ν”„λ‘œμ„ΈμŠ€)이 μ€‘λ‹¨λ˜λŠ” DoSλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. (Fail Securely) + } + +} + +fun help() { + println("ERROR: help has not been written yet!") +} + +private object Constants { + @JvmField + val defaultSensitiveFiles = listOf(".git", ".env", ".ssh", ".htpasswd", ".htaccess", "id_rsa", "id_ed25519", "secrets.yml", ".html4ignore", ".DS_Store", ".aws", ".kube", ".npmrc", ".gnupg", "config.json", "credentials.json") + + @JvmField + val defaultSensitiveFileNamesLowercase = + defaultSensitiveFiles.map { it.toLowerCase(java.util.Locale.ROOT) }.toSet() + + @JvmField + val defaultSensitiveExtensions = listOf( + ".pem", + ".key", + ".p12", + ".pfx", + ".crt", + ".cer", + ".der", + ".keystore", + ".truststore", + ".jks", + ".sqlite", + ".db", + ".bak", + ".sql", + ".pcap", + ".pcapng", + ".log", + ".swp", + ".swo", + ".swpx" + ) +} diff --git a/src/test/kotlin/html4tree/IgnorePolicyFailClosedTest.kt b/src/test/kotlin/html4tree/IgnorePolicyFailClosedTest.kt deleted file mode 100644 index c588eab9..00000000 --- a/src/test/kotlin/html4tree/IgnorePolicyFailClosedTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package html4tree - -import org.junit.Test -import java.io.File -import java.nio.file.Files -import kotlin.test.assertFalse - -class IgnorePolicyFailClosedTest { - @Test - fun crawlDoesNotPublishDirectoryWhenPolicyReadFails() { - val root = Files.createTempDirectory("html4tree-ignore-fail-closed-").toFile() - var processed = false - - try { - val list = LinkedList() - list.push(LinkedListEntry(root, 0, read_file_identity(root).key)) - - crawl_directories( - list, - -1, - processDirectory = { _: File, _: Set, _: Array? -> processed = true }, - processIgnoreFile = { _, _ -> throw IgnoreFileReadException("policy read failed") } - ) - - assertFalse(processed, "a directory must not be published when its declared ignore policy cannot be read") - } finally { - root.deleteRecursively() - } - } -} From e1aab931abc0b9fbfe927e5e7761ec9bc15052bf Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:54:05 +0000 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. --- src/main/kotlin/html4tree/main.kt.orig | 543 ------------------------- src/test/kotlin/html4tree/MainTest.kt | 29 ++ 2 files changed, 29 insertions(+), 543 deletions(-) delete mode 100644 src/main/kotlin/html4tree/main.kt.orig diff --git a/src/main/kotlin/html4tree/main.kt.orig b/src/main/kotlin/html4tree/main.kt.orig deleted file mode 100644 index c75de526..00000000 --- a/src/main/kotlin/html4tree/main.kt.orig +++ /dev/null @@ -1,543 +0,0 @@ -package html4tree - -import java.io.File -import java.security.MessageDigest -import java.nio.file.Files -import java.nio.file.LinkOption -import java.nio.file.StandardCopyOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.Base64 -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.types.int - -private val CSS_CONTENT = """ -body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - line-height: 1.5; - padding: 1rem; - color: #1f2328; -} -main { - max-width: 800px; - margin: 0 auto; -} -h1 { - overflow-wrap: anywhere; -} -ul { - list-style-type: none; - padding-left: 0; -} -a.dir-link { - display: flex; - align-items: flex-start; - gap: 0.5rem; - width: 100%; - overflow-wrap: anywhere; - box-sizing: border-box; -} -.icon { - flex-shrink: 0; - width: 1.25rem; - text-align: center; -} -a { - padding: 0.75rem 0.5rem; - text-decoration: none; - color: #0969da; - border-radius: 4px; - transition: background-color 0.2s ease, outline-color 0.2s ease; -} -a:hover, a:focus-visible { - background-color: #f6f8fa; - outline: 2px solid #0969da; - outline-offset: -2px; -} -a:hover span:last-child, a:focus-visible span:last-child { - text-decoration: underline; -} -@media (prefers-reduced-motion: reduce) { - a { - transition: none; - } -} -li + li { - border-top: 1px solid #d0d7de; -} -.empty-dir { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.75rem 0.5rem; - color: #656d76; - font-style: italic; -} -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} -@media (prefers-color-scheme: dark) { - body { - background-color: #0d1117; - color: #c9d1d9; - } - a { - color: #58a6ff; - } - a:hover, a:focus-visible { - background-color: #161b22; - outline-color: #58a6ff; - } - li + li { - border-top-color: #21262d; - } - .empty-dir { - color: #8b949e; - } -} -""".trimIndent() - -private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) -private val FILE_NAME_COMPARATOR = compareBy { it.name } - -class IgnoreFileReadException(message: String) : java.io.IOException(message) - -class Html4tree : CliktCommand() { - val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) - val topDir: String by argument(help="Top directory to crawl") - - override fun run() { - go(topDir, maxLevel) - } -} - -fun main(args: Array) = Html4tree().main(args) - - -internal data class FileIdentity(val key: Any?, val readable: Boolean) - - -internal fun read_file_identity(file: File): FileIdentity { - return try { - val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) - FileIdentity(attrs.fileKey(), true) - } catch (e: Exception) { - FileIdentity(null, false) - } -} - -fun go(topDir: String, maxLevel: Int) { - require(topDir.isNotBlank()) - require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } - // λ³΄μ•ˆ μˆ˜μ •: symlink 검사λ₯Ό μš°νšŒν•˜λŠ” canonicalFile λŒ€μ‹  absoluteFile을 μ‚¬μš© - // canonicalFile은 symlinkλ₯Ό λŒ€μƒ 경둜둜 ν•΄μ„ν•˜μ—¬ μ΄μ–΄μ§€λŠ” NOFOLLOW_LINKS 검사λ₯Ό 무λ ₯ν™”ν•©λ‹ˆλ‹€. - val top_dir = File(topDir).absoluteFile.toPath().normalize().toFile() - - // λ³΄μ•ˆ ν–₯상: μ‹œμŠ€ν…œ 전체 정보 λ…ΈμΆœ 및 λ¦¬μ†ŒμŠ€ 고갈(DoS) λ°©μ§€λ₯Ό μœ„ν•΄ 크둜슀 ν”Œλž«νΌ λ°©μ‹μœΌλ‘œ 루트 디렉토리 크둀링을 μ œν•œν•©λ‹ˆλ‹€. - require(top_dir.parentFile != null) { "Crawling the root directory is not allowed for security reasons" } - - require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } - - val ll = LinkedList() - - val topEntry = LinkedListEntry(top_dir,0, read_file_identity(top_dir).key) - ll.push(topEntry) - crawl_directories(ll, maxLevel) -} - -internal fun crawl_directories( - ll: LinkedList, - maxLevel: Int, - processDirectory: (File, Set, Array?) -> Unit = { file, exclude, files -> process_dir(file, exclude, files) }, - processIgnoreFile: (File, Array?) -> Set = { file, names -> process_ignore_file(file, names) }, - listFiles: (File) -> Array? = { it.listFiles() }, - readAttributes: (File) -> BasicFileAttributes? = { - try { - Files.readAttributes(it.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) - } catch (e: Exception) { - null - } - }, - readIdentity: (File) -> FileIdentity = ::read_file_identity -) { - var lle: LinkedListEntry? = ll.pull() - - while(lle != null){ - val attrs = readAttributes(lle.file) - if (attrs == null || !attrs.isDirectory) { - lle = ll.pull() - continue - } - - val currentIdentity = readIdentity(lle.file) - if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) { - lle = ll.pull() - continue - } - - val currentLevel: Int = lle.level - - // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ μΊμ‹±ν•˜μ—¬ μ€‘λ³΅λœ I/O μ‹œμŠ€ν…œ ν˜ΈμΆœμ„ μ€„μž„ - val dirFiles = listFiles(lle.file) - - // The path can be replaced between the initial identity check and - // directory enumeration. Do not process or enqueue children from a - // snapshot whose post-listing identity is unreadable or different. - val postListingIdentity = readIdentity(lle.file) - if (!postListingIdentity.readable || currentIdentity.key != postListingIdentity.key) { - lle = ll.pull() - continue - } - - val dirFilesNames = dirFiles?.let { files -> - Array(files.size) { index -> files[index].name } - } - val exclude = try { - processIgnoreFile(lle.file, dirFilesNames) - } catch (e: IgnoreFileReadException) { - lle = ll.pull() - continue - } - - if(maxLevel == -1 || currentLevel <= maxLevel) - processDirectory(lle.file, exclude, dirFiles) - - if(maxLevel == -1 || currentLevel < maxLevel) { - dirFiles?.forEach { - // ⚑ Bolt Performance Optimization: Short-circuit OS stat calls - // by checking cheap in-memory string exclusion rules first - if(!it.name.isHiddenFile() && it.name !in exclude) { - val childAttrs = readAttributes(it) - if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) { - val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) - ll.push(childEntry) - } - } - } - } - lle = ll.pull() - } -} - -fun String.isHiddenFile(): Boolean { - return when (firstOrNull()) { - '.', '\u3002', '\uFF0E', '\uFF61' -> true - else -> false - } -} - -// ⚑ Bolt Performance Optimization: Single-pass loop with lazy StringBuilder -// Chained `.replace()` calls allocate multiple intermediate strings. -// A single pass over the string lazily allocating a StringBuilder is much faster. -fun String.escapeHtml(): String { - var sb: StringBuilder? = null - for (i in 0 until this.length) { - val c = this[i] - val replacement = when (c) { - '&' -> "&" - '<' -> "<" - '>' -> ">" - '"' -> """ - '\'' -> "'" - '`' -> "`" - else -> null - } - if (replacement != null) { - if (sb == null) { - sb = StringBuilder(this.length + 16) - sb.append(this as CharSequence, 0, i) - } - sb.append(replacement) - } else { - sb?.append(c) - } - } - return sb?.toString() ?: this -} - -fun String.urlEncodePath(): String { - val bytes = this.toByteArray(Charsets.UTF_8) - var encoded: StringBuilder? = null - for (i in bytes.indices) { - val byte = bytes[i].toInt() and 0xff - val isUnreserved = (byte in 'A'.toInt()..'Z'.toInt()) || - (byte in 'a'.toInt()..'z'.toInt()) || - (byte in '0'.toInt()..'9'.toInt()) || - byte == '-'.toInt() || - byte == '.'.toInt() || - byte == '_'.toInt() || - byte == '~'.toInt() - if (isUnreserved) { - encoded?.append(byte.toChar()) - } else { - var builder = encoded - if (builder == null) { - builder = StringBuilder(bytes.size + 16) - for (j in 0 until i) { - builder.append((bytes[j].toInt() and 0xff).toChar()) - } - encoded = builder - } - // ⚑ Bolt Performance Optimization: Direct character mapping - // Avoids multiple string allocations (toString, padStart, toUpperCase) per reserved byte. - builder.append('%') - val hex1 = byte ushr 4 - val hex2 = byte and 0xf - builder.append(if (hex1 < 10) (hex1 + 48).toChar() else (hex1 + 55).toChar()) - builder.append(if (hex2 < 10) (hex2 + 48).toChar() else (hex2 + 55).toChar()) - } - } - return encoded?.toString() ?: this -} - -fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): 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() - - val list = dirFilesNames ?: curr_dir.list() - - if (list?.contains(ignore_filename) == true) { - if (!ignore_file.isFile || Files.isSymbolicLink(ignore_file.toPath()) || !ignore_file.canRead()) { - throw IgnoreFileReadException("Policy file \$ignore_filename is present in directory snapshot but inaccessible or invalid") - } - } - - // λ³΄μ•ˆ ν–₯상: .html4ignore 파일이 일반 νŒŒμΌμΈμ§€ ν™•μΈν•˜κ³ , 심볼릭 링크인 경우 λ¬΄μ‹œν•˜μ—¬ DoS 및 경둜 μ‘°μž‘μ„ λ°©μ§€ν•©λ‹ˆλ‹€. - // λ³΄μ•ˆ ν–₯상: 파일 크기(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() - - 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) { - } - } - } - } - - // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ Set에 μΆ”κ°€ν•˜κΈ° μœ„ν•΄ ν•„ν„°λ§λ§Œ ν•  λ•ŒλŠ” 정렬이 λΆˆν•„μš”ν•˜λ―€λ‘œ .sorted()λ₯Ό μ œκ±°ν•˜μ—¬ O(N log N) μ˜€λ²„ν—€λ“œλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. - list?.forEach { - val current = it - val pathCurrent = try { - java.nio.file.Paths.get(current) - } catch (_: java.nio.file.InvalidPathException) { - files_to_exclude.add(current) - return@forEach - } - for (matcher in ignored_matchers) { - if (matcher.matches(pathCurrent)) { - files_to_exclude.add(current) - break - } - } - } - } - - if ("index.html" !in files_to_exclude) - files_to_exclude.add("index.html") - - // ⚑ Bolt Performance Optimization: Extract static list to prevent redundant allocations per directory - // λ³΄μ•ˆ ν–₯상: λ―Όκ°ν•œ μ‹œμŠ€ν…œ, μ„€μ •, μ‹œν¬λ¦Ώ νŒŒμΌμ„ 디렉토리 λͺ©λ‘μ—μ„œ 기본적으둜 μ œμ™Έν•˜μ—¬ 정보 λ…ΈμΆœ(Information Exposure) λ°©μ§€ - files_to_exclude.addAll(Constants.defaultSensitiveFiles) - - // λ³΄μ•ˆ ν–₯상: dot-like prefixes and case variants of known sensitive names are excluded. - list?.forEach { - val normalizedName = it.toLowerCase(java.util.Locale.ROOT) - if ( - it.isHiddenFile() || - normalizedName in Constants.defaultSensitiveFileNamesLowercase || - normalizedName.endsWith("~") || - Constants.defaultSensitiveExtensions.any { extension -> - normalizedName.endsWith(extension) - } - ) { - files_to_exclude.add(it) - } - } - - return files_to_exclude -} - -fun write_index_file( - curr_dir: File, - content: String, - moveFile: ( - java.nio.file.Path, - java.nio.file.Path, - Array - ) -> Unit = { source, target, options -> - Files.move(source, target, *options) - Unit - } -) { - val indexPath = curr_dir.toPath().resolve("index.html") - val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html") - try { - Files.write(tempPath, content.toByteArray(Charsets.UTF_8)) - try { - // With ATOMIC_MOVE, Java ignores every other copy option and the - // existing-target policy is provider-specific. - moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.ATOMIC_MOVE)) - } catch (error: java.io.IOException) { - if ( - error !is java.nio.file.AtomicMoveNotSupportedException && - error !is java.nio.file.FileAlreadyExistsException - ) { - throw error - } - // This compatibility fallback preserves replacement semantics but - // is explicitly non-atomic. - moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING)) - } - } finally { - Files.deleteIfExists(tempPath) - } -} - -fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array? = null){ - - val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) - val directoryName = curr_dir.name.ifEmpty { "Root" } - - val index_top = """ - - - - - - - - - - - - - ${directoryName.escapeHtml()} - 디렉토리 λͺ©λ‘ - - - -
-

${directoryName.escapeHtml()}

- -
- - -""" - - try { - write_index_file(curr_dir, index_top+index_middle()+index_bottom) - } catch (e: Exception) { - // λ³΄μ•ˆ ν–₯상: 디렉토리에 μ“°κΈ° κΆŒν•œμ΄ μ—†κ±°λ‚˜ 파일 μ‹œμŠ€ν…œ 였λ₯˜κ°€ λ°œμƒν–ˆμ„ λ•Œ - // 전체 크둀링(ν”„λ‘œμ„ΈμŠ€)이 μ€‘λ‹¨λ˜λŠ” DoSλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. (Fail Securely) - } - -} - -fun help() { - println("ERROR: help has not been written yet!") -} - -private object Constants { - @JvmField - val defaultSensitiveFiles = listOf(".git", ".env", ".ssh", ".htpasswd", ".htaccess", "id_rsa", "id_ed25519", "secrets.yml", ".html4ignore", ".DS_Store", ".aws", ".kube", ".npmrc", ".gnupg", "config.json", "credentials.json") - - @JvmField - val defaultSensitiveFileNamesLowercase = - defaultSensitiveFiles.map { it.toLowerCase(java.util.Locale.ROOT) }.toSet() - - @JvmField - val defaultSensitiveExtensions = listOf( - ".pem", - ".key", - ".p12", - ".pfx", - ".crt", - ".cer", - ".der", - ".keystore", - ".truststore", - ".jks", - ".sqlite", - ".db", - ".bak", - ".sql", - ".pcap", - ".pcapng", - ".log", - ".swp", - ".swo", - ".swpx" - ) -} diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 602e2ff7..1a878956 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -951,4 +951,33 @@ class MainTest { assertTrue(content.contains("

Root

")) } + @Test + fun testProcessIgnoreFileThrowsWhenInaccessible() { + val subdir = File(tempDir, "toctou_ignore_test") + subdir.mkdir() + val ll = LinkedList() + val entry = LinkedListEntry(subdir, 0) + entry.fileKey = "mock-key" + ll.push(entry) + + var processed = false + var listed = false + + crawl_directories( + ll, + -1, + processDirectory = { _, _, _ -> processed = true }, + processIgnoreFile = { _, _ -> throw IgnoreFileReadException("mock") }, + listFiles = { + listed = true + arrayOf(File(subdir, ".html4ignore")) + }, + readAttributes = { _ -> createMockAttributes(isDir = true, isSymlink = false) }, + readIdentity = { FileIdentity("mock-key", true) } + ) + + assertTrue(listed, "listFiles must be called before processIgnoreFile") + assertFalse(processed, "Directory with inaccessible ignore file must not be processed") + } + } From 043f0a29e5fccc7cd5d78633144f713e3db12b3d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:48:46 +0000 Subject: [PATCH 06/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. From 69d1c3894bad8c26c98e2b8fc2592d9c5fac4751 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:35:23 +0000 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. From 19a07bb08d5b6ae999b498dc3949120270b88f9a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:15:30 +0000 Subject: [PATCH 08/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. From c7af731bcfbb315b58ada094c27b493a44c63d28 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:11:27 +0000 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. From 8f522b9007fe701c9e2b1d651918dc5e0f34ccba Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:03:12 +0000 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. From b78eb41c6a6813f4468e352d22e8758201616a01 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:38:02 +0000 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€. From 4454de7ee5ee3169f21c8b050954c931bb319a8d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:22:31 +0000 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20.html4ignore=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: CRITICAL πŸ’‘ Vulnerability: 디렉토리 μŠ€λƒ…μƒ·μ— `.html4ignore` μ •μ±… 파일이 μ‘΄μž¬ν•˜μ§€λ§Œ, νŒŒμΌμ„ μ‹€μ œλ‘œ μ½λŠ” μ‹œμ (Time-of-Use)에 파일이 심볼릭 링크둜 λ³€κ²½λ˜κ±°λ‚˜, κΆŒν•œμ΄ μ—†μ–΄μ§€κ±°λ‚˜, 디렉토리인 경우 μ—λŸ¬λ₯Ό λ¬΄μ‹œν•˜κ³  ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ λͺ¨λ“  νŒŒμΌμ„ κ·ΈλŒ€λ‘œ λ…ΈμΆœν•˜λŠ” TOCTOU(Fail-Open) μ·¨μ•½μ μž…λ‹ˆλ‹€. 🎯 Impact: μ‚¬μš©μžκ°€ μ˜λ„μ μœΌλ‘œ `.html4ignore`λ₯Ό 톡해 λ―Όκ°ν•œ νŒŒμΌμ„ 숨기렀 ν–ˆμœΌλ‚˜, I/O μ—λŸ¬λ‚˜ 레이슀 μ»¨λ””μ…˜μœΌλ‘œ 파일 읽기가 μ‹€νŒ¨ν•  경우 λ―Όκ°ν•œ μ‹œμŠ€ν…œ 및 μ‚¬μš©μž 파일이 μƒμ„±λœ HTML μΈλ±μŠ€μ— λ…ΈμΆœλ˜μ–΄ μ‹¬κ°ν•œ 정보 유좜둜 μ΄μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: `.html4ignore` 파일이 μ‘΄μž¬ν•  경우 파일 μœ νš¨μ„± 검사 μ‹€νŒ¨ λ˜λŠ” 읽기 κΆŒν•œ μ—†μŒ μ‹œ `IgnoreFileReadException`을 λͺ…μ‹œμ μœΌλ‘œ λ˜μ§€λ„λ‘ μˆ˜μ •ν•˜μ˜€μœΌλ©°, μƒμœ„ 크둀러(`crawl_directories`)μ—μ„œ 이λ₯Ό μž‘μ•„ ν•΄λ‹Ή λ””λ ‰ν† λ¦¬μ˜ 인덱슀 λ°œν–‰ 및 ν•˜μœ„ 디렉토리 탐색을 μ¦‰μ‹œ 쀑단(Fail-Closed)ν•˜λ„λ‘ κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: μœ λ‹› ν…ŒμŠ€νŠΈ μΆ”κ°€λ₯Ό 톡해 `.html4ignore` 파일 접근이 λΆˆκ°€λŠ₯ν•˜κ±°λ‚˜ μ˜ˆμ™Έκ°€ λ°œμƒν•  λ•Œ 크둀링과 인덱슀 λ Œλ”λ§μ΄ μ•ˆμ „ν•˜κ²Œ μ€‘λ‹¨λ˜λŠ”μ§€ κ²€μ¦ν–ˆμŠ΅λ‹ˆλ‹€.