Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2026-06-24 - HTML XSS 취약점 및 Kotlin ν…œν”Œλ¦Ώ μΈμ μ…˜ λ°©μ§€
**Vulnerability:** `html4tree` μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ—μ„œ 파일 및 디렉토리 이름을 기반으둜 `index.html`을 생성할 λ•Œ XSS 취약점이 μ‘΄μž¬ν–ˆμŠ΅λ‹ˆλ‹€. μ•…μ˜μ μΈ 디렉토리/파일 이름(예: `<script>alert(1)</script>`)이 HTML ν…œν”Œλ¦Ώμ— μ΄μŠ€μΌ€μ΄ν”„λ˜μ§€ μ•Šκ³  직접 μ‚½μž…λ˜μ—ˆμŠ΅λ‹ˆλ‹€.
**Learning:** 파일 μ‹œμŠ€ν…œ κ²½λ‘œλ‚˜ 이름도 μ‹ λ’°ν•  수 μ—†λŠ” μž…λ ₯으둜 μ·¨κΈ‰ν•΄μ•Ό ν•˜λ©°, HTML둜 λ Œλ”λ§λ  λ•ŒλŠ” λ°˜λ“œμ‹œ μ μ ˆν•œ 인코딩(HTML μ—”ν‹°ν‹° μ΄μŠ€μΌ€μ΄ν”„ λ“±)이 ν•„μš”ν•©λ‹ˆλ‹€. λ˜ν•œ 속성값에 λ“€μ–΄κ°€λŠ” 경우 λ°˜λ“œμ‹œ λ”°μ˜΄ν‘œ(`"` λ˜λŠ” `'`)둜 감싸야 속성 νƒˆμΆœ(Attribute Breakout) 곡격을 막을 수 μžˆμŠ΅λ‹ˆλ‹€.
**Prevention:** Kotlin의 λ¬Έμžμ—΄ ν…œν”Œλ¦Ώ 내에 μ‚¬μš©μžλ‚˜ 파일 μ‹œμŠ€ν…œμœΌλ‘œλΆ€ν„° 온 동적 값을 μ‚½μž…ν•  경우 `.escapeHtml()`κ³Ό 같은 ν™•μž₯ ν•¨μˆ˜λ₯Ό κ΅¬ν˜„ν•˜μ—¬ `<, >, &, ", '` λ“±μ˜ 특수 문자λ₯Ό 필터링해야 ν•©λ‹ˆλ‹€.
12 changes: 12 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ buildscript {

apply plugin: 'kotlin'
apply plugin: 'application'
apply plugin: 'jacoco'

jacoco {
toolVersion = "0.8.7"
}

jacocoTestReport {
reports {
xml.enabled true
html.enabled true
}
}

mainClassName = 'html4tree.MainKt'

Expand Down
20 changes: 17 additions & 3 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ fun process_ignore_file(curr_dir: File): List<String> {
return files_to_exclude
}

fun String.escapeHtml(): String {
return this.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;")
}

fun process_dir(curr_dir: File){

val exclude: List<String> = process_ignore_file(curr_dir)
Expand All @@ -86,14 +94,17 @@ fun process_dir(curr_dir: File){
</style>
"""

// λ³΄μ•ˆ κ°•ν™”: XSS 취약점 λ°©μ§€λ₯Ό μœ„ν•΄ 디렉토리 및 파일 μ΄λ¦„μ˜ HTML μ—”ν‹°ν‹°λ₯Ό μ΄μŠ€μΌ€μ΄ν”„ μ²˜λ¦¬ν•©λ‹ˆλ‹€.
val safeDirName = curr_dir.getName().escapeHtml()

val index_top = """<!doctype html>
<html>
<head>
<title>${curr_dir.getName()}</title>
<title>${safeDirName}</title>
${css}
</head>
<body>
<h1>${curr_dir.getName()}</h1>
<h1>${safeDirName}</h1>
<ul>
<li><a style="display:block; width:100%" href="./..">&#x21B0; ..</a></li>
"""
Expand All @@ -105,7 +116,10 @@ fun process_dir(curr_dir: File){
dir_files.sortWith(compareBy ({it.name}) )
dir_files.forEach {
if((it.getName() !in exclude) && (it != curr_dir)) {
l += """ <li><a style="display:block; width:100%" href=${if (it.isDirectory()) { "./${it.getName()}/" } else { "./${it.getName()}" }}>${if (it.isDirectory()) { "&#128193;" } else { "&rtrif;" }} ${it.getName()}</a></li>"""+"\n"
val safeName = it.getName().escapeHtml()
val href = if (it.isDirectory()) { "./${safeName}/" } else { "./${safeName}" }
// λ³΄μ•ˆ κ°•ν™”: href 속성 값을 λ”°μ˜΄ν‘œλ‘œ κ°μ‹Έμ„œ 속성 νƒˆμΆœ(attribute breakout)을 λ°©μ§€ν•©λ‹ˆλ‹€.
l += """ <li><a style="display:block; width:100%" href="${href}">${if (it.isDirectory()) { "&#128193;" } else { "&rtrif;" }} ${safeName}</a></li>"""+"\n"
}
}

Expand Down
48 changes: 48 additions & 0 deletions src/test/kotlin/html4tree/CoverageFixTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class CoverageFixTest {
@Test
fun testLinkedListFull() {
val list = LinkedList()
val e1 = LinkedListEntry(File("a"), 0)
val e2 = LinkedListEntry(File("b"), 1)

list.push(e1)
assertNotNull(list.first)
assertNotNull(list.last)

list.push(e2)
assertNotNull(list.first)
assertNotNull(list.last)
assertEquals("b", list.first?.data?.name)
assertEquals("a", list.last?.data?.name)

val pulled1 = list.pull()
assertNotNull(pulled1)
assertEquals("a", pulled1?.file?.name)

val pulled2 = list.pull()
assertNotNull(pulled2)
assertEquals("b", pulled2?.file?.name)
}

@Test
fun testGoMaxLevel() {
val tempDir = createTempDir("goMaxTestDir")
try {
val childDir1 = File(tempDir, "child1")
childDir1.mkdirs()

go(tempDir.absolutePath, -1)
assertTrue(File(tempDir, "index.html").exists())
assertTrue(File(childDir1, "index.html").exists())

} finally {
tempDir.deleteRecursively()
}
}
}
27 changes: 27 additions & 0 deletions src/test/kotlin/html4tree/EntryTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class EntryTest {
@Test
fun testDataClasses() {
val f = File("a")
val e1 = Entry(f, 0, null)
val e2 = Entry(f, 0, null)
val e3 = Entry(File("b"), 1, null)
assertEquals(e1, e2)
assertEquals(e1.hashCode(), e2.hashCode())
assertNotEquals(e1, e3)
assertEquals("Entry(data=a, level=0, next=null)", e1.toString())

val le1 = LinkedListEntry(f, 0)
val le2 = LinkedListEntry(f, 0)
val le3 = LinkedListEntry(File("b"), 1)
assertEquals(le1, le2)
assertEquals(le1.hashCode(), le2.hashCode())
assertNotEquals(le1, le3)
assertEquals("LinkedListEntry(file=a, level=0)", le1.toString())
}
}
40 changes: 40 additions & 0 deletions src/test/kotlin/html4tree/GoTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class GoTest {
@Test
fun testGo() {
val tempDir = createTempDir("goTestDir")
try {
val childDir1 = File(tempDir, "child1")
childDir1.mkdirs()

val childDir2 = File(tempDir, "child2")
childDir2.mkdirs()

go(tempDir.absolutePath, 0)

assertTrue(File(tempDir, "index.html").exists())
assertFalse(File(childDir1, "index.html").exists())
assertFalse(File(childDir2, "index.html").exists())

go(tempDir.absolutePath, 1)

assertTrue(File(tempDir, "index.html").exists())
assertTrue(File(childDir1, "index.html").exists())
assertTrue(File(childDir2, "index.html").exists())

try {
go(File(tempDir, "non_existent").absolutePath, 0)
fail("Expected IllegalArgumentException")
} catch (e: IllegalArgumentException) {
// pass
}
} finally {
tempDir.deleteRecursively()
}
}
}
43 changes: 43 additions & 0 deletions src/test/kotlin/html4tree/Html4treeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class Html4treeTest {
@Test
fun testProcessDirEscapesMaliciousDirectoryNames() {
val tempDir = createTempDir("html4treeTest")
try {
val maliciousDir = File(tempDir, "<script>alert(1)</script>")
maliciousDir.mkdirs()

val maliciousFile = File(maliciousDir, "test file\" onmouseover=\"alert(1)")
maliciousFile.createNewFile()

val normalDir = File(maliciousDir, "normal_dir")
normalDir.mkdirs()

process_dir(maliciousDir)

val indexFile = File(maliciousDir, "index.html")
assertTrue(indexFile.exists())

val content = indexFile.readText()

assertTrue("Top directory name not escaped in title", content.contains("&lt;script&gt;alert(1)&lt;/script&gt;") || content.contains("script&gt;"))
assertTrue("Top directory name not escaped in h1", content.contains("&lt;script&gt;alert(1)&lt;/script&gt;") || content.contains("script&gt;"))

assertTrue("Child file name not escaped in href", content.contains("href=\"./test file&quot; onmouseover=&quot;alert(1)\""))
assertTrue("Child file name not escaped in text", content.contains("&rtrif; test file&quot; onmouseover=&quot;alert(1)</a>"))

assertTrue("Normal dir not included correctly", content.contains("href=\"./normal_dir/\""))
assertTrue("Normal dir not included correctly", content.contains("&#128193; normal_dir</a>"))

assertFalse("Found unescaped script tag", content.contains("<script>alert(1)</script>"))
assertFalse("Found unescaped quote", content.contains("href=\"./test file\" onmouseover=\"alert(1)"))
} finally {
tempDir.deleteRecursively()
}
}
}
33 changes: 33 additions & 0 deletions src/test/kotlin/html4tree/IgnoreFileTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class IgnoreFileTest {
@Test
fun testProcessIgnoreFile() {
val tempDir = createTempDir("ignoreTestDir")
try {
val file1 = File(tempDir, "file1.txt")
file1.createNewFile()
val file2 = File(tempDir, "file2.csv")
file2.createNewFile()

var excludeList = process_ignore_file(tempDir)
assertTrue(excludeList.contains("index.html"))
assertFalse(excludeList.contains("file1.txt"))
assertFalse(excludeList.contains("file2.csv"))

val ignoreFile = File(tempDir, ".html4ignore")
ignoreFile.writeText(".*\\.txt\n")

excludeList = process_ignore_file(tempDir)
assertTrue(excludeList.contains("index.html"))
assertTrue(excludeList.contains("file1.txt"))
assertFalse(excludeList.contains("file2.csv"))
} finally {
tempDir.deleteRecursively()
}
}
}
17 changes: 17 additions & 0 deletions src/test/kotlin/html4tree/LinkedListPullNullTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class LinkedListPullNullTest {
@Test
fun testLinkedListPullNull() {
val list = LinkedList()
list.last = Entry(File("a"), 0, null)
list.pull()

list.last = Entry(File("b"), 0, Entry(File("c"), 0, null))
list.pull()
}
}
20 changes: 20 additions & 0 deletions src/test/kotlin/html4tree/LinkedListPushTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class LinkedListPushTest {
@Test
fun testLinkedListPushNullNext() {
val list = LinkedList()
val e1 = LinkedListEntry(File("a"), 0)

list.last = Entry(File("init"), 0, null)
list.first = null

list.push(e1)

assertNull(list.first)
}
}
31 changes: 31 additions & 0 deletions src/test/kotlin/html4tree/LinkedListTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class LinkedListTest {
@Test
fun testLinkedList() {
val list = LinkedList()
val entry1 = LinkedListEntry(File("dir1"), 0)
val entry2 = LinkedListEntry(File("dir2"), 1)

assertNull(list.pull())

list.push(entry1)
list.push(entry2)

val pulled1 = list.pull()
assertNotNull(pulled1)
assertEquals("dir1", pulled1?.file?.name)
assertEquals(0, pulled1?.level)

val pulled2 = list.pull()
assertNotNull(pulled2)
assertEquals("dir2", pulled2?.file?.name)
assertEquals(1, pulled2?.level)

assertNull(list.pull())
}
}
23 changes: 23 additions & 0 deletions src/test/kotlin/html4tree/MainFunTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File

class MainFunTest {
@Test
fun testMain() {
val tempDir = createTempDir("mainTestDir")
try {
main(arrayOf("--max-level", "0", tempDir.absolutePath))
assertTrue(File(tempDir, "index.html").exists())
} finally {
tempDir.deleteRecursively()
}
}

@Test
fun testHelp() {
help()
}
}