From 534ebbc5e2b785b97cd1a47a5c2aae5287dd329f Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Sun, 9 Aug 2026 21:13:37 +0000
Subject: [PATCH] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20=EC=9B=90=EC=9E=90?=
=?UTF-8?q?=EC=A0=81=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=8F=99(Atomic=20Mo?=
=?UTF-8?q?ve)=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=9C=20TOCTOU=20=EC=B7=A8?=
=?UTF-8?q?=EC=95=BD=EC=A0=90=20=EC=99=84=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
임시 인덱스 파일을 생성한 후 대상 경로로 덮어쓸 때 발생할 수 있는
Time-of-Check to Time-of-Use (TOCTOU) 경합 조건을 방지하기 위해
StandardCopyOption.ATOMIC_MOVE 플래그를 추가했습니다.
해당 파일 시스템에서 원자적 이동을 지원하지 않는 경우 예외를 잡아
안전하게 폴백(Fallback) 처리할 수 있도록 방어적인 코드를 작성하고
테스트 코드 상에서 100% 분기 커버리지를 달성했습니다.
---
.jules/sentinel.md | 5 +++++
src/main/kotlin/html4tree/main.kt | 14 ++++++++++++--
src/test/kotlin/html4tree/MainTest.kt | 23 +++++++++++++++++++++++
3 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index cdf88010..ed6b0170 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-09 - [html4tree] 원자적 파일 이동(Atomic Move)을 통한 임시 파일 덮어쓰기 TOCTOU 보안 향상
+**Vulnerability:** 파일 시스템 생성과 교체 사이에 발생하는 Time-of-Check to Time-of-Use (TOCTOU) 취약점.
+**Learning:** `write_index_file`은 임시 파일을 생성하고 목표 경로(`index.html`)로 이동시키는데, 단순히 `StandardCopyOption.REPLACE_EXISTING`만 사용할 경우 다른 프로세스가 그 찰나의 순간에 대상 파일을 변경하거나 심볼릭 링크로 바꿔치기할 수 있는 경합 조건(Race Condition)이 존재합니다.
+**Prevention:** 파일 덮어쓰기 작업 시 `StandardCopyOption.ATOMIC_MOVE` 옵션을 명시적으로 사용하여 파일 이동 연산이 원자적(atomic)으로 이루어지도록 보장하고, 파일 시스템에서 해당 옵션을 지원하지 않을 수 있으므로 `AtomicMoveNotSupportedException`을 `try-catch` 블록으로 안전하게 폴백(Fallback) 처리해야 합니다.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index 8942c047..abe501b7 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -327,12 +327,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,
+ moveFile: (java.nio.file.Path, java.nio.file.Path) -> Unit = { source, target ->
+ Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
+ }
+) {
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 {
+ moveFile(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 179b6c5b..2e547e90 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -376,6 +376,29 @@ class MainTest {
assertTrue(leftoverTemp.isEmpty(), "temporary index file should be cleaned up on failure")
}
+ @Test
+ fun testWriteIndexFileHandlesAtomicMoveNotSupported() {
+ val content = "atomic fallback content"
+ write_index_file(tempDir, content) { source, target ->
+ throw java.nio.file.AtomicMoveNotSupportedException(source.toString(), target.toString(), "Mocked atomic move failure")
+ }
+ val indexPath = File(tempDir, "index.html")
+ assertTrue(indexPath.exists())
+ assertEquals(content, indexPath.readText())
+ }
+
+ @Test
+ fun testWriteIndexFileAtomicMoveSuccessFallback() {
+ val content = "atomic move success content"
+ // This simulates a successful atomic move when no exception is thrown
+ write_index_file(tempDir, content) { source, target ->
+ Files.move(source, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
+ }
+ val indexPath = File(tempDir, "index.html")
+ assertTrue(indexPath.exists())
+ assertEquals(content, indexPath.readText())
+ }
+
@Test
fun testProcessDirReplacesIndexSymlinkWithoutTouchingTarget() {
val targetFile = File(tempDir, "target.txt")