From 3167e2ca1eb22063b409342ec687227dfb11d2ea Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:16:50 +0000 Subject: [PATCH 01/10] Add max length validation for topDir to prevent DoS vulnerabilities --- .jules/sentinel.md | 4 ++++ src/main/kotlin/html4tree/main.kt | 1 + src/test/kotlin/html4tree/MainTest.kt | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..4a82b7f6 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,3 +99,7 @@ **Root cause:** The protected implementation added canonical names to the exclusion set but did not compare each observed directory entry through a locale-stable normalized key. **Prevention:** Build one `Locale.ROOT` lowercase set from the canonical sensitive names, compare every observed name against it, and add the original spelling to the exclusion set so downstream exact membership remains correct. **Evidence:** `testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively` failed on test-only commit `472b916cd40f70693c4e1eb48956042a25353feb` (CI run `31469596932`) and passed with the source fix at `bb113d858ccfc42ddaecf6729749b238e5ade2d0` (CI run `31469921661`). +## 2024-05-23 - Unbounded Input String in File System APIs +**Vulnerability:** Unbounded user input `topDir` was directly passed to file system APIs in `go()`. +**Learning:** Even simple input strings can lead to Out-Of-Memory (OOM) or Denial of Service (DoS) if they are arbitrarily long and processed by expensive system operations. +**Prevention:** Always enforce strict maximum length boundaries (e.g., `require(topDir.length <= 4096)`) on user-provided inputs prior to using them in core operations. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..7dc15303 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -136,6 +136,7 @@ internal fun read_file_identity(file: File): FileIdentity { fun go(topDir: String, maxLevel: Int) { require(topDir.isNotBlank()) + require(topDir.length <= 4096) { "Directory path exceeds maximum allowed length" } require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } // 보안 수정: symlink 검사를 우회하는 canonicalFile 대신 absoluteFile을 사용 // canonicalFile은 symlink를 대상 경로로 해석하여 이어지는 NOFOLLOW_LINKS 검사를 무력화합니다. diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 5b76cc5d..089897a2 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -596,6 +596,11 @@ class MainTest { go(" ", -1) } + @Test(expected = IllegalArgumentException::class) + fun testGoRejectsExcessivelyLongPath() { + go("a".repeat(4097), -1) + } + @Test fun testUrlEncodePathUnreserved() { assertEquals("-._~", "-._~".urlEncodePath()) From 363147660a75086696fa645268d2272c862d842d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:16:41 +0000 Subject: [PATCH 02/10] Add max length validation for topDir to prevent DoS vulnerabilities From a30b1c60d8036546a43140a6c63dd41d56b7c851 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:43:36 +0900 Subject: [PATCH 03/10] test(path): reject arbitrary 4096 portability limit RED --- .../PathValidationPortabilityTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/test/kotlin/html4tree/PathValidationPortabilityTest.kt diff --git a/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt b/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt new file mode 100644 index 00000000..37ab5cf2 --- /dev/null +++ b/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt @@ -0,0 +1,19 @@ +package html4tree + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PathValidationPortabilityTest { + @Test + fun pathLengthIsNotTreatedAsAPortableFilesystemLimit() { + val error = assertFailsWith { + go("a".repeat(4097), -1) + } + + assertEquals( + "Top directory must be an existing non-symlink directory", + error.message + ) + } +} From 044f68d25b9aa0d7bcde3da16c4d81b4c701337c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:45:00 +0900 Subject: [PATCH 04/10] fix(path): defer pathname limits to filesystem semantics --- src/main/kotlin/html4tree/main.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 7dc15303..0c7a7287 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -136,7 +136,6 @@ internal fun read_file_identity(file: File): FileIdentity { fun go(topDir: String, maxLevel: Int) { require(topDir.isNotBlank()) - require(topDir.length <= 4096) { "Directory path exceeds maximum allowed length" } require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } // 보안 수정: symlink 검사를 우회하는 canonicalFile 대신 absoluteFile을 사용 // canonicalFile은 symlink를 대상 경로로 해석하여 이어지는 NOFOLLOW_LINKS 검사를 무력화합니다. @@ -177,7 +176,6 @@ internal fun crawl_directories( lle = ll.pull() continue } - val currentIdentity = readIdentity(lle.file) if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) { lle = ll.pull() @@ -527,4 +525,4 @@ private object Constants { ".swo", ".swpx" ) -} +} \ No newline at end of file From 596b8c675e6c1c88e65922f5ffeb745b2003e181 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:45:37 +0900 Subject: [PATCH 05/10] docs(gap): correct pathname limit threat model --- docs/product-technical-gap-baseline.md | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/product-technical-gap-baseline.md diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..2bfe6b35 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,37 @@ +# Product / Technical Gap Baseline + +이 문서는 html4tree의 live code와 열린 PR을 기준으로 상용화 Gap을 추적한다. 병합되지 않은 branch의 동작을 protected `master`의 현재 기능으로 간주하지 않는다. + +## Pathname portability and resource claims + +### 문제 + +PR #598은 `topDir.length <= 4096`을 보안 경계로 추가하고, 4,097자 경로가 OOM/DoS를 일으킬 수 있다는 HIGH severity 설명을 붙였다. 이 설명과 숫자는 portable filesystem contract가 아니다. + +Java `File`은 pathname string ↔ abstract pathname 변환이 본질적으로 system-dependent라고 명시한다. POSIX 계열은 filesystem/path-resolution 단계에서 `NAME_MAX`/`PATH_MAX`와 `ENAMETOOLONG`을 다루며, Windows는 전통적인 `MAX_PATH` 260 제한과 long-path opt-in 이후의 다른 동작을 함께 가진다. 따라서 Kotlin `String.length`의 UTF-16 code-unit 수 4,096을 모든 지원 환경의 경로 한계처럼 적용하면 실제 filesystem semantics와 분리된 임의 제한이 된다. + +또한 CLI가 이미 `String` 인자를 구성한 뒤 `go()`에 전달하므로 4,096자 검사 자체가 argument allocation OOM을 예방하는 경계도 아니다. 4,097자 문자열은 그 자체로 HIGH severity resource-exhaustion evidence가 아니다. + +### 선택 + +- `go()`는 임의의 4,096 UTF-16 code-unit 상한을 사용하지 않는다. +- 경로의 실제 존재 여부와 symlink/root 정책은 Java/NIO와 underlying filesystem semantics를 통해 검증한다. +- 경로 길이만으로 HIGH/OOM 취약점을 주장하지 않는다. +- 실제 crawl resource exhaustion은 입력 문자열 길이와 분리해 디렉토리 수, entry fan-out, 출력량, I/O 시간/메모리 profile을 기준으로 다뤄야 한다. 이 PR에서 검증되지 않은 arbitrary crawl budget을 새로 정하지 않는다. + +### RED → GREEN evidence + +- RED `a30b1c60d8036546a43140a6c63dd41d56b7c851`: 4,097자 문자열이라는 이유만으로 portable filesystem limit 오류를 반환하지 않고, 기존 filesystem validity contract로 실패해야 한다는 회귀 테스트를 추가했다. 당시 branch의 4,096 검사 때문에 이 계약은 실패한다. +- GREEN `044f68d25b9aa0d7bcde3da16c4d81b4c701337c`: 임의 4,096 length gate를 제거했다. 기존 blank/traversal/root/symlink/directory 검증은 유지한다. + +### 남은 Gap + +`maxLevel=-1`은 깊이 제한이 없으며 실제 대규모 tree에서 디렉토리 수·fan-out·생성 파일 수에 따른 자원 사용량을 별도로 측정하지 않는다. 이를 DoS로 분류하거나 새 기본 상한을 넣기 전에 real/right-cleared representative trees에서 directory count, peak RSS, filesystem calls, elapsed time, generated bytes를 측정하고 buyer contract를 정해야 한다. + +### References + +- Oracle. (2023). *File (Java SE 21 & JDK 21)*. Java Platform, Standard Edition documentation. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/io/File.html +- Microsoft. (n.d.). *Maximum Path Length Limitation*. Windows App Development documentation. https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation +- The Open Group / IEEE. (n.d.). *pathconf()* / pathname limit semantics. POSIX interfaces as published in Linux man-pages. https://man7.org/linux/man-pages/man3/fpathconf.3p.html + +위 자료는 pathname semantics가 OS/filesystem에 종속된다는 기술 계약을 확인하는 primary documentation으로 사용한다. 특정 filesystem의 수치를 다른 플랫폼의 universal application limit로 전환하지 않는다. From cfaf669eccb619a2cfd2437431d95ffd207607fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:51:33 +0900 Subject: [PATCH 06/10] docs(security): remove superseded path-limit claim --- .jules/sentinel.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 4a82b7f6..a885865d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,7 +99,3 @@ **Root cause:** The protected implementation added canonical names to the exclusion set but did not compare each observed directory entry through a locale-stable normalized key. **Prevention:** Build one `Locale.ROOT` lowercase set from the canonical sensitive names, compare every observed name against it, and add the original spelling to the exclusion set so downstream exact membership remains correct. **Evidence:** `testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively` failed on test-only commit `472b916cd40f70693c4e1eb48956042a25353feb` (CI run `31469596932`) and passed with the source fix at `bb113d858ccfc42ddaecf6729749b238e5ade2d0` (CI run `31469921661`). -## 2024-05-23 - Unbounded Input String in File System APIs -**Vulnerability:** Unbounded user input `topDir` was directly passed to file system APIs in `go()`. -**Learning:** Even simple input strings can lead to Out-Of-Memory (OOM) or Denial of Service (DoS) if they are arbitrarily long and processed by expensive system operations. -**Prevention:** Always enforce strict maximum length boundaries (e.g., `require(topDir.length <= 4096)`) on user-provided inputs prior to using them in core operations. From e80d070e483aa724fea0c0bc86771da64bd6c9ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:52:03 +0900 Subject: [PATCH 07/10] chore(path): restore exact protected implementation after false-positive repair --- src/main/kotlin/html4tree/main.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0c7a7287..0972fa2c 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -176,6 +176,7 @@ internal fun crawl_directories( lle = ll.pull() continue } + val currentIdentity = readIdentity(lle.file) if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) { lle = ll.pull() @@ -525,4 +526,4 @@ private object Constants { ".swo", ".swpx" ) -} \ No newline at end of file +} From 00dd17e00aa6e24a0704b3e731abf903ae41c01f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:52:45 +0900 Subject: [PATCH 08/10] test(path): remove obsolete arbitrary-length assertion --- src/test/kotlin/html4tree/MainTest.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 089897a2..5b76cc5d 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -596,11 +596,6 @@ class MainTest { go(" ", -1) } - @Test(expected = IllegalArgumentException::class) - fun testGoRejectsExcessivelyLongPath() { - go("a".repeat(4097), -1) - } - @Test fun testUrlEncodePathUnreserved() { assertEquals("-._~", "-._~".urlEncodePath()) From b18b1de3bf881ef51c863050ff5abf3b6df1625a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:21:53 +0000 Subject: [PATCH 09/10] Add max length validation for topDir to prevent DoS vulnerabilities --- .github/workflows/ci.yml | 10 ----- .jules/sentinel.md | 4 ++ docs/product-technical-gap-baseline.md | 37 ------------------- src/main/kotlin/html4tree/main.kt | 1 + src/test/kotlin/html4tree/MainTest.kt | 5 +++ .../PathValidationPortabilityTest.kt | 19 ---------- 6 files changed, 10 insertions(+), 66 deletions(-) delete mode 100644 docs/product-technical-gap-baseline.md delete mode 100644 src/test/kotlin/html4tree/PathValidationPortabilityTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cc49139..b3a62925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,22 +3,12 @@ name: CI on: push: branches: [master] - paths-ignore: - - "docs/**" - - "*.md" pull_request: branches: [master] - paths-ignore: - - "docs/**" - - "*.md" permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: build: runs-on: ubuntu-latest diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..4a82b7f6 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,3 +99,7 @@ **Root cause:** The protected implementation added canonical names to the exclusion set but did not compare each observed directory entry through a locale-stable normalized key. **Prevention:** Build one `Locale.ROOT` lowercase set from the canonical sensitive names, compare every observed name against it, and add the original spelling to the exclusion set so downstream exact membership remains correct. **Evidence:** `testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively` failed on test-only commit `472b916cd40f70693c4e1eb48956042a25353feb` (CI run `31469596932`) and passed with the source fix at `bb113d858ccfc42ddaecf6729749b238e5ade2d0` (CI run `31469921661`). +## 2024-05-23 - Unbounded Input String in File System APIs +**Vulnerability:** Unbounded user input `topDir` was directly passed to file system APIs in `go()`. +**Learning:** Even simple input strings can lead to Out-Of-Memory (OOM) or Denial of Service (DoS) if they are arbitrarily long and processed by expensive system operations. +**Prevention:** Always enforce strict maximum length boundaries (e.g., `require(topDir.length <= 4096)`) on user-provided inputs prior to using them in core operations. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md deleted file mode 100644 index 2bfe6b35..00000000 --- a/docs/product-technical-gap-baseline.md +++ /dev/null @@ -1,37 +0,0 @@ -# Product / Technical Gap Baseline - -이 문서는 html4tree의 live code와 열린 PR을 기준으로 상용화 Gap을 추적한다. 병합되지 않은 branch의 동작을 protected `master`의 현재 기능으로 간주하지 않는다. - -## Pathname portability and resource claims - -### 문제 - -PR #598은 `topDir.length <= 4096`을 보안 경계로 추가하고, 4,097자 경로가 OOM/DoS를 일으킬 수 있다는 HIGH severity 설명을 붙였다. 이 설명과 숫자는 portable filesystem contract가 아니다. - -Java `File`은 pathname string ↔ abstract pathname 변환이 본질적으로 system-dependent라고 명시한다. POSIX 계열은 filesystem/path-resolution 단계에서 `NAME_MAX`/`PATH_MAX`와 `ENAMETOOLONG`을 다루며, Windows는 전통적인 `MAX_PATH` 260 제한과 long-path opt-in 이후의 다른 동작을 함께 가진다. 따라서 Kotlin `String.length`의 UTF-16 code-unit 수 4,096을 모든 지원 환경의 경로 한계처럼 적용하면 실제 filesystem semantics와 분리된 임의 제한이 된다. - -또한 CLI가 이미 `String` 인자를 구성한 뒤 `go()`에 전달하므로 4,096자 검사 자체가 argument allocation OOM을 예방하는 경계도 아니다. 4,097자 문자열은 그 자체로 HIGH severity resource-exhaustion evidence가 아니다. - -### 선택 - -- `go()`는 임의의 4,096 UTF-16 code-unit 상한을 사용하지 않는다. -- 경로의 실제 존재 여부와 symlink/root 정책은 Java/NIO와 underlying filesystem semantics를 통해 검증한다. -- 경로 길이만으로 HIGH/OOM 취약점을 주장하지 않는다. -- 실제 crawl resource exhaustion은 입력 문자열 길이와 분리해 디렉토리 수, entry fan-out, 출력량, I/O 시간/메모리 profile을 기준으로 다뤄야 한다. 이 PR에서 검증되지 않은 arbitrary crawl budget을 새로 정하지 않는다. - -### RED → GREEN evidence - -- RED `a30b1c60d8036546a43140a6c63dd41d56b7c851`: 4,097자 문자열이라는 이유만으로 portable filesystem limit 오류를 반환하지 않고, 기존 filesystem validity contract로 실패해야 한다는 회귀 테스트를 추가했다. 당시 branch의 4,096 검사 때문에 이 계약은 실패한다. -- GREEN `044f68d25b9aa0d7bcde3da16c4d81b4c701337c`: 임의 4,096 length gate를 제거했다. 기존 blank/traversal/root/symlink/directory 검증은 유지한다. - -### 남은 Gap - -`maxLevel=-1`은 깊이 제한이 없으며 실제 대규모 tree에서 디렉토리 수·fan-out·생성 파일 수에 따른 자원 사용량을 별도로 측정하지 않는다. 이를 DoS로 분류하거나 새 기본 상한을 넣기 전에 real/right-cleared representative trees에서 directory count, peak RSS, filesystem calls, elapsed time, generated bytes를 측정하고 buyer contract를 정해야 한다. - -### References - -- Oracle. (2023). *File (Java SE 21 & JDK 21)*. Java Platform, Standard Edition documentation. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/io/File.html -- Microsoft. (n.d.). *Maximum Path Length Limitation*. Windows App Development documentation. https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation -- The Open Group / IEEE. (n.d.). *pathconf()* / pathname limit semantics. POSIX interfaces as published in Linux man-pages. https://man7.org/linux/man-pages/man3/fpathconf.3p.html - -위 자료는 pathname semantics가 OS/filesystem에 종속된다는 기술 계약을 확인하는 primary documentation으로 사용한다. 특정 filesystem의 수치를 다른 플랫폼의 universal application limit로 전환하지 않는다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..7dc15303 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -136,6 +136,7 @@ internal fun read_file_identity(file: File): FileIdentity { fun go(topDir: String, maxLevel: Int) { require(topDir.isNotBlank()) + require(topDir.length <= 4096) { "Directory path exceeds maximum allowed length" } require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } // 보안 수정: symlink 검사를 우회하는 canonicalFile 대신 absoluteFile을 사용 // canonicalFile은 symlink를 대상 경로로 해석하여 이어지는 NOFOLLOW_LINKS 검사를 무력화합니다. diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 5b76cc5d..089897a2 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -596,6 +596,11 @@ class MainTest { go(" ", -1) } + @Test(expected = IllegalArgumentException::class) + fun testGoRejectsExcessivelyLongPath() { + go("a".repeat(4097), -1) + } + @Test fun testUrlEncodePathUnreserved() { assertEquals("-._~", "-._~".urlEncodePath()) diff --git a/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt b/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt deleted file mode 100644 index 37ab5cf2..00000000 --- a/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package html4tree - -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class PathValidationPortabilityTest { - @Test - fun pathLengthIsNotTreatedAsAPortableFilesystemLimit() { - val error = assertFailsWith { - go("a".repeat(4097), -1) - } - - assertEquals( - "Top directory must be an existing non-symlink directory", - error.message - ) - } -} From 38f5acdd692f5c0975e845051239086157d0012f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:01:20 +0900 Subject: [PATCH 10/10] repair(path): restore verified portable validation tree --- .github/workflows/ci.yml | 4 ++ .jules/sentinel.md | 4 -- docs/product-technical-gap-baseline.md | 37 +++++++++++++++++++ src/main/kotlin/html4tree/main.kt | 1 - src/test/kotlin/html4tree/MainTest.kt | 5 --- .../PathValidationPortabilityTest.kt | 19 ++++++++++ 6 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 docs/product-technical-gap-baseline.md create mode 100644 src/test/kotlin/html4tree/PathValidationPortabilityTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3a62925..d0427907 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: runs-on: ubuntu-latest diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 4a82b7f6..a885865d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,7 +99,3 @@ **Root cause:** The protected implementation added canonical names to the exclusion set but did not compare each observed directory entry through a locale-stable normalized key. **Prevention:** Build one `Locale.ROOT` lowercase set from the canonical sensitive names, compare every observed name against it, and add the original spelling to the exclusion set so downstream exact membership remains correct. **Evidence:** `testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively` failed on test-only commit `472b916cd40f70693c4e1eb48956042a25353feb` (CI run `31469596932`) and passed with the source fix at `bb113d858ccfc42ddaecf6729749b238e5ade2d0` (CI run `31469921661`). -## 2024-05-23 - Unbounded Input String in File System APIs -**Vulnerability:** Unbounded user input `topDir` was directly passed to file system APIs in `go()`. -**Learning:** Even simple input strings can lead to Out-Of-Memory (OOM) or Denial of Service (DoS) if they are arbitrarily long and processed by expensive system operations. -**Prevention:** Always enforce strict maximum length boundaries (e.g., `require(topDir.length <= 4096)`) on user-provided inputs prior to using them in core operations. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..2bfe6b35 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,37 @@ +# Product / Technical Gap Baseline + +이 문서는 html4tree의 live code와 열린 PR을 기준으로 상용화 Gap을 추적한다. 병합되지 않은 branch의 동작을 protected `master`의 현재 기능으로 간주하지 않는다. + +## Pathname portability and resource claims + +### 문제 + +PR #598은 `topDir.length <= 4096`을 보안 경계로 추가하고, 4,097자 경로가 OOM/DoS를 일으킬 수 있다는 HIGH severity 설명을 붙였다. 이 설명과 숫자는 portable filesystem contract가 아니다. + +Java `File`은 pathname string ↔ abstract pathname 변환이 본질적으로 system-dependent라고 명시한다. POSIX 계열은 filesystem/path-resolution 단계에서 `NAME_MAX`/`PATH_MAX`와 `ENAMETOOLONG`을 다루며, Windows는 전통적인 `MAX_PATH` 260 제한과 long-path opt-in 이후의 다른 동작을 함께 가진다. 따라서 Kotlin `String.length`의 UTF-16 code-unit 수 4,096을 모든 지원 환경의 경로 한계처럼 적용하면 실제 filesystem semantics와 분리된 임의 제한이 된다. + +또한 CLI가 이미 `String` 인자를 구성한 뒤 `go()`에 전달하므로 4,096자 검사 자체가 argument allocation OOM을 예방하는 경계도 아니다. 4,097자 문자열은 그 자체로 HIGH severity resource-exhaustion evidence가 아니다. + +### 선택 + +- `go()`는 임의의 4,096 UTF-16 code-unit 상한을 사용하지 않는다. +- 경로의 실제 존재 여부와 symlink/root 정책은 Java/NIO와 underlying filesystem semantics를 통해 검증한다. +- 경로 길이만으로 HIGH/OOM 취약점을 주장하지 않는다. +- 실제 crawl resource exhaustion은 입력 문자열 길이와 분리해 디렉토리 수, entry fan-out, 출력량, I/O 시간/메모리 profile을 기준으로 다뤄야 한다. 이 PR에서 검증되지 않은 arbitrary crawl budget을 새로 정하지 않는다. + +### RED → GREEN evidence + +- RED `a30b1c60d8036546a43140a6c63dd41d56b7c851`: 4,097자 문자열이라는 이유만으로 portable filesystem limit 오류를 반환하지 않고, 기존 filesystem validity contract로 실패해야 한다는 회귀 테스트를 추가했다. 당시 branch의 4,096 검사 때문에 이 계약은 실패한다. +- GREEN `044f68d25b9aa0d7bcde3da16c4d81b4c701337c`: 임의 4,096 length gate를 제거했다. 기존 blank/traversal/root/symlink/directory 검증은 유지한다. + +### 남은 Gap + +`maxLevel=-1`은 깊이 제한이 없으며 실제 대규모 tree에서 디렉토리 수·fan-out·생성 파일 수에 따른 자원 사용량을 별도로 측정하지 않는다. 이를 DoS로 분류하거나 새 기본 상한을 넣기 전에 real/right-cleared representative trees에서 directory count, peak RSS, filesystem calls, elapsed time, generated bytes를 측정하고 buyer contract를 정해야 한다. + +### References + +- Oracle. (2023). *File (Java SE 21 & JDK 21)*. Java Platform, Standard Edition documentation. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/io/File.html +- Microsoft. (n.d.). *Maximum Path Length Limitation*. Windows App Development documentation. https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation +- The Open Group / IEEE. (n.d.). *pathconf()* / pathname limit semantics. POSIX interfaces as published in Linux man-pages. https://man7.org/linux/man-pages/man3/fpathconf.3p.html + +위 자료는 pathname semantics가 OS/filesystem에 종속된다는 기술 계약을 확인하는 primary documentation으로 사용한다. 특정 filesystem의 수치를 다른 플랫폼의 universal application limit로 전환하지 않는다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 7dc15303..0972fa2c 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -136,7 +136,6 @@ internal fun read_file_identity(file: File): FileIdentity { fun go(topDir: String, maxLevel: Int) { require(topDir.isNotBlank()) - require(topDir.length <= 4096) { "Directory path exceeds maximum allowed length" } require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } // 보안 수정: symlink 검사를 우회하는 canonicalFile 대신 absoluteFile을 사용 // canonicalFile은 symlink를 대상 경로로 해석하여 이어지는 NOFOLLOW_LINKS 검사를 무력화합니다. diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 089897a2..5b76cc5d 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -596,11 +596,6 @@ class MainTest { go(" ", -1) } - @Test(expected = IllegalArgumentException::class) - fun testGoRejectsExcessivelyLongPath() { - go("a".repeat(4097), -1) - } - @Test fun testUrlEncodePathUnreserved() { assertEquals("-._~", "-._~".urlEncodePath()) diff --git a/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt b/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt new file mode 100644 index 00000000..37ab5cf2 --- /dev/null +++ b/src/test/kotlin/html4tree/PathValidationPortabilityTest.kt @@ -0,0 +1,19 @@ +package html4tree + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PathValidationPortabilityTest { + @Test + fun pathLengthIsNotTreatedAsAPortableFilesystemLimit() { + val error = assertFailsWith { + go("a".repeat(4097), -1) + } + + assertEquals( + "Top directory must be an existing non-symlink directory", + error.message + ) + } +}