diff --git a/.jules/sentinel.md b/.jules/sentinel.md index cdf88010..cfdc6bf4 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-05-18 - [파일 이동 시 TOCTOU 취약점 완화] +**Vulnerability:** 애플리케이션이 `Files.move(tempPath, indexPath, StandardCopyOption.REPLACE_EXISTING)`를 사용하여 임시 파일을 덮어쓰는 과정에서 TOCTOU (Time-of-Check to Time-of-Use) 취약점이 발생할 수 있었습니다. +**Learning:** `REPLACE_EXISTING`은 기본적으로 원자적(atomic)이지 않습니다. 임시 파일이 생성되고 이동되는 사이에 다른 프로세스가 대상 파일이나 디렉토리 구조를 변경하면 상태 불일치나 예기치 않은 덮어쓰기가 발생할 수 있습니다. +**Prevention:** `StandardCopyOption.ATOMIC_MOVE`와 `StandardCopyOption.REPLACE_EXISTING`을 함께 사용하여 원자성을 보장해야 합니다. 단, CI의 overlayfs와 같이 모든 파일 시스템에서 `ATOMIC_MOVE`를 지원하는 것은 아니므로, 항상 `AtomicMoveNotSupportedException`을 `try-catch`로 처리하고 `REPLACE_EXISTING`으로 폴백하도록 구현해야 합니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index f52a1468..3edce8fc 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -5,6 +5,7 @@ import java.security.MessageDigest import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.StandardCopyOption +import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.attribute.BasicFileAttributes import java.util.Base64 import com.github.ajalt.clikt.core.CliktCommand @@ -311,12 +312,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) -> java.nio.file.Path = { src, dest -> Files.move(src, dest, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) }, + fallbackMoveFile: (java.nio.file.Path, java.nio.file.Path) -> java.nio.file.Path = { src, dest -> Files.move(src, dest, 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 { + moveFile(tempPath, indexPath) + } catch (e: AtomicMoveNotSupportedException) { + fallbackMoveFile(tempPath, indexPath) + } } finally { Files.deleteIfExists(tempPath) } diff --git a/src/test/kotlin/html4tree/AtomicMoveTest.kt b/src/test/kotlin/html4tree/AtomicMoveTest.kt new file mode 100644 index 00000000..146cef2d --- /dev/null +++ b/src/test/kotlin/html4tree/AtomicMoveTest.kt @@ -0,0 +1,26 @@ +package html4tree + +import org.junit.Test +import java.io.File +import kotlin.test.assertTrue +import java.nio.file.Files +import java.nio.file.AtomicMoveNotSupportedException + +class AtomicMoveTest { + @Test + fun testAtomicMoveNotSupported() { + val tempDir = Files.createTempDirectory("atomic_test").toFile() + try { + write_index_file( + tempDir, + "test content", + moveFile = { _, _ -> throw AtomicMoveNotSupportedException("source", "target", "Not supported") } + ) + val indexHtml = File(tempDir, "index.html") + assertTrue(indexHtml.exists()) + assertTrue(indexHtml.readText() == "test content") + } finally { + tempDir.deleteRecursively() + } + } +}