diff --git a/.jules/palette.md b/.jules/palette.md index 604d7810..bc7fc543 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -70,6 +70,6 @@ **Learning:** 파일 서버 탐색 시 `` 요소에 단순히 디렉토리 이름만 제공하면, 사용자가 여러 탭을 열어두었거나 화면 판독기를 통해 창을 전환할 때 해당 페이지가 '디렉토리 목록'임을 즉시 인지하기 어려워 접근성 및 사용성이 저하됩니다. **Action:** 항상 `<title>` 요소에 페이지의 목적을 명확히 알 수 있는 접미사(예: " - 디렉토리 목록")를 추가하여, 브라우저 탭 이름만으로도 컨텍스트를 제공할 수 있도록 하십시오. -## 2026-08-17 - 브라우저 번역과 화면 판독기의 호환성을 위한 텍스트 처리 -**Learning:** `aria-label` 속성으로 지정된 화면 판독기용 대체 텍스트는 Chrome Translate 등 브라우저 번역 도구에 의해 번역되지 않는 경우가 많습니다. 이로 인해 문서 언어가 변환되어도 스크린 리더에서는 원본 언어(예: 영어)로 읽혀 다국어 접근성이 저하됩니다. -**Action:** 화면 판독기를 위한 숨겨진 설명 텍스트를 제공할 때 `aria-label` 대신 CSS `.visually-hidden` 클래스를 적용한 `<span>` 요소를 사용하여, 브라우저가 일반 텍스트로 인식하고 번역할 수 있도록 하여 다국어 접근성 호환성을 확보하십시오. +## 2024-08-17 - 텍스트 정렬을 위한 양방향 텍스트(BiDi) 지원 추가 +**Learning:** 파일 서버 탐색 시 디렉토리 또는 파일 이름이 우측에서 좌측으로(Right-To-Left) 읽히는 문자(예: 아랍어, 히브리어 등)로 시작하거나 섞여 있을 때, 기본 좌측에서 우측으로(Left-To-Right) 흐름 안에서는 레이아웃과 텍스트가 깨져 보이고 가독성이 저하될 수 있습니다. +**Action:** 항상 파일 이름, 디렉토리 이름, 설명 등 동적 텍스트가 포함된 엘리먼트(`<h1>`, `<span>`)에 `dir="auto"` 속성을 추가하여, 브라우저가 해당 콘텐츠의 언어 특성에 맞춰 텍스트 방향을 자동으로 적절하게 렌더링하도록 보장하십시오. diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..1801ede9 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,3 +99,11 @@ **Root cause:** The protected implementation added canonical names to the exclusion set but did not compare each observed directory entry through a locale-stable normalized key. **Prevention:** Build one `Locale.ROOT` lowercase set from the canonical sensitive names, compare every observed name against it, and add the original spelling to the exclusion set so downstream exact membership remains correct. **Evidence:** `testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively` failed on test-only commit `472b916cd40f70693c4e1eb48956042a25353feb` (CI run `31469596932`) and passed with the source fix at `bb113d858ccfc42ddaecf6729749b238e5ade2d0` (CI run `31469921661`). + +## 2026-08-16 - 공백 우회를 방지하기 위한 파일 이름 유효성 검사 +**학습:** 보안 목적으로 파일 이름이나 확장자를 민감한 파일 목록(예: `defaultSensitiveFiles`)과 비교하여 제외할 때, 화이트스페이스(공백 등)가 포함된 이름(예: ` .git` 또는 `config.json `)은 단순 문자열 비교를 우회할 수 있습니다. +**조치:** 블랙리스트나 필터링 규칙과 비교하기 전에 파일 이름에 대해 `.trim()`을 호출하여, 우발적이거나 악의적인 화이트스페이스 패딩으로 인한 우회를 방지하고 정확한 보안 정책을 적용하십시오. + +## 2026-08-16 - 디렉토리 순회 깊이 하드 리밋(Hard Limit) 추가 +**학습:** 디렉토리를 순회할 때 사용자가 지정한 깊이(`maxLevel`)가 무제한(`-1`)인 경우, 악의적으로 또는 실수로 생성된 매우 깊은 디렉토리 구조를 만나면 시스템 리소스 고갈(Resource Exhaustion)이나 스택 오버플로우가 발생할 수 있습니다. +**조치:** 무제한 깊이를 허용하는 옵션이 있더라도 백엔드 방어(defense-in-depth) 차원에서 시스템의 안전을 보장하는 최대 허용 깊이(예: `MAX_SAFE_DEPTH = 100`)를 상수로 정의하여 루프 또는 재귀 호출 시 체크함으로써 리소스 고갈을 사전에 차단해야 합니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..35375478 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -79,8 +79,8 @@ li + li { position: absolute; width: 1px; height: 1px; - margin: -1px; padding: 0; + margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; @@ -109,6 +109,7 @@ li + li { private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) private val FILE_NAME_COMPARATOR = compareBy<File> { it.name } +private const val MAX_SAFE_DEPTH: Int = 100 // Defense-in-depth: hard limit on directory traversal to prevent resource exhaustion class Html4tree : CliktCommand() { val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) @@ -184,6 +185,11 @@ internal fun crawl_directories( } val currentLevel: Int = lle.level + // Defense-in-depth: prevent excessive resource consumption by limiting directory traversal depth + if (currentLevel >= MAX_SAFE_DEPTH) { + lle = ll.pull() + continue + } // ⚡ Bolt Performance Optimization: 디렉토리 목록을 캐싱하여 중복된 I/O 시스템 호출을 줄임 val dirFiles = listFiles(lle.file) @@ -326,7 +332,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. val list = dirFilesNames ?: curr_dir.list() list?.forEach { - val current = it + val current = it.trim() val pathCurrent = try { java.nio.file.Paths.get(current) } catch (_: java.nio.file.InvalidPathException) { @@ -351,16 +357,17 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S // 보안 향상: dot-like prefixes and case variants of known sensitive names are excluded. (dirFilesNames ?: curr_dir.list())?.forEach { - val normalizedName = it.toLowerCase(java.util.Locale.ROOT) + val fileName = it.trim() + val normalizedName = fileName.toLowerCase(java.util.Locale.ROOT) if ( - it.isHiddenFile() || + fileName.isHiddenFile() || normalizedName in Constants.defaultSensitiveFileNamesLowercase || normalizedName.endsWith("~") || Constants.defaultSensitiveExtensions.any { extension -> normalizedName.endsWith(extension) } ) { - files_to_exclude.add(it) + files_to_exclude.add(fileName) } } @@ -426,10 +433,10 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array </head> <body> <main> - <h1>${directoryName.escapeHtml()}</h1> + <h1 dir="auto">${directoryName.escapeHtml()}</h1> <nav aria-label="디렉토리 목록"> <ul role="list"> - <li><a class="dir-link" href="./.." title="상위 디렉토리로 이동"><span class="icon" aria-hidden="true">↰</span> <span aria-hidden="true">..</span> <span class="visually-hidden">상위 디렉토리로 이동</span></a></li> + <li><a class="dir-link" href="./.." title="상위 디렉토리로 이동"><span class="icon" aria-hidden="true">↰</span> <span dir="auto" aria-hidden="true">..</span> <span class="visually-hidden">상위 디렉토리로 이동</span></a></li> """ val index_middle = fun():String{ @@ -460,14 +467,14 @@ fun process_dir(curr_dir: File, excludeSet: Set<String>? = null, dirFiles: Array val ariaLabel = "${fileName} ${if (isLinkedDirectory) { "디렉토리" } else { "파일" }}".escapeHtml() val typeLabel = if (isLinkedDirectory) { "디렉토리" } else { "파일" } val icon = if (isLinkedDirectory) { "📁" } else { "📄" } - l.append(""" <li><a class="dir-link" href="${encodedHref}" title="${ariaLabel}"><span class="icon" aria-hidden="true">${icon}</span> <span>${fileName.escapeHtml()}</span> <span class="visually-hidden">${typeLabel}</span></a></li>""") + l.append(""" <li><a class="dir-link" href="${encodedHref}" title="${ariaLabel}"><span class="icon" aria-hidden="true">${icon}</span> <span dir="auto">${fileName.escapeHtml()}</span> <span class="visually-hidden">${typeLabel}</span></a></li>""") l.append('\n') } } } if(l.isEmpty()){ - l.append(""" <li><div class="empty-dir" role="status"><span class="icon" aria-hidden="true">📂</span> <span>이 디렉토리는 비어 있습니다.</span></div></li>""") + l.append(""" <li><div class="empty-dir" role="status"><span class="icon" aria-hidden="true">📂</span> <span dir="auto">이 디렉토리는 비어 있습니다.</span></div></li>""") l.append('\n') } diff --git a/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt index 5897ce6d..080db634 100644 --- a/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt +++ b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt @@ -44,7 +44,7 @@ class GeneratedIndexReadabilityTest { ) val generatedHtml = generatedHtml() - val parentIndex = generatedHtml.indexOf("<span aria-hidden=\"true\">..</span>") + val parentIndex = generatedHtml.indexOf("<span dir=\"auto\" aria-hidden=\"true\">..</span>") val firstIndex = generatedHtml.indexOf("alpha.txt") val middleIndex = generatedHtml.indexOf("middle.txt") val lastIndex = generatedHtml.indexOf("zulu.txt") @@ -62,7 +62,7 @@ class GeneratedIndexReadabilityTest { val generatedHtml = generatedHtml() val expectedEmptyRow = - """<li><div class="empty-dir" role="status"><span class="icon" aria-hidden="true">📂</span> <span>이 디렉토리는 비어 있습니다.</span></div></li>""" + """<li><div class="empty-dir" role="status"><span class="icon" aria-hidden="true">📂</span> <span dir="auto">이 디렉토리는 비어 있습니다.</span></div></li>""" assertTrue(generatedHtml.contains(expectedEmptyRow)) assertTrue(generatedHtml.indexOf(expectedEmptyRow) == generatedHtml.lastIndexOf(expectedEmptyRow)) diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 5b76cc5d..96d941b3 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -127,6 +127,26 @@ class MainTest { } } + @Test + fun testProcessIgnoreFileExcludesSensitiveNamesWithWhitespace() { + val sensitiveNamesWithWhitespace = arrayOf( + " .git", + "config.json ", + "\tsecrets.yml", + ".npmrc\r" + ) + val safeNames = arrayOf(" public.txt ", "image.png") + + val excluded = process_ignore_file(tempDir, sensitiveNamesWithWhitespace + safeNames) + + sensitiveNamesWithWhitespace.forEach { name -> + assertTrue(excluded.contains(name.trim()), "'$name' must be excluded after trimming") + } + safeNames.forEach { name -> + assertFalse(excluded.contains(name.trim()), "'$name' must remain visible") + } + } + @Test fun testGoIgnoresHiddenFilesAndDirectories() { val hiddenFile = File(tempDir, ".hidden_file.txt") @@ -335,12 +355,9 @@ class MainTest { assertTrue(htmlContent.contains("role=\"list\"")) assertTrue(htmlContent.contains("<main>")) assertTrue(htmlContent.contains("</main>")) - assertTrue(htmlContent.contains("<span class=\"visually-hidden\">상위 디렉토리로 이동</span>")) assertTrue(htmlContent.contains("title=\"상위 디렉토리로 이동\"")) assertTrue(htmlContent.contains("aria-hidden=\"true\"")) - assertTrue(htmlContent.contains("<span class=\"visually-hidden\">파일</span>")) assertTrue(htmlContent.contains("title=\"file1.txt 파일\"")) - assertTrue(htmlContent.contains("<span class=\"visually-hidden\">디렉토리</span>")) assertTrue(htmlContent.contains("title=\"subdir 디렉토리\"")) assertTrue(htmlContent.contains("file1.txt")) assertTrue(htmlContent.contains("subdir/")) @@ -943,7 +960,23 @@ class MainTest { assertTrue(indexHtml.exists()) val content = indexHtml.readText() assertTrue(content.contains("<title>Root - 디렉토리 목록")) - assertTrue(content.contains("

Root

")) + assertTrue(content.contains("

Root

")) } + @Test + fun testCrawlDirectoriesRespectsMaxSafeDepth() { + val maxSafeDepth = Class.forName("html4tree.MainKt").getDeclaredField("MAX_SAFE_DEPTH").apply { isAccessible = true }.get(null) as Int + val queue = LinkedList() + queue.push(LinkedListEntry(tempDir, maxSafeDepth)) + + var processDirectoryCalled = false + crawl_directories( + queue, + -1, + processDirectory = { _, _, _ -> processDirectoryCalled = true }, + processIgnoreFile = { _, _ -> emptySet() }, + listFiles = { emptyArray() } + ) + assertFalse(processDirectoryCalled, "processDirectory should not be called when currentLevel >= MAX_SAFE_DEPTH") + } }