Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -407,6 +448,8 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array

val exclude: Set<String> = 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 = """<!doctype html>
<html lang="ko">
Expand All @@ -421,12 +464,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>${directoryTitle}</title>
<style>${CSS_CONTENT}</style>
</head>
<body>
<main>
<h1>${directoryName.escapeHtml()}</h1>
<h1 dir="auto">${displayDirectoryName.escapeHtml()}</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 @@ -456,11 +499,17 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array
} catch (e: Exception) {
}
if (!isSymbolicLink) {
val displayName = neutralize_bidi_controls(fileName)
val encodedHref = if (isLinkedDirectory) { "./${fileName.urlEncodePath()}/" } else { "./${fileName.urlEncodePath()}" }
val ariaLabel = "${fileName} ${if (isLinkedDirectory) { "디렉토리" } else { "파일" }}".escapeHtml()
val typeLabel = if (isLinkedDirectory) { "디렉토리" } else { "파일" }
val titleText = "${isolate_bidi_plain_text(displayName)} $typeLabel".escapeHtml()
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>""")
val bidiWarning = if (displayName != fileName) {
""" <span class="visually-hidden">이름에 방향 제어 문자가 있습니다</span>"""
} else {
""
}
l.append(""" <li><a class="dir-link" href="${encodedHref}" title="${titleText}"><span class="icon" aria-hidden="true">${icon}</span> <span dir="auto">${displayName.escapeHtml()}</span>${bidiWarning} <span class="visually-hidden">${typeLabel}</span></a></li>""")
l.append('\n')
}
}
Expand Down
94 changes: 94 additions & 0 deletions src/test/kotlin/html4tree/TrojanSourceSecurityTest.kt
Original file line number Diff line number Diff line change
@@ -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("<span dir=\"auto\">$displayName</span>"))
assertTrue(generatedHtml.contains("title=\"${isolate_bidi_plain_text(displayName)} 파일\""))
assertTrue(generatedHtml.contains("이름에 방향 제어 문자가 있습니다. 열기 전에 링크 대상 파일 이름을 확인하세요."))
assertTrue(generatedHtml.contains("href=\"$encodedHref\""))
assertFalse(generatedHtml.contains("$spoofedName</span>"))
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("<h1 dir=\"auto\">$displayName</h1>"))
assertTrue(generatedHtml.contains("<title>${isolate_bidi_plain_text(displayName)} - 디렉토리 목록</title>"))
assertFalse(generatedHtml.contains("<h1 dir=\"auto\">invoices\u202E</h1>"))
}
}
Loading