Skip to content
Draft
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`와 같은 단순한 설정은 `<title>` 등의 속성에서 완벽히 격리되지 않습니다.
**Prevention:** 렌더링 시 악용될 수 있는 `\u202E`, `\u2066` 등의 BiDi 제어 문자를 `escapeHtml()`에서 명시적으로 리터럴(예: `\\u202E`)로 이스케이프하여 노출시킵니다. 또한, 디렉토리명 및 파일명 등 사용자가 통제 가능한 임의의 문자열을 HTML에 렌더링할 때는 W3C 권고안에 따라 First Strong Isolate (`&#x2068;`) 및 Pop Directional Isolate (`&#x2069;`)를 래핑하여 텍스트의 방향성을 구조적으로 격리해야 합니다.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- BiDi(양방향) 텍스트 제어 문자를 이스케이프하고 FSI/PDI 래퍼를 적용하여 파일명/확장자 스푸핑(Trojan Source) 취약점 수정
# Changelog

All notable changes to this project are documented in this file.
Expand Down
19 changes: 14 additions & 5 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -243,6 +243,15 @@ fun String.escapeHtml(): String {
'"' -> "&quot;"
'\'' -> "&#x27;"
'`' -> "&#x60;"
'\u202A' -> "\\u202A"
'\u202B' -> "\\u202B"
'\u202C' -> "\\u202C"
'\u202D' -> "\\u202D"
'\u202E' -> "\\u202E"
'\u2066' -> "\\u2066"
'\u2067' -> "\\u2067"
'\u2068' -> "\\u2068"
'\u2069' -> "\\u2069"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else -> null
}
if (replacement != null) {
Expand Down Expand Up @@ -421,12 +430,12 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array
<!-- 보안 향상: 리퍼러를 통한 디렉토리 경로 노출 방지 -->
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow">
<title>${directoryName.escapeHtml()} - 디렉토리 목록</title>
<title>&#x2068;${directoryName.escapeHtml()}&#x2069; - 디렉토리 목록</title>
<style>${CSS_CONTENT}</style>
</head>
<body>
<main>
<h1>${directoryName.escapeHtml()}</h1>
<h1>&#x2068;${directoryName.escapeHtml()}&#x2069;</h1>
<nav aria-label="디렉토리 목록">
<ul role="list">
<li><a class="dir-link" href="./.." title="상위 디렉토리로 이동"><span class="icon" aria-hidden="true">&#x21B0;</span> <span aria-hidden="true">..</span> <span class="visually-hidden">상위 디렉토리로 이동</span></a></li>
Expand Down Expand Up @@ -457,10 +466,10 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array
}
if (!isSymbolicLink) {
val encodedHref = if (isLinkedDirectory) { "./${fileName.urlEncodePath()}/" } else { "./${fileName.urlEncodePath()}" }
val ariaLabel = "${fileName} ${if (isLinkedDirectory) { "디렉토리" } else { "파일" }}".escapeHtml()
val ariaLabel = "&#x2068;${fileName.escapeHtml()}&#x2069; ${if (isLinkedDirectory) { "디렉토리" } else { "파일" }}"
val typeLabel = if (isLinkedDirectory) { "디렉토리" } else { "파일" }
val icon = if (isLinkedDirectory) { "&#128193;" } else { "&#128196;" }
l.append(""" <li><a class="dir-link" href="${encodedHref}" title="${ariaLabel}"><span class="icon" aria-hidden="true">${icon}</span> <span>${fileName.escapeHtml()}</span> <span class="visually-hidden">${typeLabel}</span></a></li>""")
l.append(""" <li><a class="dir-link" href="${encodedHref}" title="${ariaLabel}"><span class="icon" aria-hidden="true">${icon}</span> <span class="entry-name">&#x2068;${fileName.escapeHtml()}&#x2069;</span> <span class="visually-hidden">${typeLabel}</span></a></li>""")
l.append('\n')
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ class GeneratedIndexReadabilityTest {
assertTrue(
style.contains(
"""
a:hover span:last-child, a:focus-visible span:last-child {
a:hover .entry-name, a:focus-visible .entry-name {
text-decoration: underline;
}
""".trimIndent()
Expand Down
19 changes: 14 additions & 5 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ class MainTest {
assertEquals("&quot;", "\"".escapeHtml())
assertEquals("&#x27;", "'".escapeHtml())
assertEquals("&#x60;", "`".escapeHtml())
assertEquals("\\u202A", "\u202A".escapeHtml())
assertEquals("\\u202B", "\u202B".escapeHtml())
assertEquals("\\u202C", "\u202C".escapeHtml())
assertEquals("\\u202D", "\u202D".escapeHtml())
assertEquals("\\u202E", "\u202E".escapeHtml())
assertEquals("\\u2066", "\u2066".escapeHtml())
assertEquals("\\u2067", "\u2067".escapeHtml())
assertEquals("\\u2068", "\u2068".escapeHtml())
assertEquals("\\u2069", "\u2069".escapeHtml())
assertEquals("&amp;&lt;&gt;&quot;&#x27;&#x60;", "&<>\"'`".escapeHtml())
assertEquals("normal text", "normal text".escapeHtml())
assertEquals("mix text &amp; and &lt;tag&gt;", "mix text & and <tag>".escapeHtml())
Expand Down Expand Up @@ -339,10 +348,10 @@ class MainTest {
assertTrue(htmlContent.contains("title=\"상위 디렉토리로 이동\""))
assertTrue(htmlContent.contains("aria-hidden=\"true\""))
assertTrue(htmlContent.contains("<span class=\"visually-hidden\">파일</span>"))
assertTrue(htmlContent.contains("title=\"file1.txt 파일\""))
assertTrue(htmlContent.contains("title=\"&#x2068;file1.txt&#x2069; 파일\""))
assertTrue(htmlContent.contains("<span class=\"visually-hidden\">디렉토리</span>"))
assertTrue(htmlContent.contains("title=\"subdir 디렉토리\""))
assertTrue(htmlContent.contains("file1.txt"))
assertTrue(htmlContent.contains("title=\"&#x2068;subdir&#x2069; 디렉토리\""))
assertTrue(htmlContent.contains("&#x2068;file1.txt&#x2069;"))
assertTrue(htmlContent.contains("subdir/"))
assertTrue(htmlContent.contains("&#128193;"))
assertFalse(htmlContent.contains("test.ignore"))
Expand Down Expand Up @@ -942,8 +951,8 @@ class MainTest {
val indexHtml = File(fakeRoot, "index.html")
assertTrue(indexHtml.exists())
val content = indexHtml.readText()
assertTrue(content.contains("<title>Root - 디렉토리 목록</title>"))
assertTrue(content.contains("<h1>Root</h1>"))
assertTrue(content.contains("<title>&#x2068;Root&#x2069; - 디렉토리 목록</title>"))
assertTrue(content.contains("<h1>&#x2068;Root&#x2069;</h1>"))
}

}
Loading