From c7d89cc8e123a03eebc29cdb48f7cb14701327de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 19:10:40 +0900 Subject: [PATCH 1/3] test(security): expose Trojan Source display boundary --- .../html4tree/TrojanSourceSecurityTest.kt | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/test/kotlin/html4tree/TrojanSourceSecurityTest.kt diff --git a/src/test/kotlin/html4tree/TrojanSourceSecurityTest.kt b/src/test/kotlin/html4tree/TrojanSourceSecurityTest.kt new file mode 100644 index 00000000..5841e39a --- /dev/null +++ b/src/test/kotlin/html4tree/TrojanSourceSecurityTest.kt @@ -0,0 +1,94 @@ +package html4tree + +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Buyer-facing regressions for bidirectional-control filenames. + * + * Display text must make control characters visible without changing the real + * filesystem name used by the link target. + */ +class TrojanSourceSecurityTest { + private lateinit var temporaryDirectory: File + + @Before + fun createTemporaryDirectory() { + temporaryDirectory = Files.createTempDirectory("html4tree-trojan-").toFile() + } + + @After + fun removeTemporaryDirectory() { + temporaryDirectory.deleteRecursively() + } + + @Test + fun bidiControlClassificationCoversFormattingControls() { + val controls = listOf( + '\u061C', '\u200E', '\u200F', + '\u202A', '\u202B', '\u202C', '\u202D', '\u202E', + '\u2066', '\u2067', '\u2068', '\u2069' + ) + controls.forEach { assertTrue(is_bidi_control(it)) } + listOf('A', '\u061B', '\u200D', '\u2029', '\u2065', '\u206A').forEach { + assertFalse(is_bidi_control(it)) + } + } + + @Test + fun neutralizationReplacesControlsWithoutChangingOrdinaryText() { + assertEquals("", neutralize_bidi_controls("")) + assertEquals("invoice.txt", neutralize_bidi_controls("invoice.txt")) + assertEquals("invoice.txt\uFFFDexe", neutralize_bidi_controls("invoice.txt\u202Eexe")) + assertEquals("\uFFFD\uFFFD", neutralize_bidi_controls("\u2066\u2069")) + assertEquals("a\uFFFDb\uFFFDc", neutralize_bidi_controls("a\u200Eb\u200Fc")) + } + + @Test + fun htmlEscapingPreservesRendererOwnedIsolationMarks() { + val isolated = isolate_bidi_plain_text("invoice.txt") + assertEquals(isolated, isolated.escapeHtml()) + assertTrue(isolated.startsWith("\u2068")) + assertTrue(isolated.endsWith("\u2069")) + } + + @Test + fun generatedListingNeutralizesSpoofButKeepsExactHref() { + val spoofedName = "invoice.txt\u202Eexe" + val spoofedFile = File(temporaryDirectory, spoofedName).apply { writeText("payload") } + + process_dir(temporaryDirectory, setOf("index.html"), arrayOf(spoofedFile)) + + val generatedHtml = File(temporaryDirectory, "index.html").readText(Charsets.UTF_8) + val encodedHref = "./${spoofedName.urlEncodePath()}" + val displayName = "invoice.txt\uFFFDexe" + + assertTrue(generatedHtml.contains("$displayName")) + assertTrue(generatedHtml.contains("title=\"${isolate_bidi_plain_text(displayName)} 파일\"")) + assertTrue(generatedHtml.contains("이름에 방향 제어 문자가 있습니다")) + assertTrue(generatedHtml.contains("href=\"$encodedHref\"")) + assertFalse(generatedHtml.contains("$spoofedName")) + assertFalse(generatedHtml.contains("title=\"${isolate_bidi_plain_text(spoofedName)}")) + } + + @Test + fun directoryHeadingAndTabTitleNeutralizeControls() { + val spoofedDirectory = File(temporaryDirectory, "invoices\u202E") + spoofedDirectory.mkdir() + val note = File(spoofedDirectory, "note.txt").apply { writeText("note") } + + process_dir(spoofedDirectory, setOf("index.html"), arrayOf(note)) + + val generatedHtml = File(spoofedDirectory, "index.html").readText(Charsets.UTF_8) + val displayName = "invoices\uFFFD" + assertTrue(generatedHtml.contains("

$displayName

")) + assertTrue(generatedHtml.contains("${isolate_bidi_plain_text(displayName)} - 디렉토리 목록")) + assertFalse(generatedHtml.contains("

invoices\u202E

")) + } +} From 3a9ba27b0f1ea96218b943caecfeb3893ff8b0c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 19:16:40 +0900 Subject: [PATCH 2/3] fix(security): neutralize bidi controls in displayed names --- src/main/kotlin/html4tree/main.kt | 59 ++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..b67a9f09 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -207,7 +207,7 @@ internal fun crawl_directories( if(maxLevel == -1 || currentLevel < maxLevel) { dirFiles?.forEach { - // ⚡ Bolt Performance Optimization: Short-circuit OS stat calls + // ⚡ Bolt Performance Optimization: Short-circuit string match before expensive OS filesystem calls // by checking cheap in-memory string exclusion rules first if(!it.name.isHiddenFile() && it.name !in exclude) { val childAttrs = readAttributes(it) @@ -258,6 +258,47 @@ fun String.escapeHtml(): String { return sb?.toString() ?: this } +/** + * Wrap plain-text filename content in Unicode First Strong Isolate / Pop + * Directional Isolate. HTML `dir="auto"` isolates element text, while title + * attributes need explicit isolation to keep adjacent UI copy in its own run. + */ +internal fun isolate_bidi_plain_text(value: String): String { + return "\u2068$value\u2069" +} + +/** True for Unicode bidirectional format controls that can reorder glyphs. */ +internal fun is_bidi_control(character: Char): Boolean { + val code = character.toInt() + return code == 0x061C || + code == 0x200E || + code == 0x200F || + code in 0x202A..0x202E || + code in 0x2066..0x2069 +} + +/** + * Neutralize controls in display text only. The exact filesystem name remains + * untouched for href generation, so visible spoof prevention does not break + * navigation or mutate the underlying entry. + */ +internal fun neutralize_bidi_controls(value: String): String { + var builder: StringBuilder? = null + for (index in 0 until value.length) { + val character = value[index] + if (is_bidi_control(character)) { + if (builder == null) { + builder = StringBuilder(value.length) + builder.append(value as CharSequence, 0, index) + } + builder.append('\uFFFD') + } else { + builder?.append(character) + } + } + return builder?.toString() ?: value +} + fun String.urlEncodePath(): String { val bytes = this.toByteArray(Charsets.UTF_8) var encoded: StringBuilder? = null @@ -407,6 +448,8 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) val directoryName = curr_dir.name.ifEmpty { "Root" } + val displayDirectoryName = neutralize_bidi_controls(directoryName) + val directoryTitle = "${isolate_bidi_plain_text(displayDirectoryName)} - 디렉토리 목록".escapeHtml() val index_top = """ @@ -421,12 +464,12 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array - ${directoryName.escapeHtml()} - 디렉토리 목록 + ${directoryTitle}
-

${directoryName.escapeHtml()}

+

${displayDirectoryName.escapeHtml()}