Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,8 @@
**Vulnerability:** CSP ํ•ด์‹œ ๋ถˆ์ผ์น˜๋กœ ์ธํ•œ ์ธ๋ผ์ธ ์Šคํƒ€์ผ ์ฐจ๋‹จ
**Learning:** ๋ธŒ๋ผ์šฐ์ €๋Š” ์ธ๋ผ์ธ ์Šคํฌ๋ฆฝํŠธ์™€ ์Šคํƒ€์ผ์˜ ๋‚ด๋ถ€ ํ…์ŠคํŠธ(๊ณต๋ฐฑ๊ณผ ์ค„๋ฐ”๊ฟˆ ํฌํ•จ)๋ฅผ ์ •ํ™•ํ•˜๊ฒŒ ํ•ด์‹ฑํ•˜์—ฌ Content-Security-Policy(CSP) ํ•ด์‹œ์™€ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค. Kotlin์˜ ๋ฉ€ํ‹ฐ๋ผ์ธ ๋ฌธ์ž์—ด(`"""`)์„ ์‚ฌ์šฉํ•˜์—ฌ ํ…œํ”Œ๋ฆฟ์— ์ฝ˜ํ…์ธ ๋ฅผ ์ฃผ์ž…ํ•  ๋•Œ ์•”๋ฌต์ ์ธ ์—ฌ๋ฐฑ์ด๋‚˜ ์ค„๋ฐ”๊ฟˆ์ด ์ถ”๊ฐ€๋˜๋ฉด ์ตœ์ข… HTML ๋ฌธ์ž์—ด์ด ๋ณ€๊ฒฝ๋˜์–ด CSP ํ•ด์‹œ๊ฐ€ ๋ฌดํšจํ™”๋ฉ๋‹ˆ๋‹ค.
**Prevention:** ์ฝ˜ํ…์ธ ๋ฅผ ํ•ด์‹ฑํ•˜๊ธฐ ์ „์— `.trimIndent()`๋ฅผ ์ ์šฉํ•˜์—ฌ ์›๋ณธ ๋ฌธ์ž์—ด์„ ์ •๊ทœํ™”ํ•˜๊ณ , HTML ํ…œํ”Œ๋ฆฟ์— ์ฃผ์ž…ํ•  ๋•Œ `<style>${exactContent}</style>`์™€ ๊ฐ™์ด ๊ณต๋ฐฑ ์—†์ด ์ฃผ์ž…ํ•˜์—ฌ ํ•ด์‹œ๊ฐ€ ์™„๋ฒฝํ•˜๊ฒŒ ์ผ์น˜ํ•˜๋„๋ก ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

## 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)ํ•˜๋„๋ก ๊ตฌํ˜„ํ•˜์‹ญ์‹œ์˜ค.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions docs/doctoring.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 10 additions & 2 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,20 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = 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<out java.nio.file.CopyOption>) -> 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)
}
Expand Down
77 changes: 73 additions & 4 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -346,10 +346,80 @@ class MainTest {
assertTrue(htmlContent.contains("margin: 0 auto;"))
}

@Test
fun testWriteIndexFileFallbackSuccessful() {
var fallbackCalled = false
val mockMove: (java.nio.file.Path, java.nio.file.Path, Array<out java.nio.file.CopyOption>) -> 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(), "Simulated provider rejection")
}
fallbackCalled = true
java.nio.file.Files.move(src, dest, *options)
}

write_index_file(tempDir, "test content", mockMove)

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 testWriteIndexFileTempPlacementInSameDirectory() {
var tempFileDir: java.io.File? = null
val mockMove: (java.nio.file.Path, java.nio.file.Path, Array<out java.nio.file.CopyOption>) -> 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<out java.nio.file.CopyOption>) -> 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<out java.nio.file.CopyOption>) -> 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(), "Simulated provider rejection")
}
throw java.io.IOException("Fallback simulated IO failure")
}

assertFailsWith<java.io.IOException> {
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() {
// 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")
Expand All @@ -358,10 +428,9 @@ class MainTest {
write_index_file(tempDir, "content")
}

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")
assertEquals("keep", File(indexDir, "occupant.txt").readText())
}

@Test
Expand Down
Loading