From f805a35a1f9d9b97c34053570fb359f8419737ef Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Thu, 16 Jul 2026 03:26:19 +0000
Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=EB=A3=A8?=
=?UTF-8?q?=ED=8A=B8=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=EC=9D=98=20?=
=?UTF-8?q?=EB=B9=88=20=EC=9D=B4=EB=A6=84=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?=
=?UTF-8?q?=ED=96=A5=EC=83=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/palette.md | 4 ++++
src/main/kotlin/html4tree/main.kt | 4 ++--
src/test/kotlin/html4tree/MainTest.kt | 15 +++++++++++++++
3 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/.jules/palette.md b/.jules/palette.md
index 9a12c9d..2fc727f 100644
--- a/.jules/palette.md
+++ b/.jules/palette.md
@@ -48,3 +48,7 @@
## 2024-08-01 - 네이티브 브라우저 UI의 다크 모드 지원 강제
**학습:** CSS 미디어 쿼리(`@media (prefers-color-scheme: dark)`)를 통해 다크 모드를 지원하더라도, 브라우저의 네이티브 UI 요소(스크롤바, 기본 폼 컨트롤, 기본 백그라운드 등)는 테마 변경을 인식하지 못해 어두운 테마 환경에서 밝은 스크롤바가 표시되는 등 시각적 불일치를 초래합니다.
**조치:** 항상 HTML 문서의 `
` 영역에 ` ` 메타 태그를 명시적으로 추가하여 브라우저 수준에서 사용자의 시스템 테마(다크 모드 등)를 완전히 상속받아 일관성 있는 네이티브 UI를 렌더링하도록 보장하십시오.
+
+## 2026-07-16 - 루트 디렉토리의 빈 이름 처리 및 접근성 향상
+**Learning:** 파일 시스템 루트 디렉토리(예: Linux/Mac의 `/` 또는 Windows의 `C:\`)를 처리할 때, Java의 `File.getName()`은 빈 문자열을 반환합니다. 이로 인해 생성된 HTML의 ``과 `` 요소가 비어 있게 되어, 스크린 리더 사용자가 페이지의 맥락을 이해하기 어려워집니다.
+**Action:** 디렉토리 이름을 렌더링할 때 `name`이 비어 있는 경우 `absolutePath`로 폴백(`curr_dir.name.ifEmpty { curr_dir.absolutePath }`)하도록 구현하여 항상 의미 있는 제목과 제목 태그가 생성되도록 보장하십시오.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index b455862..1c3c2ee 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -327,12 +327,12 @@ ${cssContent}
- ${curr_dir.getName().escapeHtml()}
+ ${curr_dir.name.ifEmpty { curr_dir.absolutePath }.escapeHtml()}
${css}
- ${curr_dir.getName().escapeHtml()}
+ ${curr_dir.name.ifEmpty { curr_dir.absolutePath }.escapeHtml()}
↰ ..
diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt
index 1349471..694a385 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -383,6 +383,21 @@ class MainTest {
assertTrue(indexFile.readText().contains(""))
}
+ @Test
+ fun testProcessDirWithEmptyNameFallback() {
+ val tempDirFallback = Files.createTempDirectory("test-fallback").toFile()
+ val fakeRoot = object : File(tempDirFallback, "fakeRoot") {
+ override fun getName() = ""
+ }
+ fakeRoot.mkdir()
+ process_dir(fakeRoot)
+ val indexFile = File(fakeRoot, "index.html")
+ assertTrue(indexFile.exists())
+ val htmlContent = indexFile.readText()
+ assertTrue(htmlContent.contains("${fakeRoot.absolutePath.escapeHtml()} "))
+ assertTrue(htmlContent.contains("${fakeRoot.absolutePath.escapeHtml()} "))
+ }
+
@Test
fun testGoWithSymlink() {
val subdir = File(tempDir, "subdir")
From 578803feb7210a4acba88cf439408ffdbf7ee85d Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Thu, 16 Jul 2026 03:44:04 +0000
Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20MEDIUM?=
=?UTF-8?q?=20Fix=20TOCTOU=20vulnerability=20in=20write=5Findex=5Ffile?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/sentinel.md | 5 +++++
src/main/kotlin/html4tree/main.kt | 6 ++++--
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index 6ecf72f..bdfe56f 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -83,3 +83,8 @@
**Vulnerability:** 정적 HTML 생성 도구에서 매번 다른 Nonce를 동적으로 생성하여 CSP에 적용하는 것은, 캐싱 효율을 저하시킬 뿐만 아니라 정적 배포 환경(예: GitHub Pages 등)에서 올바른 보안 정책 수립을 방해할 수 있는 안티 패턴입니다.
**Learning:** 정적으로 고정된 인라인 스타일이나 스크립트에는 난수화된 Nonce보다 콘텐츠 자체의 해시(SHA-256 등)를 사용하는 것이 안전하고 일관된 방식임을 배웠습니다.
**Prevention:** 자동 생성되는 정적 HTML의 콘텐츠 보안 정책(CSP)에는 `style-src 'sha256-'` 방식을 적용하고, `
val l = StringBuilder()
val filesList = dirFiles ?: curr_dir.listFiles()
- val dir_files: MutableList = filesList?.toMutableList() ?: mutableListOf()
+ val dir_files: MutableList = if (filesList != null) filesList.toMutableList() else mutableListOf()
dir_files.sortWith(compareBy ({it.name}) )
dir_files.forEach {
val fileName = it.getName()
diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt
index 694a385..34af58e 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -109,6 +109,10 @@ class MainTest {
val hiddenFile = File(tempDir, ".hidden_file.txt")
hiddenFile.createNewFile()
+ // Also create a file that starts with a dot but is processed in the loop branches
+ val dotIgnoredFile = File(tempDir, ".ignored")
+ dotIgnoredFile.createNewFile()
+
val hiddenDir = File(tempDir, ".hidden_dir")
hiddenDir.mkdir()
val fileInHiddenDir = File(hiddenDir, "file_in_hidden_dir.txt")
@@ -569,6 +573,20 @@ class MainTest {
assertTrue(excluded.contains("test.txt"))
}
+ @Test
+ fun testGoWithDirectoryExclusion() {
+ val ignoreFile = File(tempDir, ".html4ignore")
+ ignoreFile.writeText("subdirToIgnore")
+ val subdir = File(tempDir, "subdirToIgnore")
+ subdir.mkdir()
+ File(tempDir, "test.txt").createNewFile()
+ go(tempDir.absolutePath, -1)
+ val indexFile = File(tempDir, "index.html")
+ assertTrue(indexFile.exists())
+ val htmlContent = indexFile.readText()
+ assertFalse(htmlContent.contains("subdirToIgnore"))
+ }
+
@Test
fun testProcessIgnoreFileHiddenFiles() {
// .myhidden/.hiddendir are NOT in the static sensitive-file list,
@@ -640,6 +658,37 @@ class MainTest {
assertTrue(excluded.contains("index.html"))
}
+ @Test
+ fun testProcessIgnoreFileFallbackGenericException() {
+ // Create an invalid path that throws a generic exception (e.g. InvalidPathException if mapped in channel open)
+ val badDir = object : File(tempDir, "baddir") {
+ override fun toPath(): java.nio.file.Path {
+ throw RuntimeException("Simulated exception")
+ }
+ }
+ val excluded = process_ignore_file(badDir, null)
+ assertTrue(excluded.contains("index.html"))
+ }
+
+ @Test
+ fun testUrlEncodePathFallback() {
+ // Just calling urlEncodePath directly on a string that isn't modified
+ val str = "abc"
+ assertEquals("abc", str.urlEncodePath())
+ }
+
+ @Test
+ fun testProcessIgnoreFileFallbackIOException() {
+ val ignoreFile = File(tempDir, ".html4ignore")
+ ignoreFile.mkdir()
+ File(tempDir, "test.txt").createNewFile()
+
+ // Should fall back to default exclusions gracefully
+ val excluded = process_ignore_file(tempDir, null)
+ assertFalse(excluded.contains("test.txt"))
+ assertTrue(excluded.contains("index.html"))
+ }
+
@Test
fun testProcessIgnoreFileLargeSize() {
val ignoreFile = File(tempDir, ".html4ignore")
From 7e97c7b15ff1a6fbd2a28041877925aa37b4ee73 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Thu, 16 Jul 2026 04:30:15 +0000
Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20MEDIUM?=
=?UTF-8?q?=20Fix=20named=20pipe=20blocking=20vulnerability=20in=20process?=
=?UTF-8?q?=5Fignore=5Ffile?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/sentinel.md | 5 +++
src/main/kotlin/html4tree/main.kt | 57 ++++++++++++++-------------
src/test/kotlin/html4tree/MainTest.kt | 18 +++++++++
3 files changed, 53 insertions(+), 27 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index ab05776..5362aa9 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -93,3 +93,8 @@
**Vulnerability:** Opening files using standard `File` or `Path` wrappers without enforcing no-follow links during the open can lead to symlink traversal vulnerabilities or race conditions where a file might be swapped with a symlink before it is completely read. Stalled reads could also hold the crawler indefinitely.
**Learning:** For reading potentially sensitive or untrusted configuration files (like `.html4ignore`), use race-resistant descriptor-level operations such as `java.nio.channels.FileChannel.open` with `StandardOpenOption.READ` and `LinkOption.NOFOLLOW_LINKS`. This guarantees that the opened descriptor points to a regular file and won't follow symlinks. Ensure the block is wrapped in a general `Exception` boundary so failures (like IO exceptions, missing files) fall back to secure default handling.
**Prevention:** Instead of multiple filesystem check calls (e.g. `isFile`, `isSymbolicLink`), directly open a FileChannel with `NOFOLLOW_LINKS` and wrap the sequence with a robust exception handler and size limitations to prevent DoS attacks through large files or stalled streams.
+
+## 2026-07-16 - Safe File Handling with Files.isRegularFile Guard
+**Vulnerability:** Even when opening files with NOFOLLOW_LINKS, attempting to open named pipes (FIFOs), character devices, or other special files for reading can block the process indefinitely if there is no writer. This results in an application freeze (DoS), halting the directory crawling.
+**Learning:** Never directly open potentially non-regular files without ensuring they are standard files first. Using `Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)` strictly validates that the file is not a directory, symlink, pipe, or device before you attempt a file descriptor operation on it. This combination eliminates block-read starvation.
+**Prevention:** Guard all `FileChannel.open` invocations for user-controlled filenames (like `.html4ignore`) with an explicit `isRegularFile` check to reject special types, accompanied by a resilient exception fallback handler in case of TOCTOU.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index 01c0e2f..0151775 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -183,41 +183,44 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S
// 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인
try {
val path = ignore_file.toPath()
- val channel = java.nio.channels.FileChannel.open(path, java.nio.file.StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)
- try {
- val size = channel.size()
- if (size <= 1048576) {
- val ignored_matchers = mutableListOf()
- val scanner = java.util.Scanner(java.nio.channels.Channels.newInputStream(channel), "UTF-8")
- var lineIndex = 0
- while (scanner.hasNextLine() && lineIndex < 1000) {
- val pattern = scanner.nextLine().trim()
- if (pattern.isNotEmpty() && pattern.length <= 100) {
- try {
- ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern"))
- } catch (_: java.util.regex.PatternSyntaxException) {
+ // 🛡️ Sentinel: Enforce non-blocking open, check if regular file, and no-follow links
+ if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
+ val channel = java.nio.channels.FileChannel.open(path, java.nio.file.StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)
+ try {
+ val size = channel.size()
+ if (size <= 1048576) {
+ val ignored_matchers = mutableListOf()
+ val scanner = java.util.Scanner(java.nio.channels.Channels.newInputStream(channel), "UTF-8")
+ var lineIndex = 0
+ while (scanner.hasNextLine() && lineIndex < 1000) {
+ val pattern = scanner.nextLine().trim()
+ if (pattern.isNotEmpty() && pattern.length <= 100) {
+ try {
+ ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern"))
+ } catch (_: java.util.regex.PatternSyntaxException) {
+ }
}
+ lineIndex++
}
- lineIndex++
- }
- // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다.
- val list = dirFilesNames ?: curr_dir.list()
- if (list != null) {
- list.forEach { item ->
- val current = item
- val pathCurrent = java.nio.file.Paths.get(current)
- for (matcher in ignored_matchers) {
- if (matcher.matches(pathCurrent)) {
- files_to_exclude.add(current)
- break
+ // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다.
+ val list = dirFilesNames ?: curr_dir.list()
+ if (list != null) {
+ list.forEach { item ->
+ val current = item
+ val pathCurrent = java.nio.file.Paths.get(current)
+ for (matcher in ignored_matchers) {
+ if (matcher.matches(pathCurrent)) {
+ files_to_exclude.add(current)
+ break
+ }
}
}
}
}
+ } finally {
+ channel.close()
}
- } finally {
- channel.close()
}
} catch (_: java.lang.Exception) {
// Fall through to default exclusions
diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt
index 34af58e..e8b897b 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -670,6 +670,24 @@ class MainTest {
assertTrue(excluded.contains("index.html"))
}
+ @Test
+ fun testProcessIgnoreFileUnreadableException() {
+ val unreadableFile = File(tempDir, ".html4ignore")
+ unreadableFile.writeText("*.ignore")
+ unreadableFile.setReadable(false)
+ val excluded = process_ignore_file(tempDir, null)
+ assertTrue(excluded.contains("index.html"))
+ unreadableFile.setReadable(true)
+ }
+
+ @Test
+ fun testProcessIgnoreFileRegularFileCheck() {
+ val nonRegularFile = File(tempDir, ".html4ignore")
+ nonRegularFile.mkdir()
+ val excluded = process_ignore_file(tempDir, null)
+ assertTrue(excluded.contains("index.html"))
+ }
+
@Test
fun testUrlEncodePathFallback() {
// Just calling urlEncodePath directly on a string that isn't modified