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의 ``과 `<h1>` 요소가 비어 있게 되어, 스크린 리더 사용자가 페이지의 맥락을 이해하기 어려워집니다. +**Action:** 디렉토리 이름을 렌더링할 때 `name`이 비어 있는 경우 `absolutePath`로 폴백(`curr_dir.name.ifEmpty { curr_dir.absolutePath }`)하도록 구현하여 항상 의미 있는 제목과 제목 태그가 생성되도록 보장하십시오. diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6ecf72f..5362aa9 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -83,3 +83,18 @@ **Vulnerability:** 정적 HTML 생성 도구에서 매번 다른 Nonce를 동적으로 생성하여 CSP에 적용하는 것은, 캐싱 효율을 저하시킬 뿐만 아니라 정적 배포 환경(예: GitHub Pages 등)에서 올바른 보안 정책 수립을 방해할 수 있는 안티 패턴입니다. **Learning:** 정적으로 고정된 인라인 스타일이나 스크립트에는 난수화된 Nonce보다 콘텐츠 자체의 해시(SHA-256 등)를 사용하는 것이 안전하고 일관된 방식임을 배웠습니다. **Prevention:** 자동 생성되는 정적 HTML의 콘텐츠 보안 정책(CSP)에는 `style-src 'sha256-<HASH>'` 방식을 적용하고, `<style>` 태그에서 불필요한 `nonce` 속성을 제거하여 브라우저의 무결성 검증 기능을 적극 활용하십시오. + +## 2026-07-16 - TOCTOU Symlink Attack in Temporary File Creation +**Vulnerability:** A TOCTOU (Time-of-Check to Time-of-Use) vulnerability existed when creating temporary files for `index.html`. A malicious actor could swap the target directory with a symlink after the crawler's initial checks but before the temporary file was created and moved into it. +**Learning:** Checking properties on an arbitrary file path (like whether it is a symlink) and later executing file operations on that same path using string representations is unsafe. The underlying file system object can change between the check and the use. +**Prevention:** Always re-evaluate paths to their real targets immediately before sensitive operations. In Java/Kotlin, converting a `Path` using `.toRealPath(LinkOption.NOFOLLOW_LINKS)` immediately before operations like `Files.createTempFile` and `Files.move` ensures that symlink swaps are caught or strictly prevented during those critical filesystem calls. + +## 2026-07-16 - Safe File Handling with FileChannel and Exception Boundaries +**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 b455862..0151775 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -181,35 +181,49 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 - if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){ - val ignored_matchers = mutableListOf<java.nio.file.PathMatcher>() - - ignore_file.useLines { lines -> - for ((lineIndex, it) in lines.withIndex()) { - // 줄 수 제한이 패턴 수도 함께 상한(줄당 최대 1개 패턴)하므로 별도 패턴 카운터는 불필요 - if (lineIndex >= 1000) break - val pattern = it.trim() - if (pattern.isNotEmpty() && pattern.length <= 100) { - try { - ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")) - } catch (_: java.util.regex.PatternSyntaxException) { - } - } - } - } - - // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. - val list = dirFilesNames ?: curr_dir.list() - list?.forEach { - val current = it - val pathCurrent = java.nio.file.Paths.get(current) - for (matcher in ignored_matchers) { - if (matcher.matches(pathCurrent)) { - files_to_exclude.add(current) - break - } - } - } + try { + val path = ignore_file.toPath() + // 🛡️ 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<java.nio.file.PathMatcher>() + 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++ + } + + // ⚡ 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() + } + } + } catch (_: java.lang.Exception) { + // Fall through to default exclusions } if ("index.html" !in files_to_exclude) @@ -230,8 +244,10 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S } fun write_index_file(curr_dir: File, content: String) { - val indexPath = curr_dir.toPath().resolve("index.html") - val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html") + // 🛡️ Sentinel: Validate the real path immediately before temp-file creation and move to prevent TOCTOU. + val realPath = curr_dir.toPath().toRealPath(LinkOption.NOFOLLOW_LINKS) + val indexPath = realPath.resolve("index.html") + val tempPath = Files.createTempFile(realPath, ".index-", ".html") try { Files.write(tempPath, content.toByteArray(Charsets.UTF_8)) Files.move(tempPath, indexPath, StandardCopyOption.REPLACE_EXISTING) @@ -327,12 +343,12 @@ ${cssContent} </style> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src '${styleHash}'; base-uri 'none'; form-action 'none';"> <!-- 보안 향상: 리퍼러를 통한 디렉토리 경로 노출 방지 --> <meta name="referrer" content="no-referrer"> - <title>${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()}