From 8ffd6dee74140a5b255cbdef065ef204a2202273 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Sat, 8 Aug 2026 21:04:07 +0000
Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM]=20?=
=?UTF-8?q?Fix=20TOCTOU=20vulnerability=20in=20index.html=20generation=20v?=
=?UTF-8?q?ia=20Atomic=20Move?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
๐จ Severity: MEDIUM
๐ก Vulnerability: ํ์ผ ๋ฎ์ด์ฐ๊ธฐ ๊ณผ์ ์์ `REPLACE_EXISTING`์ ์ฌ์ฉํ ๋ ๋ฐ์ํ๋ Race Condition์ผ๋ก ์ธํด ์์ฑ ์ค์ธ ์์ ํ์ผ๊ณผ ์ค์ ๋์ ํ์ผ(index.html)์ด ๊ต์ฒด๋๋ ์๊ฐ ์๊ฐ์ฐจ ๊ณต๊ฒฉ(TOCTOU)์ ๋
ธ์ถ๋ ์ ์์.
๐ฏ Impact: ๋ค์ค ์ค๋ ๋/ํ๋ก์ธ์ค ํ๊ฒฝ์์ ์๋ชป๋๊ฑฐ๋ ์กฐ์๋ ํ์ผ์ด ์ธ๋ฑ์ค๋ก ์ ๊ณต๋ ์ ์์.
๐ง Fix: `write_index_file`์์ ํ์ผ์ ์ด๋ํ ๋ ๊ธฐ๋ณธ์ ์ผ๋ก `StandardCopyOption.ATOMIC_MOVE`๋ฅผ ์ฌ์ฉํ๋๋ก ๋ณ๊ฒฝํ์ฌ ์์์ ๊ต์ฒด๋ฅผ ๋ณด์ฅํจ. ํ์ผ ์์คํ
์์ ์ด๋ฅผ ์ง์ํ์ง ์๋ ํ๊ฒฝ(`AtomicMoveNotSupportedException` ๋ฐ์)์์๋ ๊ธฐ์กด์ `REPLACE_EXISTING` ๋ฐฉ์์ผ๋ก Fallback ์ฒ๋ฆฌํ๋๋ก ๊ตฌํํจ.
โ
Verification: `./gradlew test jacocoTestReport`๋ฅผ ํตํด 100% ํ
์คํธ ์ปค๋ฒ๋ฆฌ์ง ๋ฐ Fallback ๋์ ๊ฒ์ฆ ์๋ฃ.
---
.jules/sentinel.md | 5 +++++
src/main/kotlin/html4tree/main.kt | 14 ++++++++++++--
src/test/kotlin/html4tree/MainTest.kt | 15 +++++++++++++++
3 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index cdf88010..5318aa48 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 Move)๋ฅผ ํตํ TOCTOU ์ํ
+**Vulnerability:** ํ์ผ ๊ต์ฒด ์(์์ ํ์ผ์ ๋์ ํ์ผ๋ก `REPLACE_EXISTING` ๋ฐฉ์์ผ๋ก ์ฎ๊ธธ ๋) ๋ ์ด์ค ์ปจ๋์
(Race Condition)์ผ๋ก ์ธํ ์๊ฐ ์ฐจ ๊ณต๊ฒฉ(TOCTOU)์ ๋
ธ์ถ๋ ์ ์์ต๋๋ค.
+**Learning:** ๋จ์ผ ํ์ผ ๋ฎ์ด์ฐ๊ธฐ๋ผ๋ ๋์์ ์ฌ๋ฌ ํ๋ก์ธ์ค๋ ์ค๋ ๋๊ฐ ์ ๊ทผํ๋ ํ๊ฒฝ์์๋ ์์ ์ฑ์ ๋ณด์ฅํ๊ธฐ ์ํด ์์์ ์์
(Atomic Operation)์ ์ฌ์ฉํ๋ ๊ฒ์ด ํ์์ ์
๋๋ค.
+**Prevention:** `Files.move` ์ํ ์ ๊ธฐ๋ณธ์ ์ผ๋ก `StandardCopyOption.ATOMIC_MOVE`๋ฅผ ์๋ํ์ฌ ๊ต์ฒด์ ์์์ฑ์ ๋ณด์ฅํ๊ณ , ํ์ผ ์์คํ
์์ ์ง์ํ์ง ์๋ ๊ฒฝ์ฐ(`AtomicMoveNotSupportedException` ๋ฐ์ ์)์๋ง ๊ธฐ์กด ๋ฐฉ์(`REPLACE_EXISTING`)์ผ๋ก ํด๋ฐฑ(Fallback)ํ๋๋ก ๊ตฌํํ์ญ์์ค. ์ด๋ฅผ ํตํด ๊ฐ๋ฅํ ์ต๋ํ์ ์์์ฑ(Atomicity)์ ํ๋ณดํ ์ ์์ต๋๋ค.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index f52a1468..91a2ed01 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,
+ moveFile: (java.nio.file.Path, java.nio.file.Path, Array) -> Unit = { src, dest, options ->
+ Files.move(src, dest, *options)
+ }
+) {
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, arrayOf(StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING))
+ } catch (e: java.nio.file.AtomicMoveNotSupportedException) {
+ moveFile(tempPath, indexPath, arrayOf(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..9ecde88a 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -364,6 +364,21 @@ class MainTest {
assertTrue(leftoverTemp.isEmpty(), "temporary index file should be cleaned up on failure")
}
+ @Test
+ fun testAtomicMoveFallback() {
+ var fallbackCalled = false
+ write_index_file(tempDir, "content") { src, dest, options ->
+ if (options.contains(java.nio.file.StandardCopyOption.ATOMIC_MOVE)) {
+ throw java.nio.file.AtomicMoveNotSupportedException(src.toString(), dest.toString(), "Mocked")
+ } else {
+ fallbackCalled = true
+ java.nio.file.Files.move(src, dest, *options)
+ }
+ }
+ assertTrue(fallbackCalled, "Fallback move should be called when ATOMIC_MOVE is not supported")
+ assertEquals("content", File(tempDir, "index.html").readText())
+ }
+
@Test
fun testProcessDirReplacesIndexSymlinkWithoutTouchingTarget() {
val targetFile = File(tempDir, "target.txt")