From b3d887e4e81968ea4677b4beef305ea2935f4b80 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 25 Sep 2026 21:09:37 +0000
Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM?=
=?UTF-8?q?]=20Fix=20BiDi=20text=20spoofing=20in=20generated=20HTML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/sentinel.md | 5 +++++
src/main/kotlin/html4tree/main.kt | 17 +++++++++++++----
.../html4tree/GeneratedIndexReadabilityTest.kt | 6 +++---
src/test/kotlin/html4tree/MainTest.kt | 11 ++++++-----
4 files changed, 27 insertions(+), 12 deletions(-)
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..508b988a 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..246b23dc 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("file\\u202Ename", "file\u202Ename".escapeHtml())
}
@Test
@@ -339,10 +340,10 @@ 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("디렉토리 "))
- assertTrue(htmlContent.contains("title=\"subdir 디렉토리\""))
- assertTrue(htmlContent.contains("file1.txt"))
+ assertTrue(htmlContent.contains("title=\"subdir 디렉토리\""))
+ assertTrue(htmlContent.contains("file1.txt"))
assertTrue(htmlContent.contains("subdir/"))
assertTrue(htmlContent.contains("📁"))
assertFalse(htmlContent.contains("test.ignore"))
@@ -942,8 +943,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 "))
}
}
From 55088a712bbae4b919f675574bc1fba09b91f8f8 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Sat, 26 Sep 2026 00:11:32 +0000
Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM?=
=?UTF-8?q?]=20Fix=20BiDi=20text=20spoofing=20in=20generated=20HTML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/test/kotlin/html4tree/MainTest.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt
index 246b23dc..ce167ae6 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -60,7 +60,7 @@ class MainTest {
assertEquals("&<>"'`", "&<>\"'`".escapeHtml())
assertEquals("normal text", "normal text".escapeHtml())
assertEquals("mix text & and <tag>", "mix text & and ".escapeHtml())
- assertEquals("file\\u202Ename", "file\u202Ename".escapeHtml())
+ assertEquals("bidi\\u202A\\u202B\\u202C\\u202D\\u202E\\u2066\\u2067\\u2068\\u2069chars", "bidi\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069chars".escapeHtml())
}
@Test
From 140073e753eeafc942109df23b4d36c5d36f550c Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Sat, 26 Sep 2026 04:39:15 +0000
Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM?=
=?UTF-8?q?]=20Fix=20BiDi=20text=20spoofing=20in=20generated=20HTML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From acc480e30f21be74098a3d706c801e567f308b09 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Sat, 26 Sep 2026 09:39:12 +0000
Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM?=
=?UTF-8?q?]=20Fix=20BiDi=20text=20spoofing=20in=20generated=20HTML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From 84f9651567ebf1b6e02bd58e33dc3109cd9931ca Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Sat, 26 Sep 2026 10:24:14 +0000
Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM?=
=?UTF-8?q?]=20Fix=20BiDi=20text=20spoofing=20in=20generated=20HTML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/kotlin/html4tree/main.kt | 2 +-
src/test/kotlin/html4tree/MainTest.kt | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index 508b988a..3517b06d 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -469,7 +469,7 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array
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/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt
index ce167ae6..a17377ef 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -341,8 +341,10 @@ class MainTest {
assertTrue(htmlContent.contains("aria-hidden=\"true\""))
assertTrue(htmlContent.contains("파일 "))
assertTrue(htmlContent.contains("title=\"file1.txt 파일\""))
+ assertTrue(htmlContent.contains("aria-label=\"file1.txt 파일\""))
assertTrue(htmlContent.contains("디렉토리 "))
assertTrue(htmlContent.contains("title=\"subdir 디렉토리\""))
+ assertTrue(htmlContent.contains("aria-label=\"subdir 디렉토리\""))
assertTrue(htmlContent.contains("file1.txt"))
assertTrue(htmlContent.contains("subdir/"))
assertTrue(htmlContent.contains("📁"))
From 95185f6ecc5558968185189bfd40d20a57c309a7 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:55:51 +0000
Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM?=
=?UTF-8?q?]=20Fix=20BiDi=20text=20spoofing=20in=20generated=20HTML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit