From 2afc134feab712c50b910d57630ca7291b8b8c49 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 7 Aug 2026 20:56:45 +0000
Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20=EC=9C=A0?=
=?UTF-8?q?=EB=8B=88=EC=BD=94=EB=93=9C=20=ED=98=B8=EB=AA=A8=EA=B8=80?=
=?UTF-8?q?=EB=A6=AC=ED=94=84=EB=A5=BC=20=ED=86=B5=ED=95=9C=20=EC=88=A8?=
=?UTF-8?q?=EA=B9=80=20=ED=8C=8C=EC=9D=BC=20=EC=9A=B0=ED=9A=8C=20=EC=B7=A8?=
=?UTF-8?q?=EC=95=BD=EC=A0=90=20=EC=88=98=EC=A0=95?=
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 | 12 +++++++++---
src/test/kotlin/html4tree/MainTest.kt | 24 ++++++++++++++++++++++++
3 files changed, 38 insertions(+), 3 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index cdf88010..9b3050f2 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -88,3 +88,8 @@
**Vulnerability:** CSP 해시 불일치로 인한 인라인 스타일 차단
**Learning:** 브라우저는 인라인 스크립트와 스타일의 내부 텍스트(공백과 줄바꿈 포함)를 정확하게 해싱하여 Content-Security-Policy(CSP) 해시와 비교합니다. Kotlin의 멀티라인 문자열(`"""`)을 사용하여 템플릿에 콘텐츠를 주입할 때 암묵적인 여백이나 줄바꿈이 추가되면 최종 HTML 문자열이 변경되어 CSP 해시가 무효화됩니다.
**Prevention:** 콘텐츠를 해싱하기 전에 `.trimIndent()`를 적용하여 원본 문자열을 정규화하고, HTML 템플릿에 주입할 때 ``와 같이 공백 없이 주입하여 해시가 완벽하게 일치하도록 해야 합니다.
+
+## 2024-07-28 - [html4tree] 유니코드 호모글리프를 통한 숨김 파일 우회 취약점 수정
+**Vulnerability:** 파일 이름 필터링을 우회하기 위해 마침표(.) 대신 유니코드 호모글리프(예: U+3002, U+FF0E, U+FF61)를 사용하여 숨김 파일을 생성 및 노출시키는 취약점.
+**Learning:** 숨김 파일이나 디렉토리를 필터링할 때 단순히 ASCII 마침표('.')만 검사하면 악의적인 사용자가 유니코드 호모글리프를 악용하여 필터링을 우회할 수 있습니다.
+**Prevention:** 파일 시스템 필터링 로직에서 유니코드 호모글리프를 명시적으로 식별하고 차단하는 추가 검증이 필요합니다.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index f52a1468..fdcfb6fd 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -176,7 +176,7 @@ internal fun crawl_directories(
dirFiles?.forEach {
// ⚡ Bolt Performance Optimization: Short-circuit OS stat calls (isDirectory/isSymbolicLink)
// by checking cheap in-memory string exclusion rules first
- if(!it.name.startsWith(".") && it.name !in exclude && isDirectory(it) && !isSymbolicLink(it)) {
+ if(!it.name.isHiddenFile() && it.name !in exclude && isDirectory(it) && !isSymbolicLink(it)) {
val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key)
ll.push(childEntry)
}
@@ -186,6 +186,12 @@ internal fun crawl_directories(
}
}
+fun String.isHiddenFile(): Boolean {
+ if (this.isEmpty()) return false
+ val firstChar = this[0]
+ return firstChar == '.' || firstChar == '\u3002' || firstChar == '\uFF0E' || firstChar == '\uFF61'
+}
+
// ⚡ Bolt Performance Optimization: Single-pass loop with lazy StringBuilder
// Chained `.replace()` calls allocate multiple intermediate strings.
// A single pass over the string lazily allocating a StringBuilder is much faster.
@@ -303,7 +309,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S
// 보안 향상: .env, .git 등 민감한 정보가 포함될 수 있는 숨김 파일(.으로 시작하는 모든 항목)을 기본적으로 노출하지 않도록 제외 (정보 노출 방지)
(dirFilesNames ?: curr_dir.list())?.forEach {
- if (it.startsWith(".")) {
+ if (it.isHiddenFile()) {
files_to_exclude.add(it)
}
}
@@ -357,7 +363,7 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array
val fileName = it.getName()
// ⚡ Bolt Performance Optimization: Short-circuit string match before expensive OS filesystem calls
// 🛡️ Sentinel: Ignore hidden files/directories to prevent sensitive data exposure
- if (!fileName.startsWith(".") && fileName !in exclude) {
+ if (!fileName.isHiddenFile() && fileName !in exclude) {
var isLinkedDirectory = false
var isSymbolicLink = false
try {
diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt
index 83739c9c..6e2ea0bc 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -706,4 +706,28 @@ class MainTest {
assertFalse(processed, "fileKey mismatch should skip directory processing")
assertFalse(listed, "fileKey mismatch should skip child listing")
}
+
+ @Test
+ fun testIsHiddenFile() {
+ assertTrue(".env".isHiddenFile())
+ assertTrue("\u3002env".isHiddenFile())
+ assertTrue("\uFF0Egit".isHiddenFile())
+ assertTrue("\uFF61ssh".isHiddenFile())
+ assertFalse("test.txt".isHiddenFile())
+ assertFalse("".isHiddenFile())
+ }
+
+ @Test
+ fun testProcessIgnoreFileHomoglyphs() {
+ File(tempDir, "\u3002myhidden").createNewFile()
+ File(tempDir, "\uFF0Ehiddendir").mkdir()
+ File(tempDir, "\uFF61env").createNewFile()
+ File(tempDir, "test2.txt").createNewFile()
+
+ val excluded = process_ignore_file(tempDir)
+ assertTrue(excluded.contains("\u3002myhidden"))
+ assertTrue(excluded.contains("\uFF0Ehiddendir"))
+ assertTrue(excluded.contains("\uFF61env"))
+ assertFalse(excluded.contains("test2.txt"))
+ }
}