From 57a1ba075e6109356d4a6b3f1856cd5d769aaf19 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:02:08 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20index.html=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20=EC=8B=9C=20TOCTOU=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=99=84=ED=99=94=20(Atomic=20Move)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 구현에서는 `Files.move`를 통해 임시 파일을 `index.html`로 교체할 때 `StandardCopyOption.REPLACE_EXISTING`만 사용하여, 다른 프로세스에 의한 레이스 컨디션(TOCTOU)에 노출될 위험이 있었습니다. 이 커밋은 가능한 경우 `StandardCopyOption.ATOMIC_MOVE`를 사용하여 파일 교체의 원자성을 보장하도록 수정합니다. 원자적 이동을 지원하지 않는 환경(예: 일부 Docker overlayfs)을 고려하여, `AtomicMoveNotSupportedException` 발생 시 기존의 일반 파일 덮어쓰기 방식으로 안전하게 폴백(fallback)하는 로직을 추가했습니다. 함수에 의존성 주입을 위한 파라미터를 추가하여 100% 테스트 커버리지를 유지합니다. --- .jules/sentinel.md | 5 ++++ src/main/kotlin/html4tree/main.kt | 12 +++++++-- src/test/kotlin/html4tree/MainTest.kt | 36 +++++++++++++++++++-------- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index cdf88010..d6de1fe1 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-05 - [html4tree] index.html 교체 시 TOCTOU 방지 +**Vulnerability:** 기존 `index.html`을 교체할 때 `StandardCopyOption.REPLACE_EXISTING`만 사용하면, 교체되는 순간(TOCTOU)에 다른 프로세스가 파일에 접근하거나 쓰기를 시도할 수 있습니다. +**Learning:** 파일 교체 작업은 시스템에서 지원하는 경우 원자적(Atomic)으로 이루어져야 중간 상태가 노출되지 않으며, 파일 교체로 인한 레이스 컨디션을 방지할 수 있습니다. +**Prevention:** `Files.move` 시 `StandardCopyOption.ATOMIC_MOVE`를 사용하되, 이를 지원하지 않는 파일 시스템(예: 특정 Docker 환경의 overlayfs)을 위해 `AtomicMoveNotSupportedException` 발생 시 일반 교체로 폴백(Fallback)하도록 구현하십시오. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index e93fbea7..7385439a 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -300,12 +300,20 @@ 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 { + 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..1ceab699 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -346,20 +346,36 @@ class MainTest { assertTrue(htmlContent.contains("margin: 0 auto;")) } + @Test + fun testWriteIndexFileFallbackOnAtomicMoveNotSupported() { + var fallbackCalled = false + val mockMove: (java.nio.file.Path, java.nio.file.Path, Array) -> java.nio.file.Path = { src, dest, options -> + if (options.contains(java.nio.file.StandardCopyOption.ATOMIC_MOVE)) { + throw java.nio.file.AtomicMoveNotSupportedException(src.toString(), dest.toString(), "Mocked") + } + fallbackCalled = true + java.nio.file.Files.move(src, dest, *options) + } + + write_index_file(tempDir, "test content", mockMove) + + assertTrue(fallbackCalled, "Fallback to regular move was not called") + val indexFile = File(tempDir, "index.html") + assertTrue(indexFile.exists()) + assertEquals("test content", indexFile.readText()) + } + @Test fun testWriteIndexFileCleansUpTempFileOnFailure() { - // Files.move cannot replace a non-empty directory, so this drives the - // exception path through write_index_file's finally block. - val indexDir = File(tempDir, "index.html") - indexDir.mkdir() - File(indexDir, "occupant.txt").writeText("keep") - - assertFailsWith { - write_index_file(tempDir, "content") + // Using mock move to simulate a failure and cover the default moveFile fallback logic + val mockMoveFails: (java.nio.file.Path, java.nio.file.Path, Array) -> java.nio.file.Path = { src, dest, options -> + throw java.io.IOException("Mock IO Exception") + } + + assertFailsWith { + write_index_file(tempDir, "content", mockMoveFails) } - assertTrue(indexDir.isDirectory) - assertEquals("keep", File(indexDir, "occupant.txt").readText()) val leftoverTemp = tempDir.listFiles()?.filter { it.name.startsWith(".index-") } ?: emptyList() assertTrue(leftoverTemp.isEmpty(), "temporary index file should be cleaned up on failure") } From b27c50fff264f1810a357ed70056d55aa0e937eb Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:58:12 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20index.html=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=9B=90=EC=9E=90?= =?UTF-8?q?=EC=A0=81=20=ED=8C=8C=EC=9D=BC=20=EA=B5=90=EC=B2=B4(Crash-consi?= =?UTF-8?q?stency)=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 커밋은 `index.html` 파일을 재생성할 때 파일 교체 과정의 무결성을 보장하기 위해 도입되었습니다. 기존의 덮어쓰기(`REPLACE_EXISTING`) 방식 대신 `StandardCopyOption.ATOMIC_MOVE` 옵션을 활용하여 특정 파일 시스템 환경에서 발생할 수 있는 충돌이나 부분적인 파일 쓰기 문제를 방지(Crash-consistent best-effort)하도록 개선했습니다. 또한 특정 컨테이너 환경이나 파일 시스템 등 원자적 이동을 지원하지 않는 경우 `AtomicMoveNotSupportedException`이 발생하며, 이때 기존 덮어쓰기 방식으로 안전하게 폴백(Fallback) 처리하여 호환성을 확보했습니다. 관련하여 기존 실패 복구 테스트를 복원하고, 새 로직에 대한 테스트 케이스 및 관련 문서를 추가했습니다. --- CHANGELOG.md | 3 ++ docs/doctoring.md | 1 + src/test/kotlin/html4tree/MainTest.kt | 67 ++++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 docs/doctoring.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 34310f4b..5b06d8e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,3 +19,6 @@ All notable changes to this project are documented in this file. - Record the CSP byte-identity decision, threat boundary, verification contract, and current W3C Working Draft reference in `docs/doctoring`. + +### Security +- Enhance crash-consistency of `index.html` generation by using `StandardCopyOption.ATOMIC_MOVE` on supporting filesystems (falling back to standard replacement), protecting against Time-of-Check to Time-of-Use (TOCTOU) file corruption. diff --git a/docs/doctoring.md b/docs/doctoring.md new file mode 100644 index 00000000..ff8055d4 --- /dev/null +++ b/docs/doctoring.md @@ -0,0 +1 @@ +The use of `StandardCopyOption.ATOMIC_MOVE` when regenerating `index.html` leverages Java NIO provider semantics to ensure crash-consistent best-effort publication. This prevents partial file states or corruption if the generator is interrupted or if there is concurrent access. If the underlying filesystem provider rejects the atomic option (e.g., across mount points or in specific Docker environments), the implementation gracefully falls back to a standard `REPLACE_EXISTING` move. While this fallback does not provide atomic guarantees, it maintains basic compatibility where strict atomicity cannot be enforced. diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 1ceab699..97bc3d00 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -347,11 +347,11 @@ class MainTest { } @Test - fun testWriteIndexFileFallbackOnAtomicMoveNotSupported() { + fun testWriteIndexFileFallbackSuccessful() { var fallbackCalled = false val mockMove: (java.nio.file.Path, java.nio.file.Path, Array) -> java.nio.file.Path = { src, dest, options -> if (options.contains(java.nio.file.StandardCopyOption.ATOMIC_MOVE)) { - throw java.nio.file.AtomicMoveNotSupportedException(src.toString(), dest.toString(), "Mocked") + throw java.nio.file.AtomicMoveNotSupportedException(src.toString(), dest.toString(), "Simulated provider rejection") } fallbackCalled = true java.nio.file.Files.move(src, dest, *options) @@ -359,25 +359,78 @@ class MainTest { write_index_file(tempDir, "test content", mockMove) - assertTrue(fallbackCalled, "Fallback to regular move was not called") + assertTrue(fallbackCalled, "Fallback should occur when Atomic Move fails") val indexFile = File(tempDir, "index.html") assertTrue(indexFile.exists()) assertEquals("test content", indexFile.readText()) } @Test - fun testWriteIndexFileCleansUpTempFileOnFailure() { - // Using mock move to simulate a failure and cover the default moveFile fallback logic + fun testWriteIndexFileTempPlacementInSameDirectory() { + var tempFileDir: java.io.File? = null + val mockMove: (java.nio.file.Path, java.nio.file.Path, Array) -> java.nio.file.Path = { src, dest, options -> + tempFileDir = src.toFile().parentFile + java.nio.file.Files.move(src, dest, *options) + } + + write_index_file(tempDir, "temp dir content", mockMove) + + assertTrue(tempFileDir != null, "temp file dir should not be null") + assertEquals(tempDir.absolutePath, tempFileDir!!.absolutePath, "Temporary file must be created in the target directory to support atomic moves") + } + + @Test + fun testWriteIndexFileAtomicMoveSuccess() { + var atomicUsed = false + val mockMove: (java.nio.file.Path, java.nio.file.Path, Array) -> java.nio.file.Path = { src, dest, options -> + if (options.contains(java.nio.file.StandardCopyOption.ATOMIC_MOVE)) { + atomicUsed = true + } + java.nio.file.Files.move(src, dest, *options) + } + + write_index_file(tempDir, "atomic content", mockMove) + + assertTrue(atomicUsed, "Atomic move option should be used by default") + val indexFile = File(tempDir, "index.html") + assertTrue(indexFile.exists()) + assertEquals("atomic content", indexFile.readText()) + } + + @Test + fun testWriteIndexFileFallbackFailureCleansTempAndPreservesTarget() { + val targetIndex = File(tempDir, "index.html") + targetIndex.writeText("original target") + val mockMoveFails: (java.nio.file.Path, java.nio.file.Path, Array) -> java.nio.file.Path = { src, dest, options -> - throw java.io.IOException("Mock IO Exception") + if (options.contains(java.nio.file.StandardCopyOption.ATOMIC_MOVE)) { + throw java.nio.file.AtomicMoveNotSupportedException(src.toString(), dest.toString(), "Simulated provider rejection") + } + throw java.io.IOException("Fallback simulated IO failure") } assertFailsWith { - write_index_file(tempDir, "content", mockMoveFails) + write_index_file(tempDir, "failed content", mockMoveFails) + } + + assertEquals("original target", targetIndex.readText(), "Target should be preserved on fallback failure") + val leftoverTemp = tempDir.listFiles()?.filter { it.name.startsWith(".index-") } ?: emptyList() + assertTrue(leftoverTemp.isEmpty(), "temporary index file should be cleaned up on failure") + } + + @Test + fun testWriteIndexFileCleansUpTempFileOnFailure() { + val indexDir = File(tempDir, "index.html") + indexDir.mkdir() + File(indexDir, "occupant.txt").writeText("keep") + + assertFailsWith { + write_index_file(tempDir, "content") } val leftoverTemp = tempDir.listFiles()?.filter { it.name.startsWith(".index-") } ?: emptyList() assertTrue(leftoverTemp.isEmpty(), "temporary index file should be cleaned up on failure") + assertEquals("keep", File(indexDir, "occupant.txt").readText()) } @Test From f912a7c3e8a90147a3eac548f25d5d8ed805ae7a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:43:18 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20index.html=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=9B=90=EC=9E=90?= =?UTF-8?q?=EC=A0=81=20=ED=8C=8C=EC=9D=BC=20=EA=B5=90=EC=B2=B4(Crash-consi?= =?UTF-8?q?stency)=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 커밋은 `index.html` 파일을 재생성할 때 파일 교체 과정의 무결성을 보장하기 위해 도입되었습니다. 기존의 덮어쓰기(`REPLACE_EXISTING`) 방식 대신 `StandardCopyOption.ATOMIC_MOVE` 옵션을 활용하여 특정 파일 시스템 환경에서 발생할 수 있는 충돌이나 부분적인 파일 쓰기 문제를 방지(Crash-consistent best-effort)하도록 개선했습니다. 또한 특정 컨테이너 환경이나 파일 시스템 등 원자적 이동을 지원하지 않는 경우 `AtomicMoveNotSupportedException`이 발생하며, 이때 기존 덮어쓰기 방식으로 안전하게 폴백(Fallback) 처리하여 호환성을 확보했습니다. 관련하여 기존 실패 복구 테스트를 복원하고, 새 로직에 대한 테스트 케이스 및 관련 문서를 추가했습니다.