From 6cff5699afc31a32bcdea39511623f6c9fd07dbf Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:56:01 +0000 Subject: [PATCH] =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=96=A5=EC=83=81:=20writ?= =?UTF-8?q?e=5Findex=5Ffile=EC=97=90=20ATOMIC=5FMOVE=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `write_index_file` 함수에서 기존 파일을 덮어쓸 때 `StandardCopyOption.ATOMIC_MOVE`를 사용하도록 수정. - 파일 시스템 TOCTOU(Time-of-Check to Time-of-Use) 및 심볼릭 링크 공격을 방지. - `ATOMIC_MOVE`를 지원하지 않는 환경을 위해 `AtomicMoveNotSupportedException` 발생 시 `REPLACE_EXISTING`으로 폴백(fallback)하도록 구현. - `MainTest.kt`에 `AtomicMoveNotSupportedException` 예외 경로를 커버하는 테스트 추가하여 100% 테스트 커버리지 유지. - `.jules/sentinel.md`에 관련된 보안 학습 내용(원자적 파일 교체) 한국어로 기록. --- .jules/sentinel.md | 5 +++++ src/main/kotlin/html4tree/main.kt | 14 ++++++++++++-- src/test/kotlin/html4tree/MainTest.kt | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index cdf88010..0868d013 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-08-08 - [html4tree] 원자적 파일 교체 (Atomic File Swap) +**Vulnerability:** 파일 시스템 TOCTOU(Time-of-Check to Time-of-Use) 및 심볼릭 링크 공격. `REPLACE_EXISTING`으로 파일을 직접 덮어쓰면 기존 파일이 심볼릭 링크일 경우 대상이 덮어쓰여 임의의 파일이 손상될 수 있습니다. +**Learning:** 안전하게 파일을 교체하려면 `ATOMIC_MOVE`를 시도하여 교체 중간에 끼어들 틈이나 링크 추적을 허용하지 않도록 하고 실패 시 `REPLACE_EXISTING`으로 우회하는 방법이 가장 효과적입니다. +**Prevention:** `Files.move()` 시 항상 `StandardCopyOption.ATOMIC_MOVE`를 사용하고 `AtomicMoveNotSupportedException`을 안전하게 처리하도록 구성하십시오. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index f52a1468..2be062e9 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -311,12 +311,22 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S return files_to_exclude } -fun write_index_file(curr_dir: File, content: String) { +fun write_index_file( + curr_dir: File, + content: String, + moveOp: (java.nio.file.Path, java.nio.file.Path) -> Unit = { src, dest -> + Files.move(src, dest, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } +) { val indexPath = curr_dir.toPath().resolve("index.html") val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html") try { Files.write(tempPath, content.toByteArray(Charsets.UTF_8)) - Files.move(tempPath, indexPath, StandardCopyOption.REPLACE_EXISTING) + try { + moveOp(tempPath, indexPath) + } catch (e: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(tempPath, indexPath, StandardCopyOption.REPLACE_EXISTING) + } } finally { Files.deleteIfExists(tempPath) } diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 83739c9c..b9abe353 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -364,6 +364,20 @@ class MainTest { assertTrue(leftoverTemp.isEmpty(), "temporary index file should be cleaned up on failure") } + @Test + fun testWriteIndexFileAtomicException() { + val localTempDir = Files.createTempDirectory("test_atomic").toFile() + try { + write_index_file(localTempDir, "content") { _, _ -> + throw java.nio.file.AtomicMoveNotSupportedException("src", "dest", "mock exception") + } + val indexPath = localTempDir.toPath().resolve("index.html") + assertTrue(Files.exists(indexPath)) + } finally { + localTempDir.deleteRecursively() + } + } + @Test fun testProcessDirReplacesIndexSymlinkWithoutTouchingTarget() { val targetFile = File(tempDir, "target.txt")