Skip to content
Open
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
**Vulnerability:** Unbounded memory consumption DoS risk due to JSON file parsing without size limits in file-backed repositories and stores.
**Learning:** `json.loads(file.read_text())` reads the entire file content into memory. This can lead to out-of-memory errors or denial-of-service if an attacker or misconfiguration provides an excessively large file. The application relies heavily on file-backed adapters (e.g., `JsonFilePracticeRepository`, `JsonFileProgressSnapshotStore`, `CheckpointStore`).
**Prevention:** Implement a strict file size limit check using `path.stat().st_size` (e.g., standardized at 10MB or `10 * 1024 * 1024` bytes) before reading the file content into memory in all file-based adapters.
## 2025-02-23 - [TOCTOU Vulnerability in File Size Checks]
**Vulnerability:** Time-of-Check to Time-of-Use (TOCTOU) and Device File Bypass in file size limits.
**Learning:** Checking `path.stat().st_size` is vulnerable to TOCTOU if the file changes between stat and read. Furthermore, checking `st_size` on device files (like `/dev/zero`) returns 0, bypassing the size check, and `read_text()` will then consume unbounded memory.
**Prevention:** To prevent unbounded memory consumption and TOCTOU, JSON file-backed adapters must use a secure bounded read pattern. Open the file, read up to `limit + 1` bytes, and raise `ValueError` if the length of the read content exceeds the limit. Do not rely solely on `st_size`.
10 changes: 7 additions & 3 deletions src/python_learning_orchestrated/adapters/checkpoint_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,13 @@ def _to_int(value: object, default: int) -> int:
def _read_json(path: Path) -> dict[str, object]:
if not path.exists():
return {}
if path.stat().st_size > 10 * 1024 * 1024:
raise ValueError(f"Checkpoint file {path} exceeds 10MB size limit")
parsed = json.loads(path.read_text(encoding="utf-8"))
if not path.is_file():
raise ValueError(f"Checkpoint path {path} is not a regular file")
with open(path, encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
Comment on lines +239 to +240

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file size limit 10 * 1024 * 1024 is a 'magic number'. To improve readability and maintainability, consider defining it as a module-level constant, for example:

_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024  # 10MB

Then you can use this constant here. This makes the intent clearer and simplifies future changes to the limit.

raise ValueError(f"Checkpoint file {path} exceeds 10MB size limit")
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,19 @@ def record_attempts(self, attempts: list[Attempt]) -> None:
def _load_storage(self) -> dict[str, object]:
if not self._file_path.exists():
return {"items": [], "attempts": []}
if self._file_path.stat().st_size > 10 * 1024 * 1024:
if not self._file_path.is_file():
raise ValueError(
f"Practice repository file {self._file_path} exceeds 10MB size limit"
f"Practice repository path {self._file_path} is not a regular file"
)

try:
content = self._file_path.read_text(encoding="utf-8")
with open(self._file_path, encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
Comment on lines +115 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file size limit 10 * 1024 * 1024 is a 'magic number'. To improve readability and maintainability, consider defining it as a module-level constant, for example:

_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024  # 10MB

Then you can use this constant here. This makes the intent clearer and simplifies future changes to the limit.

raise ValueError(
f"Practice repository file {self._file_path} "
"exceeds 10MB size limit"
)
if not content.strip():
return {"items": [], "attempts": []}
parsed = json.loads(content)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,19 @@ def _load_storage(self) -> dict[str, LessonProgress]:
"""Load all persisted progress payloads."""
if not self._file_path.exists():
return {}
if self._file_path.stat().st_size > 10 * 1024 * 1024:
if not self._file_path.is_file():
raise ValueError(
f"Progress repository file {self._file_path} exceeds 10MB size limit"
f"Progress repository path {self._file_path} is not a regular file"
)

try:
content = self._file_path.read_text(encoding="utf-8")
with open(self._file_path, encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
Comment on lines +52 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file size limit 10 * 1024 * 1024 is a 'magic number'. To improve readability and maintainability, consider defining it as a module-level constant, for example:

_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024  # 10MB

Then you can use this constant here. This makes the intent clearer and simplifies future changes to the limit.

raise ValueError(
f"Progress repository file {self._file_path} "
"exceeds 10MB size limit"
)
if not content.strip():
return {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,19 @@ def save(self, snapshot: ProgressSnapshot) -> None:
def _load_payload(self) -> dict[str, object]:
if not self._file_path.exists():
return {}
if self._file_path.stat().st_size > 10 * 1024 * 1024:
if not self._file_path.is_file():
raise ValueError(
f"Progress snapshot file {self._file_path} exceeds 10MB size limit"
f"Progress snapshot path {self._file_path} is not a regular file"
)
try:
parsed = json.loads(self._file_path.read_text(encoding="utf-8"))
with open(self._file_path, encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
Comment on lines +45 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file size limit 10 * 1024 * 1024 is a 'magic number'. To improve readability and maintainability, consider defining it as a module-level constant, for example:

_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024  # 10MB

Then you can use this constant here. This makes the intent clearer and simplifies future changes to the limit.

raise ValueError(
f"Progress snapshot file {self._file_path} "
"exceeds 10MB size limit"
)
parsed = json.loads(content)
except (OSError, json.JSONDecodeError):
return {}
return parsed if isinstance(parsed, dict) else {}
Expand Down
Loading