diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..0d12e9f8 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`). + +## 2024-09-24 - BiDi Spoofing / Trojan Source Prevention +**Vulnerability:** 파일 및 디렉토리 이름이 디렉토리 목록 화면에 표시될 때, 공격자가 악의적으로 유니코드 양방향 텍스트(BiDi) 제어 문자인 Right-to-Left Override(\u202E)를 삽입하여 확장자를 속일 수 있는(BiDi Spoofing) 취약점이 있었습니다. 이로 인해 악성 실행 파일이 안전한 문서 파일인 것처럼 보이게 할 수 있었습니다. +**Learning:** HTML 이스케이핑만으로는 충분하지 않습니다. 파일 이름 같은 사용자 입력이 브라우저에서 방향성을 띄지 않고 있는 그대로 안전하게 출력되려면, 제어 문자를 명시적으로 보여주는 처리와 함께 텍스트 방향을 격리(Isolate)해야 한다는 것을 배웠습니다. +**Prevention:** HTML 이스케이프 함수 내에서 `\u202E` 문자를 문자열 `\\u202E`로 렌더링되게 치환하고, HTML 내에서 사용자 입력이 들어가는 부분을 First Strong Isolate (`⁨`)와 Pop Directional Isolate (`⁩`) 기호로 감싸 렌더링 방향이 왜곡되지 않게 보호해야 합니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..dfadca56 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 span.entry-name, a:focus-visible span.entry-name { text-decoration: underline; } @media (prefers-reduced-motion: reduce) { @@ -243,6 +243,7 @@ fun String.escapeHtml(): String { '"' -> """ '\'' -> "'" '`' -> "`" + '\u202E' -> "\\u202E" else -> null } if (replacement != null) { @@ -421,15 +422,15 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array - ${directoryName.escapeHtml()} - 디렉토리 목록 + ⁨${directoryName.escapeHtml()}⁩ - 디렉토리 목록
-

${directoryName.escapeHtml()}

+

⁨${directoryName.escapeHtml()}⁩

")) assertTrue(htmlContent.contains("상위 디렉토리로 이동")) assertTrue(htmlContent.contains("title=\"상위 디렉토리로 이동\"")) + assertTrue(htmlContent.contains("aria-label=\"상위 디렉토리로 이동\"")) 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("title=\"⁨subdir⁩ 디렉토리\"")) + assertTrue(htmlContent.contains("aria-label=\"⁨subdir⁩ 디렉토리\"")) assertTrue(htmlContent.contains("file1.txt")) assertTrue(htmlContent.contains("subdir/")) assertTrue(htmlContent.contains("📁")) @@ -942,8 +946,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⁩

")) } }