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 @@ -83,3 +83,8 @@
**Vulnerability:** ์ •์  HTML ์ƒ์„ฑ ๋„๊ตฌ์—์„œ ๋งค๋ฒˆ ๋‹ค๋ฅธ Nonce๋ฅผ ๋™์ ์œผ๋กœ ์ƒ์„ฑํ•˜์—ฌ CSP์— ์ ์šฉํ•˜๋Š” ๊ฒƒ์€, ์บ์‹ฑ ํšจ์œจ์„ ์ €ํ•˜์‹œํ‚ฌ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์ •์  ๋ฐฐํฌ ํ™˜๊ฒฝ(์˜ˆ: GitHub Pages ๋“ฑ)์—์„œ ์˜ฌ๋ฐ”๋ฅธ ๋ณด์•ˆ ์ •์ฑ… ์ˆ˜๋ฆฝ์„ ๋ฐฉํ•ดํ•  ์ˆ˜ ์žˆ๋Š” ์•ˆํ‹ฐ ํŒจํ„ด์ž…๋‹ˆ๋‹ค.
**Learning:** ์ •์ ์œผ๋กœ ๊ณ ์ •๋œ ์ธ๋ผ์ธ ์Šคํƒ€์ผ์ด๋‚˜ ์Šคํฌ๋ฆฝํŠธ์—๋Š” ๋‚œ์ˆ˜ํ™”๋œ Nonce๋ณด๋‹ค ์ฝ˜ํ…์ธ  ์ž์ฒด์˜ ํ•ด์‹œ(SHA-256 ๋“ฑ)๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•˜๊ณ  ์ผ๊ด€๋œ ๋ฐฉ์‹์ž„์„ ๋ฐฐ์› ์Šต๋‹ˆ๋‹ค.
**Prevention:** ์ž๋™ ์ƒ์„ฑ๋˜๋Š” ์ •์  HTML์˜ ์ฝ˜ํ…์ธ  ๋ณด์•ˆ ์ •์ฑ…(CSP)์—๋Š” `style-src 'sha256-<HASH>'` ๋ฐฉ์‹์„ ์ ์šฉํ•˜๊ณ , `<style>` ํƒœ๊ทธ์—์„œ ๋ถˆํ•„์š”ํ•œ `nonce` ์†์„ฑ์„ ์ œ๊ฑฐํ•˜์—ฌ ๋ธŒ๋ผ์šฐ์ €์˜ ๋ฌด๊ฒฐ์„ฑ ๊ฒ€์ฆ ๊ธฐ๋Šฅ์„ ์ ๊ทน ํ™œ์šฉํ•˜์‹ญ์‹œ์˜ค.

## 2024-07-14 - [html4tree] ์›์ž์  ํŒŒ์ผ ๊ต์ฒด๋ฅผ ํ†ตํ•œ TOCTOU ์™„ํ™”
**Vulnerability:** ์ž„์‹œ ํŒŒ์ผ์„ `index.html`๋กœ ๊ต์ฒดํ•  ๋•Œ ์›์ž์ (Atomic) ์ด๋™์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์œผ๋ฉด, ํŒŒ์ผ ๊ต์ฒด ์ค‘๊ฐ„์— ์™ธ๋ถ€์—์„œ ์‹ฌ๋ณผ๋ฆญ ๋งํฌ๋กœ ๋ฎ์–ด์“ฐ๋Š” TOCTOU(Time-of-Check to Time-of-Use) ๊ณต๊ฒฉ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
**Learning:** `Files.move`๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ `StandardCopyOption.ATOMIC_MOVE`๋ฅผ ์‹œ๋„ํ•˜์—ฌ ๊ต์ฒด๊ฐ€ ์›์ž์ ์œผ๋กœ ์ด๋ฃจ์–ด์ง€๋„๋ก ๋ณด์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํŒŒ์ผ ์‹œ์Šคํ…œ์ด ์ด๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์˜ˆ์™ธ๋ฅผ ์žก์•„ `REPLACE_EXISTING`์œผ๋กœ ์šฐ์•„ํ•˜๊ฒŒ ํด๋ฐฑ(fallback)ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
**Prevention:** ํŒŒ์ผ ์“ฐ๊ธฐ ์ž‘์—… ํ›„ ๋ฎ์–ด์“ฐ๊ธฐ๋ฅผ ํ•  ๋•Œ๋Š” ๊ฐ€๋Šฅํ•œ ํ•œ `StandardCopyOption.ATOMIC_MOVE` ์˜ต์…˜์„ ํ™œ์šฉํ•˜์—ฌ ๊ฒฝ์Ÿ ์กฐ๊ฑด(Race condition)์„ ๋ฐฉ์ง€ํ•˜์‹ญ์‹œ์˜ค.
39 changes: 31 additions & 8 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,14 +63,20 @@ internal fun crawl_directories(
processDirectory: (File, Set<String>, Array<File>?) -> Unit = { file, exclude, files -> process_dir(file, exclude, files) },
processIgnoreFile: (File, Array<String>?) -> Set<String> = { file, names -> process_ignore_file(file, names) },
listFiles: (File) -> Array<File>? = { it.listFiles() },
isDirectory: (File) -> Boolean = { Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS) },
isSymbolicLink: (File) -> Boolean = { Files.isSymbolicLink(it.toPath()) },
readAttributes: (File) -> BasicFileAttributes? = { file ->
try {
Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS)
} catch (e: Exception) {
null
}
},
readIdentity: (File) -> FileIdentity = ::read_file_identity
) {
var lle: LinkedListEntry? = ll.pull()

while(lle != null){
if (!isDirectory(lle.file)) {
val rootAttrs = readAttributes(lle.file)
if (rootAttrs == null || !rootAttrs.isDirectory) {
lle = ll.pull()
continue
}
Expand All @@ -94,9 +101,12 @@ internal fun crawl_directories(
dirFiles?.forEach {
// โšก Bolt Performance Optimization: Short-circuit OS stat calls (isDirectory/isSymbolicLink)
// by checking cheap in-memory string exclusion rules first
if(!it.name.startsWith(".") && it.name !in exclude && isDirectory(it) && !isSymbolicLink(it)) {
val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key)
ll.push(childEntry)
if(!it.name.startsWith(".") && it.name !in exclude) {
val attrs = readAttributes(it)
if (attrs != null && attrs.isDirectory && !attrs.isSymbolicLink) {
val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key)
ll.push(childEntry)
}
}
}
}
Expand Down Expand Up @@ -229,12 +239,25 @@ 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,
moveAtomic: (java.nio.file.Path, java.nio.file.Path) -> Unit = { src, dst ->
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
},
moveFallback: (java.nio.file.Path, java.nio.file.Path) -> Unit = { src, dst ->
Files.move(src, dst, 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 {
moveAtomic(tempPath, indexPath)
} catch (e: AtomicMoveNotSupportedException) {
moveFallback(tempPath, indexPath)
}
} finally {
Files.deleteIfExists(tempPath)
}
Expand Down
89 changes: 79 additions & 10 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,7 @@ class MainTest {
processDirectory = { file, _, _ -> processed.add(file) },
processIgnoreFile = { _, _ -> emptySet() },
listFiles = { emptyArray() },
isDirectory = { true },
isSymbolicLink = { false },
readAttributes = { mockAttributes(isDir = true) },
readIdentity = { FileIdentity("after-swap", true) }
)

Expand All @@ -176,8 +175,7 @@ class MainTest {
processDirectory = { file, _, _ -> processed.add(file) },
processIgnoreFile = { _, _ -> emptySet() },
listFiles = { emptyArray() },
isDirectory = { true },
isSymbolicLink = { false },
readAttributes = { mockAttributes(isDir = true) },
readIdentity = { FileIdentity(null, false) }
)

Expand All @@ -200,8 +198,7 @@ class MainTest {
processDirectory = { file, _, _ -> processed.add(file) },
processIgnoreFile = { _, _ -> emptySet() },
listFiles = { file -> if (file == root) arrayOf(child) else emptyArray() },
isDirectory = { true },
isSymbolicLink = { false },
readAttributes = { mockAttributes(isDir = true) },
readIdentity = { file ->
val key = file.absolutePath
val callCount = callsByPath.getOrDefault(key, 0)
Expand Down Expand Up @@ -239,8 +236,7 @@ class MainTest {
processDirectory = { file, _, _ -> processed.add(file) },
processIgnoreFile = { _, _ -> emptySet() },
listFiles = { emptyArray() },
isDirectory = { it == directoryEntry },
isSymbolicLink = { false },
readAttributes = { file -> mockAttributes(isDir = file == directoryEntry) },
readIdentity = { FileIdentity("directory-key", true) }
)

Expand Down Expand Up @@ -444,6 +440,56 @@ class MainTest {
assertFalse(File(gitDir, "index.html").exists())
}

@Test
fun testWriteIndexFileAtomicMoveFallback() {
val dir = File(tempDir, "fallback")
dir.mkdir()
var fallbackCalled = false
write_index_file(
dir,
"content",
moveAtomic = { _, _ -> throw java.nio.file.AtomicMoveNotSupportedException("src", "dst", "not supported") },
moveFallback = { src, dst ->
fallbackCalled = true
java.nio.file.Files.move(src, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
)
assertTrue(fallbackCalled)
assertEquals("content", File(dir, "index.html").readText())
}

@Test
fun testWriteIndexFileAtomicMoveDefaultFallback() {
val dir = File(tempDir, "default-fallback")
dir.mkdir()
write_index_file(
dir,
"content",
moveAtomic = { _, _ -> throw java.nio.file.AtomicMoveNotSupportedException("src", "dst", "not supported") }
// Omitting moveFallback to trigger the default implementation
)
assertEquals("content", File(dir, "index.html").readText())
}

@Test
fun testWriteIndexFileAtomicMoveSuccess() {
val dir = File(tempDir, "success")
dir.mkdir()
var fallbackCalled = false
write_index_file(
dir,
"content",
moveAtomic = { src, dst ->
java.nio.file.Files.move(src, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
},
moveFallback = { _, _ ->
fallbackCalled = true
}
)
assertFalse(fallbackCalled)
assertEquals("content", File(dir, "index.html").readText())
}

@Test
fun testProcessDirHandlesNonDirectoryWithoutThrowing() {
val notADirectory = File(tempDir, "not-a-directory")
Expand Down Expand Up @@ -476,6 +522,16 @@ class MainTest {
}
}

@Test
fun testCrawlDirectoriesReadAttributesExceptionCoverage() {
val ll = LinkedList()
val entry = LinkedListEntry(File(tempDir, "does-not-exist"), 0)
ll.push(entry)
crawl_directories(ll, -1)
// Ensure it doesn't crash and pulls the entry
assertNull(ll.pull())
}

@Test
fun testCliParsing() {
val cli = Html4tree()
Expand Down Expand Up @@ -698,12 +754,25 @@ class MainTest {
listed = true
emptyArray()
},
isDirectory = { true },
isSymbolicLink = { false },
readAttributes = { mockAttributes(isDir = true) },
readIdentity = { FileIdentity("current-key", true) }
)

assertFalse(processed, "fileKey mismatch should skip directory processing")
assertFalse(listed, "fileKey mismatch should skip child listing")
}

private fun mockAttributes(isDir: Boolean): java.nio.file.attribute.BasicFileAttributes {
return object : java.nio.file.attribute.BasicFileAttributes {
override fun lastModifiedTime() = java.nio.file.attribute.FileTime.fromMillis(0)
override fun lastAccessTime() = java.nio.file.attribute.FileTime.fromMillis(0)
override fun creationTime() = java.nio.file.attribute.FileTime.fromMillis(0)
override fun isRegularFile() = !isDir
override fun isDirectory() = isDir
override fun isSymbolicLink() = false
override fun isOther() = false
override fun size() = 0L
override fun fileKey() = "mock-key"
}
}
}
Loading