From ee3cb6228daf406c5f1dffdba2695bc3b92349e6 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Mon, 10 Aug 2026 20:55:56 +0000
Subject: [PATCH 1/2] =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=96=A5=EC=83=81:=20?=
=?UTF-8?q?=EC=9B=90=EC=9E=90=EC=A0=81=20=ED=8C=8C=EC=9D=BC=20=EC=93=B0?=
=?UTF-8?q?=EA=B8=B0(Atomic=20File=20Move)=EB=A5=BC=20=ED=86=B5=ED=95=9C?=
=?UTF-8?q?=20TOCTOU=20=EB=B0=8F=20=EB=B6=88=EC=99=84=EC=A0=84=ED=95=9C=20?=
=?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=85=B8=EC=B6=9C=20=EB=B0=A9=EC=A7=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/sentinel.md | 5 +++++
src/main/kotlin/html4tree/main.kt | 13 +++++++++++--
src/test/kotlin/html4tree/MainTest.kt | 22 ++++++++++++++++++++++
3 files changed, 38 insertions(+), 2 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index cdf88010..46d98bcc 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-10 - [html4tree] 원자적 파일 쓰기(Atomic File Move)를 통한 TOCTOU 방지
+**Vulnerability:** 파일 쓰기 중간에 충돌이 발생하거나 파일 교체 중 불완전한 상태가 노출되는 취약점 및 TOCTOU(Time-of-Check to Time-of-Use) 취약점
+**Learning:** 파일을 쓸 때 임시 파일에 먼저 쓰고 `Files.move`를 사용할 때 `StandardCopyOption.ATOMIC_MOVE`를 사용하지 않으면, 파일 교체 중간 상태가 노출되거나 다른 프로세스가 해당 파일을 참조할 때 불완전한 상태일 수 있습니다. 환경에 따라(예: Docker의 overlayfs 등) Atomic Move를 지원하지 않을 수 있으므로, `AtomicMoveNotSupportedException` 발생 시 기본 교체로 폴백(fallback)하는 방어가 필요합니다.
+**Prevention:** 파일 업데이트 시 임시 파일에 내용을 완전히 쓴 후, `StandardCopyOption.ATOMIC_MOVE` 옵션과 함께 `Files.move`를 사용하여 대상 경로로 원자적으로 이동시키고 지원하지 않는 경우를 대비한 `try-catch` 폴백을 구현하십시오.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index 8942c047..eb5d9ccd 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -327,12 +327,21 @@ 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) -> java.nio.file.Path = { 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 {
+ // 보안 향상: TOCTOU(Time-of-Check to Time-of-Use) 및 불완전한 파일 쓰기를 방지하기 위해 Atomic Move를 시도합니다.
+ moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE))
+ } 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 179b6c5b..0a4da8da 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -8,6 +8,7 @@ import java.io.ByteArrayOutputStream
import java.io.File
import java.io.PrintStream
import java.nio.file.Files
+import java.nio.file.StandardCopyOption
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileTime
import kotlin.test.assertEquals
@@ -376,6 +377,27 @@ class MainTest {
assertTrue(leftoverTemp.isEmpty(), "temporary index file should be cleaned up on failure")
}
+ @Test
+ fun testWriteIndexFileAtomicFallback() {
+ var calledWithAtomic = false
+ var calledWithoutAtomic = false
+ val mockMove: (java.nio.file.Path, java.nio.file.Path, Array) -> java.nio.file.Path = { src, dest, options ->
+ if (options.contains(StandardCopyOption.ATOMIC_MOVE)) {
+ calledWithAtomic = true
+ throw java.nio.file.AtomicMoveNotSupportedException(src.toString(), dest.toString(), "Mocked")
+ } else {
+ calledWithoutAtomic = true
+ Files.move(src, dest, *options)
+ }
+ }
+
+ write_index_file(tempDir, "atomic fallback test", mockMove)
+
+ assertTrue(calledWithAtomic, "Should attempt atomic move first")
+ assertTrue(calledWithoutAtomic, "Should fallback to non-atomic move")
+ assertEquals("atomic fallback test", File(tempDir, "index.html").readText())
+ }
+
@Test
fun testProcessDirReplacesIndexSymlinkWithoutTouchingTarget() {
val targetFile = File(tempDir, "target.txt")
From c1d75ebe600b52661282719e5a7d79c12b4a6dd8 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Mon, 10 Aug 2026 21:26:16 +0000
Subject: [PATCH 2/2] =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=96=A5=EC=83=81:=20?=
=?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8C=85=20=EC=8B=9C=20=EB=B0=9C=EC=83=9D?=
=?UTF-8?q?=ED=95=98=EB=8A=94=20TOCTOU=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?=
=?UTF-8?q?=EC=99=84=ED=99=94=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=20=EC=93=B0?=
=?UTF-8?q?=EA=B8=B0=20=EC=9B=90=EC=9E=90=EC=A0=81(Atomic)=20=EC=B2=98?=
=?UTF-8?q?=EB=A6=AC=20=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/sentinel.md | 5 ++++
src/main/kotlin/html4tree/main.kt | 12 +++++++--
src/test/kotlin/html4tree/MainTest.kt | 37 +++++++++++++++++++++++++++
3 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index 46d98bcc..c08de43a 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -93,3 +93,8 @@
**Vulnerability:** 파일 쓰기 중간에 충돌이 발생하거나 파일 교체 중 불완전한 상태가 노출되는 취약점 및 TOCTOU(Time-of-Check to Time-of-Use) 취약점
**Learning:** 파일을 쓸 때 임시 파일에 먼저 쓰고 `Files.move`를 사용할 때 `StandardCopyOption.ATOMIC_MOVE`를 사용하지 않으면, 파일 교체 중간 상태가 노출되거나 다른 프로세스가 해당 파일을 참조할 때 불완전한 상태일 수 있습니다. 환경에 따라(예: Docker의 overlayfs 등) Atomic Move를 지원하지 않을 수 있으므로, `AtomicMoveNotSupportedException` 발생 시 기본 교체로 폴백(fallback)하는 방어가 필요합니다.
**Prevention:** 파일 업데이트 시 임시 파일에 내용을 완전히 쓴 후, `StandardCopyOption.ATOMIC_MOVE` 옵션과 함께 `Files.move`를 사용하여 대상 경로로 원자적으로 이동시키고 지원하지 않는 경우를 대비한 `try-catch` 폴백을 구현하십시오.
+
+## 2026-08-10 - [html4tree] 파일 리스팅 중 발생하는 심볼릭 링크 스왑 취약점(TOCTOU) 추가 대응
+**Vulnerability:** 디렉토리 속성 검사 직후부터 파일 리스팅 시점 사이에 악의적인 심볼릭 링크로 교체될 수 있는 TOCTOU 취약점.
+**Learning:** 크롤러가 파일을 큐에서 꺼내어 검사하는 과정과 실제 리스팅하는 과정 사이에 시간차가 발생하면, 공격자가 심볼릭 링크를 통해 의도되지 않은 디렉토리의 파일을 노출시킬 수 있습니다.
+**Prevention:** 큐에서 꺼내어 검사한 직후 리스팅(`listFiles`)을 수행한 다음, 다시 한번 디렉토리의 속성(`BasicFileAttributes.fileKey()`)을 검사하여 속성이 변경되었거나 접근이 불가해진 경우를 감지하고 작업을 취소해야 합니다.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index eb5d9ccd..75ab9c53 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -172,6 +172,14 @@ internal fun crawl_directories(
// ⚡ Bolt Performance Optimization: 디렉토리 목록을 캐싱하여 중복된 I/O 시스템 호출을 줄임
val dirFiles = listFiles(lle.file)
+
+ // 보안 향상: 디렉토리 목록을 가져온 후, 악의적인 심볼릭 링크로 교체되었는지 확인하여 TOCTOU 취약점을 방지합니다.
+ val postIdentity = readIdentity(lle.file)
+ if (!postIdentity.readable || currentIdentity.key != postIdentity.key) {
+ lle = ll.pull()
+ continue
+ }
+
val dirFilesNames = dirFiles?.map { it.name }?.toTypedArray()
val exclude = processIgnoreFile(lle.file, dirFilesNames)
@@ -338,9 +346,9 @@ fun write_index_file(
Files.write(tempPath, content.toByteArray(Charsets.UTF_8))
try {
// 보안 향상: TOCTOU(Time-of-Check to Time-of-Use) 및 불완전한 파일 쓰기를 방지하기 위해 Atomic Move를 시도합니다.
- moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE))
+ moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE))
} catch (e: java.nio.file.AtomicMoveNotSupportedException) {
- moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING))
+ 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 0a4da8da..b683f45d 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -739,4 +739,41 @@ class MainTest {
assertFalse(processed, "fileKey mismatch should skip directory processing")
assertFalse(listed, "fileKey mismatch should skip child listing")
}
+
+ @Test
+ fun testToctouSymlinkSwapRejectionPostListing() {
+ val subdir = File(tempDir, "toctou_test_dir_post")
+ subdir.mkdir()
+ val ll = LinkedList()
+ val entry = LinkedListEntry(subdir, 0)
+ entry.fileKey = "valid-key"
+ ll.push(entry)
+
+ var processed = false
+ var listCalled = false
+ var identityCallCount = 0
+
+ crawl_directories(
+ ll,
+ -1,
+ processDirectory = { _, _, _ -> processed = true },
+ processIgnoreFile = { _, _ -> emptySet() },
+ listFiles = {
+ listCalled = true
+ emptyArray()
+ },
+ readAttributes = { file -> createMockAttributes(isDir = true, isSymlink = false) },
+ readIdentity = {
+ identityCallCount++
+ if (identityCallCount == 1) {
+ FileIdentity("valid-key", true) // First check passes
+ } else {
+ FileIdentity("changed-key", true) // Second check fails (swapped)
+ }
+ }
+ )
+
+ assertTrue(listCalled, "listFiles should be called since the first check passes")
+ assertFalse(processed, "Directory processing should be skipped due to TOCTOU mitigation post-listing")
+ }
}