diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index a885865d..d00e08e1 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -99,3 +99,7 @@
**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-05-24 - BiDi 스푸핑 취약점 (CVE-2021-42574)
+**Vulnerability:** 파일명에 유니코드 양방향(BiDi) 제어 문자(예: RLO, LRI)가 포함될 경우 UI에서 확장자가 스푸핑되어 악성 파일(예: `.exe`가 `.txt`로 보임)이 다운로드될 수 있는 Trojan Source 취약점이 발견되었습니다.
+**Learning:** `escapeHtml()` 함수에서 HTML 예약어 이외의 렌더링에 영향을 미치는 숨겨진 제어 문자는 기본적으로 필터링되지 않았으며, `dir="auto"`와 같은 단순한 설정은 `
` 등의 속성에서 완벽히 격리되지 않습니다.
+**Prevention:** 렌더링 시 악용될 수 있는 `\u202E`, `\u2066` 등의 BiDi 제어 문자를 `escapeHtml()`에서 명시적으로 리터럴(예: `\\u202E`)로 이스케이프하여 노출시킵니다. 또한, 디렉토리명 및 파일명 등 사용자가 통제 가능한 임의의 문자열을 HTML에 렌더링할 때는 W3C 권고안에 따라 First Strong Isolate (``) 및 Pop Directional Isolate (``)를 래핑하여 텍스트의 방향성을 구조적으로 격리해야 합니다.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c442b3b1..563acc08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- BiDi(양방향) 텍스트 제어 문자를 이스케이프하고 FSI/PDI 래퍼를 적용하여 파일명/확장자 스푸핑(Trojan Source) 취약점 수정
# Changelog
All notable changes to this project are documented in this file.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index 0972fa2c..48a39271 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -56,7 +56,7 @@ a:hover, a:focus-visible {
outline: 2px solid #0969da;
outline-offset: -2px;
}
-a:hover span:last-child, a:focus-visible span:last-child {
+a:hover .entry-name, a:focus-visible .entry-name {
text-decoration: underline;
}
@media (prefers-reduced-motion: reduce) {
@@ -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()} - 디렉토리 목록
-