Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
**Learning:** `File.listFiles()` returns null (not empty) on unreadable directories. Directory crawlers must reject symlink roots, skip symlink children, and avoid enqueueing paths deeper than the configured traversal limit.
**Prevention:** Use `java.nio.file.Files.isSymbolicLink(file.toPath())` for root and child directory checks, gracefully handle `null` arrays from `listFiles()` and `list()`, and only enqueue child directories when the current level is still below `maxLevel`.

## 2024-06-28 - [html4tree] Static HTML Generation Security
**Vulnerability:** Defense in Depth (CSP Missing)
**Learning:** Even when inputs are properly escaped, statically generated HTML that displays file/directory structures should implement a Content Security Policy (CSP) to provide an extra layer of defense against potential XSS bypasses.
**Prevention:** Include a strict CSP meta tag (e.g., `default-src 'none'; style-src 'unsafe-inline';`) in auto-generated HTML headers when external scripts or resources are not required.
## 2024-06-28 - [html4tree] 정적 HTML 생성 λ³΄μ•ˆ
**Vulnerability:** 심측 λ°©μ–΄ λˆ„λ½ (CSP λˆ„λ½)
**Learning:** μž…λ ₯값이 적절히 μ΄μŠ€μΌ€μ΄ν”„ λ˜λ”λΌλ„, 파일/디렉토리 ꡬ쑰λ₯Ό ν‘œμ‹œν•˜λŠ” 정적 생성 HTML은 잠재적인 XSS 우회 곡격에 λŒ€λΉ„ν•˜μ—¬ 좔가적인 λ°©μ–΄ 계측을 μ œκ³΅ν•˜λŠ” μ½˜ν…μΈ  λ³΄μ•ˆ μ •μ±…(CSP)을 λ°˜λ“œμ‹œ κ΅¬ν˜„ν•΄μ•Ό ν•©λ‹ˆλ‹€.
**Prevention:** μ™ΈλΆ€ μŠ€ν¬λ¦½νŠΈλ‚˜ λ¦¬μ†ŒμŠ€κ°€ ν•„μš”ν•˜μ§€ μ•Šμ€ 경우 μžλ™ μƒμ„±λ˜λŠ” HTML 헀더에 μ—„κ²©ν•œ CSP 메타 νƒœκ·Έ(예: `default-src 'none'; style-src 'nonce-...'; base-uri 'none'; form-action 'none';`)λ₯Ό ν¬ν•¨ν•˜κ³ , μŠ€νƒ€μΌ λΈ”λ‘μ—λŠ” μΌνšŒμ„± nonceλ₯Ό λΆ€μ—¬ν•˜μ‹­μ‹œμ˜€.

## 2024-05-31 - [CRITICAL] 심볼릭 링크 검사 우회 취약점 (canonicalFile)
**Vulnerability:** `File(path).canonicalFile`λ₯Ό μ‚¬μš©ν•˜μ—¬ 심볼릭 링크 μ—¬λΆ€λ₯Ό κ²€μ‚¬ν•˜λ©΄, `canonicalFile` ν•¨μˆ˜ λ‚΄λΆ€μ—μ„œ 심볼릭 링크λ₯Ό 이미 λŒ€μƒ(target) 파일의 μ‹€μ œ 경둜둜 해석(resolve)ν•΄ 버리기 λ•Œλ¬Έμ—, 이후에 μ§„ν–‰λ˜λŠ” `Files.isDirectory(..., LinkOption.NOFOLLOW_LINKS)` λ“±μ˜ 심볼릭 링크 μ œν•œ 검사가 μ™„μ „νžˆ 무λ ₯ν™”λ˜λŠ” 취약점이 λ°œκ²¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.
Expand Down
34 changes: 28 additions & 6 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package html4tree

import java.io.File
import java.security.SecureRandom
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.StandardCopyOption
import java.util.Base64
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.default
Expand All @@ -21,8 +23,17 @@ class Html4tree : CliktCommand() {

fun main(args: Array<String>) = Html4tree().main(args)

private val nonceRandom = SecureRandom()

fun generate_csp_nonce(): String {
val nonceBytes = ByteArray(16)
nonceRandom.nextBytes(nonceBytes)
return Base64.getEncoder().encodeToString(nonceBytes)
}

fun go(topDir: String, maxLevel: Int) {
require(topDir.isNotBlank())
require(!topDir.contains("..")) { "Path traversal sequences are not allowed." }
// λ³΄μ•ˆ μˆ˜μ •: symlink 검사λ₯Ό μš°νšŒν•˜λŠ” canonicalFile λŒ€μ‹  absoluteFile을 μ‚¬μš©
// canonicalFile은 symlinkλ₯Ό λŒ€μƒ 경둜둜 ν•΄μ„ν•˜μ—¬ μ΄μ–΄μ§€λŠ” NOFOLLOW_LINKS 검사λ₯Ό 무λ ₯ν™”ν•©λ‹ˆλ‹€.
val top_dir = File(topDir).absoluteFile.toPath().normalize().toFile()
Expand All @@ -44,8 +55,9 @@ fun go(topDir: String, maxLevel: Int) {
process_dir(lle.file)

if(maxLevel == -1 || currentLevel < maxLevel) {
val exclude = process_ignore_file(lle.file)
lle.file.listFiles()?.forEach {
if(Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS)){
if(Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(it.toPath()) && it.name !in exclude) {
ll.push( LinkedListEntry(it, currentLevel+1))
}
}
Expand Down Expand Up @@ -176,13 +188,18 @@ fun write_index_file(curr_dir: File, content: String) {
fun process_dir(curr_dir: File){

val exclude: Set<String> = process_ignore_file(curr_dir)
val styleNonce = generate_csp_nonce()

val css = """
<style>
<style nonce="${styleNonce}">
ul {
list-style-type: none;
padding-left: 0;
}
a.dir-link {
display: block;
width: 100%;
}
a {
padding: 0.5rem;
text-decoration: none;
Expand Down Expand Up @@ -214,6 +231,11 @@ fun process_dir(curr_dir: File){
outline-color: #58a6ff;
}
}
.empty-dir {
padding: 0.5rem;
opacity: 0.7;
font-style: italic;
}
</style>
"""

Expand All @@ -223,7 +245,7 @@ fun process_dir(curr_dir: File){
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- λ³΄μ•ˆ ν–₯상: 인라인 슀크립트 μ‹€ν–‰ λ°©μ§€ -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'nonce-${styleNonce}'; base-uri 'none'; form-action 'none';">
<title>${curr_dir.getName().escapeHtml()}</title>
${css}
</head>
Expand All @@ -232,7 +254,7 @@ fun process_dir(curr_dir: File){
<h1>${curr_dir.getName().escapeHtml()}</h1>
<nav aria-label="디렉토리 λͺ©λ‘">
<ul role="list">
<li><a style="display:block; width:100%" href="./.." aria-label="μƒμœ„ λ””λ ‰ν† λ¦¬λ‘œ 이동"><span aria-hidden="true">&#x21B0;</span> ..</a></li>
<li><a class="dir-link" href="./.." aria-label="μƒμœ„ λ””λ ‰ν† λ¦¬λ‘œ 이동"><span aria-hidden="true">&#x21B0;</span> ..</a></li>
"""

val index_middle = fun():String{
Expand All @@ -249,14 +271,14 @@ fun process_dir(curr_dir: File){
val encodedHref = if (isLinkedDirectory) { "./${fileName.urlEncodePath()}/" } else { "./${fileName.urlEncodePath()}" }
val ariaLabel = "${fileName} ${if (isLinkedDirectory) { "디렉토리" } else { "파일" }}".escapeHtml()
val icon = if (isLinkedDirectory) { "&#128193;" } else { "&rtrif;" }
l.append(""" <li><a style="display:block; width:100%" href="${encodedHref}" aria-label="${ariaLabel}"><span aria-hidden="true">${icon}</span> ${fileName.escapeHtml()}</a></li>""")
l.append(""" <li><a class="dir-link" href="${encodedHref}" aria-label="${ariaLabel}"><span aria-hidden="true">${icon}</span> ${fileName.escapeHtml()}</a></li>""")
l.append('\n')
}
}
}

if(l.isEmpty()){
l.append(""" <li><div style="padding: 0.5rem; opacity: 0.7; font-style: italic;">이 λ””λ ‰ν† λ¦¬λŠ” λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.</div></li>""")
l.append(""" <li><div class="empty-dir">이 λ””λ ‰ν† λ¦¬λŠ” λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.</div></li>""")
l.append('\n')
}

Expand Down
42 changes: 41 additions & 1 deletion src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ class MainTest {
assertTrue(htmlContent.contains("role=\"list\""))
}

@Test
fun testGoRejectsRelativePathTraversal() {
assertFailsWith<IllegalArgumentException> {
go("../../../etc/passwd", -1)
}
}

@Test
fun testProcessIgnoreFile() {
val ignoreFile = File(tempDir, ".html4ignore")
Expand Down Expand Up @@ -162,7 +169,15 @@ class MainTest {
assertTrue(htmlContent.contains("&#128193;"))
assertFalse(htmlContent.contains("test.ignore"))
assertTrue(htmlContent.contains("Content-Security-Policy"))
assertTrue(htmlContent.contains("default-src 'none'; style-src 'unsafe-inline';"))
val nonce = Regex("""<style nonce="([A-Za-z0-9+/=]+)">""").find(htmlContent)?.groupValues?.get(1)
assertTrue(!nonce.isNullOrBlank(), "style nonce should be rendered")
assertTrue(htmlContent.contains("default-src 'none'; style-src 'nonce-${nonce}'"))
assertTrue(htmlContent.contains("base-uri 'none'; form-action 'none';"))
assertFalse(htmlContent.contains("unsafe-inline"))
assertFalse(htmlContent.contains("style=\""))
assertTrue(htmlContent.contains("a.dir-link"))
assertTrue(htmlContent.contains("class=\"dir-link\""))
assertTrue(htmlContent.contains(".empty-dir"))
assertTrue(htmlContent.contains("prefers-color-scheme: dark"))
}

Expand Down Expand Up @@ -249,6 +264,31 @@ class MainTest {
assertFalse(File(subsubdir, "index.html").exists())
}

@Test
fun testGoIndexesNormalChildButSkipsSensitiveTraversal() {
val subdir = File(tempDir, "subdir")
subdir.mkdir()
File(tempDir, "plain.txt").writeText("plain")
val gitDir = File(tempDir, ".git")
gitDir.mkdir()

go(tempDir.absolutePath, -1)

assertTrue(File(tempDir, "index.html").exists())
assertTrue(File(subdir, "index.html").exists())
assertFalse(File(gitDir, "index.html").exists())
}

@Test
fun testProcessDirHandlesNonDirectoryWithoutThrowing() {
val notADirectory = File(tempDir, "not-a-directory")
notADirectory.writeText("content")

process_dir(notADirectory)

assertEquals("content", notADirectory.readText())
}

@Test
fun testGoWithUnreadableDir() {
val unreadableDir = File(tempDir, "unreadable")
Expand Down
Loading