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 @@
## 2023-10-25 - XSS ๋ฐ URL ์ธ์ฝ”๋”ฉ ์ทจ์•ฝ์  ์ˆ˜์ • (Sentinel)
**Vulnerability:** `html4tree`์˜ `process_dir` ๋กœ์ง์—์„œ ๋””๋ ‰ํ† ๋ฆฌ ๋ฐ ํŒŒ์ผ๋ช…์„ ์ด์Šค์ผ€์ดํ”„ ๋ฐ ์ธ์ฝ”๋”ฉ ์—†์ด ์ง์ ‘ HTML์˜ ๋งํฌ์™€ ํƒ€์ดํ‹€, `href` ์†์„ฑ์— ๋ฐ”์ธ๋”ฉํ•˜์—ฌ XSS ๊ณต๊ฒฉ ๋ฐ ๋„์–ด์“ฐ๊ธฐ๊ฐ€ ์žˆ๋Š” ํŒŒ์ผ๋ช… ์ ‘๊ทผ ์—๋Ÿฌ(URL Path ๊นจ์ง)๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
**Learning:** ๋‹จ์ˆœํ•œ `String` ์ด์–ด๋ถ™์ด๊ธฐ ๋ฐฉ์‹์œผ๋กœ HTML์„ ๋ Œ๋”๋งํ•  ๋•Œ๋Š” ์‚ฌ์šฉ์ž ์ž…๋ ฅ์ด๋‚˜ ์™ธ๋ถ€ ์ƒํƒœ(์ด ๊ฒฝ์šฐ ํŒŒ์ผ ์‹œ์Šคํ…œ ์ƒ์˜ ํŒŒ์ผ ์ด๋ฆ„)์— ์˜ํ•ด ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์•…์˜์ ์ธ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ํŒŒ์‹ฑํ•˜๊ฒŒ ๋  ์œ„ํ—˜์„ฑ์ด ์กด์žฌํ•œ๋‹ค๋Š” ๊ฒƒ์„ ์ด ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ํŠนํžˆ Kotlin์˜ raw string literal(`"""`)์„ ์‚ฌ์šฉํ•  ๋•Œ๋„ ๋ณ€์ˆ˜ ์‚ฝ์ž… ๋ถ€๋ถ„์€ ์ฒ ์ €ํžˆ ํ•„ํ„ฐ๋ง๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
**Prevention:** `String.escapeHtml()`๊ณผ `String.encodeUrlPath()`๋ผ๋Š” ํ™•์žฅ ํ•จ์ˆ˜๋ฅผ ๋งŒ๋“ค์–ด HTML ์—”ํ‹ฐํ‹ฐ ์น˜ํ™˜(ํŠนํžˆ `<`, `>`, `&`, `"`, `'`)๊ณผ `java.net.URLEncoder.encode`๋ฅผ ๊ฒฐํ•ฉํ•˜์—ฌ ์•ˆ์ „ํ•˜๊ฒŒ ์ถœ๋ ฅํ•˜๋„๋ก ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‚˜์•„๊ฐ€, ์ถ”ํ›„ ์œ ์‚ฌํ•œ HTML ํ…œํ”Œ๋ฆฟ ๋ Œ๋”๋ง ์‹œ์—๋Š” ํŒŒ์ผ๋ช…๊ณผ ๊ฐ™์ด ํ†ต์ œ ๋ถˆ๊ฐ€๋Šฅํ•œ ๋ฌธ์ž์—ด์— ๋Œ€ํ•ด์„œ๋Š” ํ•ญ์ƒ ์ด์Šค์ผ€์ดํ”„ ํ•จ์ˆ˜๋ฅผ ๊ฑฐ์น˜๋„๋ก ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
10 changes: 10 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,19 @@ buildscript {

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

mainClassName = 'html4tree.MainKt'

jacocoTestReport {
reports {
xml.enabled false
csv.enabled false
html.enabled true
}
}
test.finalizedBy jacocoTestReport

defaultTasks 'build'

repositories {
Expand Down
24 changes: 21 additions & 3 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ 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 String.encodeUrlPath(): String {
return java.net.URLEncoder.encode(this, "UTF-8").replace("+", "%20")
}

fun process_dir(curr_dir: File){

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

val escapedDirName = curr_dir.getName().escapeHtml()
val index_top = """<!doctype html>
<html>
<head>
<title>${curr_dir.getName()}</title>
<title>${escapedDirName}</title>
${css}
</head>
<body>
<h1>${curr_dir.getName()}</h1>
<h1>${escapedDirName}</h1>
<ul>
<li><a style="display:block; width:100%" href="./..">&#x21B0; ..</a></li>
"""
Expand All @@ -105,7 +118,12 @@ 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 name = it.getName()
val escapedName = name.escapeHtml()
val encodedUrl = name.encodeUrlPath()
val href = if (it.isDirectory()) "./${encodedUrl}/" else "./${encodedUrl}"
val icon = if (it.isDirectory()) "&#128193;" else "&rtrif;"
l += """ <li><a style="display:block; width:100%" href="${href}">${icon} ${escapedName}</a></li>"""+"\n"
}
}

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

import org.junit.Test
import java.io.File
import java.nio.file.Files

class GoFailTest {
@Test(expected = IllegalArgumentException::class)
fun testGoRequireDirNotExists() {
go("not_exists_dir", 0)
}

@Test(expected = IllegalArgumentException::class)
fun testGoRequireDirNotDirectory() {
val f = File.createTempFile("temp", "txt")
f.deleteOnExit()
go(f.absolutePath, 0)
}
}
125 changes: 125 additions & 0 deletions src/test/kotlin/html4tree/Html4treeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package html4tree

import org.junit.Test
import org.junit.Assert.*
import java.io.File
import java.nio.file.Files

class Html4treeTest {

@Test
fun testEscapeHtml() {
val input = """<script>alert("XSS" & 'hack')</script>"""
val expected = """&lt;script&gt;alert(&quot;XSS&quot; &amp; &#x27;hack&#x27;)&lt;/script&gt;"""
assertEquals(expected, input.escapeHtml())
}

@Test
fun testEncodeUrlPath() {
val input = "file name with spaces.txt"
val expected = "file%20name%20with%20spaces.txt"
assertEquals(expected, input.encodeUrlPath())

val input2 = """<script>"""
val expected2 = "%3Cscript%3E"
assertEquals(expected2, input2.encodeUrlPath())
}

@Test
fun testProcessDirGeneratesSafeHtml() {
val tempDir = Files.createTempDirectory("html4tree_test").toFile()
tempDir.deleteOnExit()

val badDirName = """<img src=x onerror=alert(1)>"""
val badDir = File(tempDir, badDirName)
badDir.mkdir()
badDir.deleteOnExit()

val badFileName = "file name.txt"
val badFile = File(tempDir, badFileName)
badFile.createNewFile()
badFile.deleteOnExit()

process_dir(tempDir)

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

val content = indexFile.readText()

// title, h1 escape
assertTrue(content.contains("<title>${tempDir.name.escapeHtml()}</title>"))
assertTrue(content.contains("<h1>${tempDir.name.escapeHtml()}</h1>"))

// child dir escape and encode
assertTrue(content.contains("""href="./%3Cimg%20src%3Dx%20onerror%3Dalert%281%29%3E/""""))
assertTrue(content.contains("""&lt;img src=x onerror=alert(1)&gt;"""))

// child file escape and encode
assertTrue(content.contains("""href="./file%20name.txt""""))
assertTrue(content.contains("""file name.txt"""))
}

@Test
fun testGoMaxLevel() {
val tempDir = Files.createTempDirectory("html4tree_test_go").toFile()
tempDir.deleteOnExit()

val subDir = File(tempDir, "subdir")
subDir.mkdir()
subDir.deleteOnExit()

val subSubDir = File(subDir, "subsubdir")
subSubDir.mkdir()
subSubDir.deleteOnExit()

// maxLevel 0
go(tempDir.absolutePath, 0)
assertTrue(File(tempDir, "index.html").exists())
assertFalse(File(subDir, "index.html").exists())
assertFalse(File(subSubDir, "index.html").exists())

File(tempDir, "index.html").delete()

// maxLevel 1
go(tempDir.absolutePath, 1)
assertTrue(File(tempDir, "index.html").exists())
assertTrue(File(subDir, "index.html").exists())
assertFalse(File(subSubDir, "index.html").exists())
}

@Test
fun testProcessIgnoreFile() {
val tempDir = Files.createTempDirectory("html4tree_test_ignore").toFile()
tempDir.deleteOnExit()

val ignoreFile = File(tempDir, ".html4ignore")
ignoreFile.writeText(".*\\.txt\nsecret\\.dat")
ignoreFile.deleteOnExit()

val txtFile = File(tempDir, "a.txt")
txtFile.createNewFile()
txtFile.deleteOnExit()

val secretFile = File(tempDir, "secret.dat")
secretFile.createNewFile()
secretFile.deleteOnExit()

val normalFile = File(tempDir, "b.png")
normalFile.createNewFile()
normalFile.deleteOnExit()

val excludeList = process_ignore_file(tempDir)

assertTrue(excludeList.contains("a.txt"))
assertTrue(excludeList.contains("secret.dat"))
assertTrue(excludeList.contains("index.html"))
assertFalse(excludeList.contains("b.png"))
}

@Test
fun testHelp() {
help()
}
}
16 changes: 16 additions & 0 deletions src/test/kotlin/html4tree/LinkedListPullNullTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package html4tree

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

class LinkedListPullNullTest {
@Test
fun testGoNotDir() {
val f = File.createTempFile("temp", "txt")
f.deleteOnExit()
val ll = LinkedList()
ll.push(LinkedListEntry(f, 0))
val lle = ll.pull()
assert(lle != null && !lle.file.isDirectory())
}
}
19 changes: 19 additions & 0 deletions src/test/kotlin/html4tree/LinkedListTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package html4tree

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

class LinkedListTest {
@Test
fun testGettersAndSetters() {
val ll = LinkedList()
val e1 = Entry(File("test"), 0, null)
ll.first = e1
assertEquals(e1, ll.first)

val e2 = Entry(File("test2"), 1, null)
ll.last = e2
assertEquals(e2, ll.last)
}
}
24 changes: 24 additions & 0 deletions src/test/kotlin/html4tree/MainKtEdgeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package html4tree

import org.junit.Test
import java.io.File
import java.nio.file.Files

class MainKtEdgeTest {
@Test
fun testProcessDirSelfDirExclusion() {
val tempDir = Files.createTempDirectory("html4tree_test_self").toFile()
tempDir.deleteOnExit()

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

val f1 = File(tempDir, "test.txt")
f1.createNewFile()

process_dir(tempDir)

val content = File(tempDir, "index.html").readText()
assert(!content.contains("test.txt"))
}
}
18 changes: 18 additions & 0 deletions src/test/kotlin/html4tree/MainKtTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package html4tree

import org.junit.Test
import java.io.File
import java.nio.file.Files

class MainKtTest {
@Test
fun testMain() {
val tempDir = Files.createTempDirectory("html4tree_test_main").toFile()
tempDir.deleteOnExit()
val args = arrayOf(tempDir.absolutePath)
main(args)

val indexFile = File(tempDir, "index.html")
assert(indexFile.exists())
}
}
38 changes: 38 additions & 0 deletions src/test/kotlin/html4tree/UtilTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package html4tree

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

class UtilTest {
@Test
fun testLinkedList() {
val ll = LinkedList()

val f1 = File("f1")
val f2 = File("f2")
val f3 = File("f3")

ll.push(LinkedListEntry(f1, 1))
ll.push(LinkedListEntry(f2, 2))
ll.push(LinkedListEntry(f3, 3))

val p1 = ll.pull()
assertNotNull(p1)
assertEquals(f1, p1?.file)
assertEquals(1, p1?.level)

val p2 = ll.pull()
assertNotNull(p2)
assertEquals(f2, p2?.file)
assertEquals(2, p2?.level)

val p3 = ll.pull()
assertNotNull(p3)
assertEquals(f3, p3?.file)
assertEquals(3, p3?.level)

val p4 = ll.pull()
assertNull(p4)
}
}