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 and OOM DoS in Bounded Reads]
**Vulnerability:** File size limit checks using `path.stat().st_size` combined with `path.read_text()` create TOCTOU (Time-of-Check to Time-of-Use) vulnerabilities and memory DoS risks (e.g., using device files like `/dev/zero` which report size 0 but have infinite stream).
**Learning:** Using `is_file()` to enforce regular files before size checking is critical. Also, using a bounded `read()` (e.g., `content = f.read(limit + 1)`) and verifying the read length prevents unbounded memory consumption safely, without relying on `stat()`.
**Prevention:** Instead of `st_size`, verify `is_file()` first, then use a secure bounded read `content = f.read(10 * 1024 * 1024 + 1)` and raise an error if `len(content) > 10 * 1024 * 1024`.
11 changes: 9 additions & 2 deletions src/python_learning_orchestrated/adapters/checkpoint_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,16 @@ 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:
if not path.is_file():
return {}

with open(path, encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)

if len(content) > 10 * 1024 * 1024:
raise ValueError(f"Checkpoint file {path} exceeds 10MB size limit")
parsed = json.loads(path.read_text(encoding="utf-8"))

parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
Comment on lines +245 to 246

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The call to json.loads(content) is not wrapped in a try...except block and does not handle empty or whitespace-only files. If content is empty or not valid JSON, this will raise a json.JSONDecodeError, causing an unhandled exception. Other adapters in this PR handle this case for robustness. For consistency and to prevent crashes, you should handle this potential error.

Suggested change
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
try:
parsed = json.loads(content) if content.strip() else {}
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}

Comment on lines +236 to 246

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

This new secure file reading logic is a great improvement. However, this pattern is now duplicated across four different files:

  • checkpoint_store.py
  • json_file_practice_repository.py
  • json_file_progress_repository.py
  • json_file_progress_snapshot_store.py

This duplication introduces a few issues:

  1. Maintainability: If this logic needs to be updated in the future (e.g., changing the size limit), it must be changed in all four places, which is error-prone.
  2. Magic Number: The size limit 10 * 1024 * 1024 is a magic number repeated in each implementation.

To address this, I recommend refactoring this logic into a single, shared utility function. This _read_json function is a great candidate to be moved to a shared utility module. Within that utility, you could define the size limit as a constant (e.g., _MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024) to be used by the function. This would centralize the security-critical code, eliminate duplication, and improve maintainability.



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:
raise ValueError(
f"Practice repository file {self._file_path} exceeds 10MB size limit"
)
if not self._file_path.is_file():
return {"items": [], "attempts": []}

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:
raise ValueError(
Comment on lines +113 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.

P2 Badge Enforce size limit in bytes, not UTF-8 characters

This bounded-read check is done in text mode, so f.read(10 * 1024 * 1024 + 1) and len(content) both operate on characters rather than bytes. A UTF-8 JSON file containing multibyte characters can be larger than 10MB on disk while still passing this guard, which is a regression from the previous byte-based st_size limit and weakens the stated DoS protection for non-ASCII payloads. Please enforce the cap in binary mode (byte count) before decoding.

Useful? React with πŸ‘Β / πŸ‘Ž.

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:
raise ValueError(
f"Progress repository file {self._file_path} exceeds 10MB size limit"
)
if not self._file_path.is_file():
return {}

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:
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:
raise ValueError(
f"Progress snapshot file {self._file_path} exceeds 10MB size limit"
)
if not self._file_path.is_file():
return {}

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:
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 {}
Comment on lines 42 to 54

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 structure of this try...except block can be improved for clarity and consistency. The final return statement is outside the try block, which can make the control flow harder to follow. Additionally, unlike other adapters, this doesn't explicitly handle empty files before attempting to parse JSON.

Moving the successful return path inside the try block and adding a check for empty content would make the code more readable and align it better with the other adapters.

Suggested change
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:
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 {}
try:
with open(self._file_path, encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
raise ValueError(
f"Progress snapshot file {self._file_path} exceeds 10MB size limit"
)
if not content.strip():
return {}
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
except (OSError, json.JSONDecodeError):
return {}

Expand Down
Loading