diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6c61284..33ac92a 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -22,3 +22,8 @@ **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-30 - canonicalFile을 통한 경로 탐색 및 심볼릭 링크 우회 취약점 +**Vulnerability:** `topDir` 검사에서 심볼릭 링크 여부를 확인(`Files.isDirectory(..., LinkOption.NOFOLLOW_LINKS)`)하기 전에 `File(topDir).canonicalFile`을 사용했습니다. +**Learning:** `canonicalFile`은 심볼릭 링크를 실제 경로로 미리 확인합니다. 따라서 사용자가 심볼릭 링크를 입력하면 `canonicalFile`은 실제 타겟으로 경로를 조용히 평가하여 의도한 심볼릭 링크 제한 검사를 우회하게 됩니다. 이는 Java/Kotlin에서 흔히 발생하는 경로 탐색 및 심볼릭 링크 제한 우회 결함입니다. +**Prevention:** `canonicalFile` 대신 `File(topDir).toPath().toAbsolutePath().normalize().toFile()`을 사용합니다. 이는 파일 시스템과 상호 작용하여 심볼릭 링크를 해석하지 않고 경로 문자열을 정규화하므로 후속 심볼릭 링크 검사가 대상이 아닌 실제 심볼릭 링크 경로에서 올바르게 작동할 수 있습니다. diff --git a/patch_test.diff b/patch_test.diff new file mode 100644 index 0000000..7f7f805 --- /dev/null +++ b/patch_test.diff @@ -0,0 +1,39 @@ +<<<<<<< SEARCH + @Test + fun testGoWithMaxLevel() { +======= + @Test + fun testGoWithMaxLevel() { + val subdir = File(tempDir, "subdir") + subdir.mkdir() + val subsubdir = File(subdir, "subsubdir") + subsubdir.mkdir() + + go(tempDir.absolutePath, 0) + + assertTrue(File(tempDir, "index.html").exists()) + assertFalse(File(subdir, "index.html").exists()) + assertFalse(File(subsubdir, "index.html").exists()) + } + + @Test + fun testGoMaxLevelLimitsCrawl() { + val subdir = File(tempDir, "subdir") + subdir.mkdir() + + go(tempDir.absolutePath, -2) // Should not process any dirs theoretically, testing the currentLevel <= maxLevel boundary + } + + @Test + fun testProcessIgnoreFileWithListNull() { + val unreadableDir = File(tempDir, "unreadable2") + unreadableDir.mkdir() + File(unreadableDir, ".html4ignore").writeText(".*\\.txt") + unreadableDir.setReadable(false, false) + try { + process_ignore_file(unreadableDir) + } finally { + unreadableDir.setReadable(true, false) + } + } +>>>>>>> REPLACE diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 2e2809f..ae3e0b5 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -23,7 +23,7 @@ fun main(args: Array) = Html4tree().main(args) fun go(topDir: String, maxLevel: Int) { require(topDir.isNotBlank()) - val top_dir = File(topDir).canonicalFile + val top_dir = File(topDir).toPath().toAbsolutePath().normalize().toFile() require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } val ll = LinkedList() diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index e8a3082..84e6684 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -227,6 +227,28 @@ class MainTest { assertFalse(File(subsubdir, "index.html").exists()) } + @Test + fun testGoMaxLevelLimitsCrawl() { + val subdir = File(tempDir, "subdir") + subdir.mkdir() + + go(tempDir.absolutePath, -2) + assertFalse(File(tempDir, "index.html").exists()) + } + + @Test + fun testProcessIgnoreFileWithListNull() { + val unreadableDir = File(tempDir, "unreadable2") + unreadableDir.mkdir() + File(unreadableDir, ".html4ignore").writeText(".*\\.txt") + Assume.assumeTrue(unreadableDir.setReadable(false, false)) + try { + process_ignore_file(unreadableDir) + } finally { + unreadableDir.setReadable(true, false) + } + } + @Test fun testGoWithUnreadableDir() { val unreadableDir = File(tempDir, "unreadable")