diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index a885865d..e42582d6 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-09-25 - BiDi Text Spoofing 방지
+**Vulnerability:** 파일명에 RTL(Right-to-Left) 오버라이드 등 유니코드 양방향(BiDi) 제어 문자가 포함될 경우 확장자가 위조되는 취약점이 발견되었습니다.
+**Learning:** BiDi 오버라이드는 시각적으로 위험한 파일(예: `.exe`)을 무해한 파일(예: `.txt`)처럼 보이게 만들 수 있습니다. HTML을 렌더링할 때 사용자 통제 영역이 이를 적절히 처리하거나 무효화하지 않으면 XSS 또는 악성 다운로드를 유도할 수 있습니다.
+**Prevention:** HTML 이스케이프 과정에서 유니코드 BiDi 제어 문자(예: `\u202E`)를 이스케이프(예: `\\u202E`)하여 무력화하고, 렌더링된 요소(예: `
`, ``의 텍스트)를 ``(FSI) 및 ``(PDI)로 감싸 텍스트 방향성이 문서 내 다른 요소에 영향을 미치지 않도록 격리해야 합니다.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index 0972fa2c..3517b06d 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -243,6 +243,15 @@ fun String.escapeHtml(): String {
'"' -> """
'\'' -> "'"
'`' -> "`"
+ '\u202A' -> "\\u202A"
+ '\u202B' -> "\\u202B"
+ '\u202C' -> "\\u202C"
+ '\u202D' -> "\\u202D"
+ '\u202E' -> "\\u202E"
+ '\u2066' -> "\\u2066"
+ '\u2067' -> "\\u2067"
+ '\u2068' -> "\\u2068"
+ '\u2069' -> "\\u2069"
else -> null
}
if (replacement != null) {
@@ -421,12 +430,12 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array
- ${directoryName.escapeHtml()} - 디렉토리 목록
+ ${directoryName.escapeHtml()} - 디렉토리 목록
- ${directoryName.escapeHtml()}
+ ${directoryName.escapeHtml()}
↰ .. 상위 디렉토리로 이동
@@ -457,10 +466,10 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array
}
if (!isSymbolicLink) {
val encodedHref = if (isLinkedDirectory) { "./${fileName.urlEncodePath()}/" } else { "./${fileName.urlEncodePath()}" }
- val ariaLabel = "${fileName} ${if (isLinkedDirectory) { "디렉토리" } else { "파일" }}".escapeHtml()
+ val ariaLabel = "${fileName.escapeHtml()} ${if (isLinkedDirectory) { "디렉토리" } else { "파일" }}"
val typeLabel = if (isLinkedDirectory) { "디렉토리" } else { "파일" }
val icon = if (isLinkedDirectory) { "📁" } else { "📄" }
- l.append(""" ${icon} ${fileName.escapeHtml()} ${typeLabel} """)
+ l.append(""" ${icon} ${fileName.escapeHtml()} ${typeLabel} """)
l.append('\n')
}
}
diff --git a/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt
index 5897ce6d..209189cd 100644
--- a/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt
+++ b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt
@@ -45,9 +45,9 @@ class GeneratedIndexReadabilityTest {
val generatedHtml = generatedHtml()
val parentIndex = generatedHtml.indexOf(".. ")
- val firstIndex = generatedHtml.indexOf("alpha.txt")
- val middleIndex = generatedHtml.indexOf("middle.txt")
- val lastIndex = generatedHtml.indexOf("zulu.txt")
+ val firstIndex = generatedHtml.indexOf("alpha.txt")
+ val middleIndex = generatedHtml.indexOf("middle.txt")
+ val lastIndex = generatedHtml.indexOf("zulu.txt")
assertTrue(parentIndex >= 0)
assertTrue(parentIndex < firstIndex)
diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt
index 5b76cc5d..a17377ef 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -60,6 +60,7 @@ class MainTest {
assertEquals("&<>"'`", "&<>\"'`".escapeHtml())
assertEquals("normal text", "normal text".escapeHtml())
assertEquals("mix text & and <tag>", "mix text & and ".escapeHtml())
+ assertEquals("bidi\\u202A\\u202B\\u202C\\u202D\\u202E\\u2066\\u2067\\u2068\\u2069chars", "bidi\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069chars".escapeHtml())
}
@Test
@@ -339,10 +340,12 @@ class MainTest {
assertTrue(htmlContent.contains("title=\"상위 디렉토리로 이동\""))
assertTrue(htmlContent.contains("aria-hidden=\"true\""))
assertTrue(htmlContent.contains("파일 "))
- assertTrue(htmlContent.contains("title=\"file1.txt 파일\""))
+ assertTrue(htmlContent.contains("title=\"file1.txt 파일\""))
+ assertTrue(htmlContent.contains("aria-label=\"file1.txt 파일\""))
assertTrue(htmlContent.contains("디렉토리 "))
- assertTrue(htmlContent.contains("title=\"subdir 디렉토리\""))
- assertTrue(htmlContent.contains("file1.txt"))
+ assertTrue(htmlContent.contains("title=\"subdir 디렉토리\""))
+ assertTrue(htmlContent.contains("aria-label=\"subdir 디렉토리\""))
+ assertTrue(htmlContent.contains("file1.txt"))
assertTrue(htmlContent.contains("subdir/"))
assertTrue(htmlContent.contains("📁"))
assertFalse(htmlContent.contains("test.ignore"))
@@ -942,8 +945,8 @@ class MainTest {
val indexHtml = File(fakeRoot, "index.html")
assertTrue(indexHtml.exists())
val content = indexHtml.readText()
- assertTrue(content.contains("Root - 디렉토리 목록 "))
- assertTrue(content.contains("Root "))
+ assertTrue(content.contains("Root - 디렉토리 목록 "))
+ assertTrue(content.contains("Root "))
}
}