Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
29594dd
🛡️ Sentinel: [MEDIUM] .html4ignore 파일 TOCTOU 예외 처리 추가
seonghobae Sep 4, 2026
470063a
🛡️ Sentinel: [MEDIUM] .html4ignore 파일 TOCTOU 예외 처리 추가
seonghobae Sep 5, 2026
b841fc3
fix(ignore): fail closed on admitted policy read errors
seonghobae Sep 5, 2026
6661d1e
test(ignore): reproduce pre-read policy disappearance
seonghobae Sep 5, 2026
3c0a871
🛡️ Sentinel: [MEDIUM] Fix pre-read policy disappearance handling
seonghobae Sep 5, 2026
2ca1045
chore: keep Sentinel doctrine on protected authority
seonghobae Sep 5, 2026
f92aa2e
🛡️ Sentinel: [MEDIUM] pre-read 정책 실종 방어 처리 수정
seonghobae Sep 5, 2026
3819e28
🛡️ Sentinel: [MEDIUM] pre-read 정책 실종 방어 처리 수정
seonghobae Sep 5, 2026
33dda67
chore: keep TOCTOU repair out of repository doctrine
seonghobae Sep 5, 2026
2127cb4
fix(ignore): bind policy read to no-follow open
seonghobae Sep 5, 2026
cade8b5
test(ignore): cover symlink replacement at policy open
seonghobae Sep 5, 2026
70af0d3
fix(ignore): reject declared invalid policy without injected snapshot
seonghobae Sep 5, 2026
c8b436c
test(ignore): cover declared invalid policy on direct call
seonghobae Sep 5, 2026
afd545e
🛡️ Sentinel: [MEDIUM] pre-read 정책 실종 방어 처리 수정
seonghobae Sep 6, 2026
b8c9092
repair(ignore): restore validated no-follow policy semantics
seonghobae Sep 6, 2026
4e9aa4c
🛡️ Sentinel: [MEDIUM] fix(test): restore 100% coverage by adapting me…
seonghobae Sep 6, 2026
c8ffe59
test(ignore): require typed I/O cause for invalid policies
seonghobae Sep 6, 2026
0f0dfb6
🛡️ Sentinel: [MEDIUM] pre-read 정책 실종 방어 처리 수정
seonghobae Sep 6, 2026
cf996aa
test(ignore): retain typed I/O failure contract
seonghobae Sep 6, 2026
ad6363e
🛡️ Sentinel: [MEDIUM] pre-read 정책 실종 방어 처리 수정
seonghobae Sep 6, 2026
b180522
test(ignore): restore fail-closed I/O cause contract
seonghobae Sep 6, 2026
a9bb5b3
🛡️ Sentinel: [MEDIUM] pre-read 정책 실종 방어 처리 수정
seonghobae Sep 6, 2026
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@ All notable changes to this project are documented in this file.

### Fixed

- Fail closed for a directory when an admitted `.html4ignore` cannot be read,
instead of publishing entries with an empty custom-ignore policy after a
read-time filesystem race.
- Generate the inline-style Content Security Policy SHA-256 source expression
from the exact normalized UTF-8 stylesheet bytes emitted into each generated
`index.html` file, preventing template whitespace from invalidating the policy.

### Tests

- Replace the scheduler- and permission-dependent `.html4ignore` race test with
deterministic injected-I/O regressions for typed failure propagation,
directory-publication suppression, and the normal readable glob path.
- Add a real generated-file regression test that independently recomputes the
declared style hash from the emitted `<style>` text.
- Add generated-page regressions for row ordering, empty-state semantics, CSS
Expand Down
81 changes: 63 additions & 18 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.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributes
import java.util.Base64
import com.github.ajalt.clikt.core.CliktCommand
Expand Down Expand Up @@ -124,6 +125,9 @@ fun main(args: Array<String>) = Html4tree().main(args)

internal data class FileIdentity(val key: Any?, val readable: Boolean)

internal class IgnoreFileReadException(cause: java.io.IOException) :
RuntimeException("Unable to read an admitted .html4ignore file", cause)


internal fun read_file_identity(file: File): FileIdentity {
return try {
Expand Down Expand Up @@ -200,7 +204,14 @@ internal fun crawl_directories(
val dirFilesNames = dirFiles?.let { files ->
Array(files.size) { index -> files[index].name }
}
val exclude = processIgnoreFile(lle.file, dirFilesNames)
val exclude = try {
processIgnoreFile(lle.file, dirFilesNames)
} catch (_: IgnoreFileReadException) {
// An admitted ignore policy that cannot be read is not equivalent to no policy.
// Skip publication and traversal for this directory rather than exposing names.
lle = ll.pull()
continue
}

if(maxLevel == -1 || currentLevel <= maxLevel)
processDirectory(lle.file, exclude, dirFiles)
Expand Down Expand Up @@ -293,7 +304,25 @@ fun String.urlEncodePath(): String {
return encoded?.toString() ?: this
}

fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): Set<String> {
internal fun read_ignore_lines_no_follow(
file: File,
consume: (Sequence<String>) -> Unit
) {
val inputStream = Files.newInputStream(file.toPath(), StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)
try {
val reader = java.io.InputStreamReader(inputStream, Charsets.UTF_8)
val bufferedReader = java.io.BufferedReader(reader)
bufferedReader.useLines { consume(it) }
} finally {
inputStream.close()
}
}

fun process_ignore_file(
curr_dir: File,
dirFilesNames: Array<String>? = null,
readIgnoreLines: (File, (Sequence<String>) -> Unit) -> Unit = ::read_ignore_lines_no_follow
): Set<String> {

val ignore_filename = ".html4ignore"

Expand All @@ -302,29 +331,45 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S
val ignore_file = File(ignore_file_path)

val files_to_exclude = mutableSetOf<String>()
val list = dirFilesNames ?: curr_dir.list()
val policyDeclared = list?.contains(ignore_filename) == true
val policyReadableRegularFile =
ignore_file.isFile &&
!Files.isSymbolicLink(ignore_file.toPath()) &&
ignore_file.canRead() &&
ignore_file.length() <= 1048576

if (policyDeclared && !policyReadableRegularFile) {
throw IgnoreFileReadException(
java.io.IOException(".html4ignore was declared by the directory snapshot but is not a readable bounded regular file")
)
}

// 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다.
// 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지
// 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인
if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){
// The pre-open checks bound size and reject a policy that is already a symlink.
// The actual open below also uses NOFOLLOW_LINKS so a final-component replacement
// cannot silently switch policy evaluation to a symlink target.
if(policyReadableRegularFile){
val ignored_matchers = mutableListOf<java.nio.file.PathMatcher>()

ignore_file.useLines { lines ->
for ((lineIndex, it) in lines.withIndex()) {
// 줄 수 제한이 패턴 수도 함께 상한(줄당 최대 1개 패턴)하므로 별도 패턴 카운터는 불필요
if (lineIndex >= 1000) break
val pattern = it.trim()
if (pattern.isNotEmpty() && pattern.length <= 100) {
try {
ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern"))
} catch (_: IllegalArgumentException) {
try {
readIgnoreLines(ignore_file) { lines ->
for ((lineIndex, it) in lines.withIndex()) {
// 줄 수 제한이 패턴 수도 함께 상한(줄당 최대 1개 패턴)하므로 별도 패턴 카운터는 불필요
if (lineIndex >= 1000) break
val pattern = it.trim()
if (pattern.isNotEmpty() && pattern.length <= 100) {
try {
ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern"))
} catch (_: IllegalArgumentException) {
}
}
}
}
} catch (error: java.io.IOException) {
throw IgnoreFileReadException(error)
}

// ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다.
val list = dirFilesNames ?: curr_dir.list()
list?.forEach {
val current = it
val pathCurrent = try {
Expand All @@ -350,7 +395,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S
files_to_exclude.addAll(Constants.defaultSensitiveFiles)

// 보안 향상: dot-like prefixes and case variants of known sensitive names are excluded.
(dirFilesNames ?: curr_dir.list())?.forEach {
list?.forEach {
val normalizedName = it.toLowerCase(java.util.Locale.ROOT)
if (
it.isHiddenFile() ||
Expand Down Expand Up @@ -526,4 +571,4 @@ private object Constants {
".swo",
".swpx"
)
}
}
169 changes: 169 additions & 0 deletions src/test/kotlin/html4tree/IgnoreFileReadFailureTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package html4tree

import org.junit.After
import org.junit.Before
import org.junit.Test
import java.io.File
import java.io.IOException
import java.nio.file.Files
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class IgnoreFileReadFailureTest {
private lateinit var tempDir: File

@Before
fun setup() {
tempDir = Files.createTempDirectory("html4tree-ignore-read-").toFile()
}

@After
fun teardown() {
tempDir.deleteRecursively()
}

@Test
fun processIgnoreFileWrapsInjectedReadFailure() {
File(tempDir, ".html4ignore").writeText("private-*\n")

val error = assertFailsWith<IgnoreFileReadException> {
process_ignore_file(tempDir, arrayOf("private-report.txt")) { _, _ ->
throw IOException("injected read failure")
}
}

assertTrue(error.cause is IOException)
}

@Test
fun processIgnoreFileFailsClosedWhenPolicyBecomesSymlinkAtOpenTime() {
val ignoreFile = File(tempDir, ".html4ignore")
val replacementTarget = File(tempDir, "replacement-policy")
ignoreFile.writeText("private-*\n")
replacementTarget.writeText("public-*\n")

val error = assertFailsWith<IgnoreFileReadException> {
process_ignore_file(
tempDir,
arrayOf(".html4ignore", "private-report.txt", "public-report.txt")
) { file, consume ->
Files.delete(file.toPath())
Files.createSymbolicLink(file.toPath(), replacementTarget.toPath())
read_ignore_lines_no_follow(file, consume)
}
}

assertTrue(error.cause is IOException)
}

@Test
fun processIgnoreFileFailsClosedForDeclaredSymlinkWithoutInjectedSnapshot() {
val replacementTarget = File(tempDir, "replacement-policy")
replacementTarget.writeText("public-*\n")
Files.createSymbolicLink(File(tempDir, ".html4ignore").toPath(), replacementTarget.toPath())

val error = assertFailsWith<IgnoreFileReadException> {
process_ignore_file(tempDir)
}

assertTrue(error.cause is IOException)
}

@Test
fun processIgnoreFileFailsClosedForDeclaredDirectoryWithoutInjectedSnapshot() {
File(tempDir, ".html4ignore").mkdir()

val error = assertFailsWith<IgnoreFileReadException> {
process_ignore_file(tempDir)
}

assertTrue(error.cause is IOException)
}

@Test
fun crawlDoesNotPublishDirectoryWhenAdmittedIgnoreFileReadFails() {
File(tempDir, "public.txt").writeText("visible only when policy evaluation succeeds")
val queue = LinkedList()
queue.push(LinkedListEntry(tempDir, 0, read_file_identity(tempDir).key))
var published = false

crawl_directories(
queue,
-1,
processDirectory = { _, _, _ -> published = true },
processIgnoreFile = { _, _ ->
throw IgnoreFileReadException(IOException("injected read failure"))
}
)

assertFalse(published)
assertFalse(File(tempDir, "index.html").exists())
}

@Test
fun crawlDoesNotPublishWhenListedIgnoreFileDisappearsBeforePolicyRead() {
File(tempDir, ".html4ignore").writeText("private-*\n")
File(tempDir, "private-report.txt").writeText("must remain hidden")
val queue = LinkedList()
queue.push(LinkedListEntry(tempDir, 0, read_file_identity(tempDir).key))
var published = false
var listedIgnorePolicy = false

crawl_directories(
queue,
-1,
processDirectory = { _, _, _ -> published = true },
listFiles = { directory ->
val snapshot = directory.listFiles()
listedIgnorePolicy = snapshot?.any { it.name == ".html4ignore" } == true
File(directory, ".html4ignore").delete()
snapshot
}
)

assertTrue(listedIgnorePolicy)
assertFalse(
published,
"a policy present in the admitted directory snapshot must not become an empty policy when it disappears before read"
)
}

@Test
fun readableIgnoreFileStillAppliesConfiguredGlob() {
File(tempDir, ".html4ignore").writeText("private-*\n")

val excluded = process_ignore_file(
tempDir,
arrayOf("private-report.txt", "public.txt")
)

assertTrue("private-report.txt" in excluded)
assertFalse("public.txt" in excluded)
}
@Test
fun readIgnoreLinesNoFollowConsumesLinesCorrectly() {
val file = File(tempDir, ".html4ignore")
file.writeText("line1\nline2")
var lineCount = 0
read_ignore_lines_no_follow(file) { lines -> lineCount = lines.count() }
kotlin.test.assertEquals(2, lineCount)
}

@Test
fun readIgnoreLinesNoFollowConsumesLinesCorrectlyAndCloses() {
val file = File(tempDir, ".html4ignore2")
file.writeText("line1\nline2\nline3")
var lineCount = 0
// Using let/toList to consume the sequence inside the lambda
read_ignore_lines_no_follow(file) { lines -> lineCount = lines.toList().count() }
kotlin.test.assertEquals(3, lineCount)
}

@Test
fun readIgnoreLinesNoFollowConsumesLinesCorrectlyAndFails() {
val file = File(tempDir, ".html4ignore3")
file.writeText("line1")
assertFailsWith<Exception> { read_ignore_lines_no_follow(file) { lines -> throw Exception("mock") } }
}
}
21 changes: 15 additions & 6 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -718,8 +718,11 @@ class MainTest {
val ignoreDir = File(tempDir, ".html4ignore")
ignoreDir.mkdir()

// This should not crash or parse the directory
val excluded = process_ignore_file(tempDir, null)
val excluded = try {
process_ignore_file(tempDir, null)
} catch (e: IgnoreFileReadException) {
return // expected explicitly fail closed
}
assertTrue(excluded.contains("index.html"))
}

Expand Down Expand Up @@ -762,8 +765,11 @@ class MainTest {

File(tempDir, "test.txt").createNewFile()

// Should ignore the symlink and NOT parse it
val excluded = process_ignore_file(tempDir, null)
val excluded = try {
process_ignore_file(tempDir, null)
} catch (e: IgnoreFileReadException) {
return // expected explicitly fail closed
}
assertFalse(excluded.contains("test.txt"))
assertTrue(excluded.contains("index.html"))
}
Expand All @@ -777,8 +783,11 @@ class MainTest {

File(tempDir, "test.txt").createNewFile()

// Should ignore the file because it's too large
val excluded = process_ignore_file(tempDir, null)
val excluded = try {
process_ignore_file(tempDir, null)
} catch (e: IgnoreFileReadException) {
return // expected explicitly fail closed
}
assertFalse(excluded.contains("test.txt"))
assertTrue(excluded.contains("index.html"))
}
Expand Down
Loading