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-03-22 - [TOCTOU and Bypassed Size Limits in File Reads]
**Vulnerability:** A Time-of-Check to Time-of-Use (TOCTOU) vulnerability existed where file size was checked via `path.stat().st_size` combined with `path.exists()` before a full unbounded read `path.read_text()`. This could allow device files (e.g. `/dev/zero`) to report 0 bytes and bypass the size check, then block/crash the application by continuously streaming data into memory during `read_text()`.
**Learning:** Relying on `path.stat().st_size` for size enforcement before reading is insecure and susceptible to TOCTOU. Furthermore, `path.exists()` allows non-regular files (like device streams) to be parsed, incorrectly reporting their size as 0, thereby completely bypassing naive checks.
**Prevention:** To prevent unbound memory DoS during file reads: always verify the path is a regular file with `path.is_file()`, eliminate `st_size` checks, and implement a strict bounded read pattern using `f.read(limit + 1)`, manually raising an error if the read content exceeds `limit`.
17 changes: 12 additions & 5 deletions src/python_learning_orchestrated/adapters/checkpoint_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,19 @@ def _to_int(value: object, default: int) -> int:


def _read_json(path: Path) -> dict[str, object]:
if not path.exists():
if not path.is_file():
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"))
return parsed if isinstance(parsed, dict) else {}

with path.open("r", encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
Comment on lines +238 to +239

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

To improve maintainability and avoid magic numbers, it's a good practice to define the file size limit as a module-level constant. This makes it easier to find and update the value if needed, and ensures consistency across the codebase.

You could define _MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 at the top of the file and use it here.

Suggested change
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
content = f.read(_MAX_FILE_SIZE_BYTES + 1)
if len(content) > _MAX_FILE_SIZE_BYTES:

raise ValueError(f"Checkpoint file {path} exceeds 10MB size limit")

if not content.strip():
return {}

parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}


def _write_json(path: Path, payload: dict[str, object]) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,27 @@ def record_attempts(self, attempts: list[Attempt]) -> None:
self._save_storage(storage)

def _load_storage(self) -> dict[str, object]:
if not self._file_path.exists():
if not self._file_path.is_file():
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"
)

try:
content = self._file_path.read_text(encoding="utf-8")
if not content.strip():
return {"items": [], "attempts": []}
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {"items": [], "attempts": []}
with self._file_path.open("r", encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
Comment on lines +111 to +112

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

Similar to the other adapters, let's replace the magic number 10 * 1024 * 1024 with a shared constant like _MAX_FILE_SIZE_BYTES for better maintainability. This will centralize the configuration for the file size limit.

Suggested change
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
content = f.read(_MAX_FILE_SIZE_BYTES + 1)
if len(content) > _MAX_FILE_SIZE_BYTES:

raise ValueError(
f"Practice repository file {self._file_path} "
"exceeds 10MB size limit"
)

if not content.strip():
return {"items": [], "attempts": []}

parsed = json.loads(content)
return (
parsed
if isinstance(parsed, dict)
else {"items": [], "attempts": []}
)
except (OSError, json.JSONDecodeError):
return {"items": [], "attempts": []}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,27 +40,30 @@ def reset_progress(self, user_id: str) -> None:

def _load_storage(self) -> dict[str, LessonProgress]:
"""Load all persisted progress payloads."""
if not self._file_path.exists():
if not self._file_path.is_file():
return {}
if self._file_path.stat().st_size > 10 * 1024 * 1024:
raise ValueError(
f"Progress repository file {self._file_path} exceeds 10MB size limit"
)

try:
content = self._file_path.read_text(encoding="utf-8")
if not content.strip():
return {}

data = json.loads(content)
if not isinstance(data, dict):
return {}

normalized: dict[str, LessonProgress] = {}
for user_id, progress in data.items():
if isinstance(user_id, str) and isinstance(progress, dict):
normalized[user_id] = cast(LessonProgress, progress)
return normalized
with self._file_path.open("r", encoding="utf-8") as f:
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
Comment on lines +48 to +49

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

To maintain consistency and avoid magic numbers, please consider defining 10 * 1024 * 1024 as a module-level constant (e.g., _MAX_FILE_SIZE_BYTES) and using it here. This improves readability and makes the limit explicit.

Suggested change
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
content = f.read(_MAX_FILE_SIZE_BYTES + 1)
if len(content) > _MAX_FILE_SIZE_BYTES:

raise ValueError(
f"Progress repository file {self._file_path} "
"exceeds 10MB size limit"
)

if not content.strip():
return {}

data = json.loads(content)
if not isinstance(data, dict):
return {}

normalized: dict[str, LessonProgress] = {}
for user_id, progress in data.items():
if isinstance(user_id, str) and isinstance(progress, dict):
normalized[user_id] = cast(LessonProgress, progress)
return normalized
except (json.JSONDecodeError, OSError):
return {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,20 @@ def save(self, snapshot: ProgressSnapshot) -> None:
self._save_payload(progress_snapshot_to_payload(snapshot))

def _load_payload(self) -> dict[str, object]:
if not self._file_path.exists():
if not self._file_path.is_file():
return {}
if self._file_path.stat().st_size > 10 * 1024 * 1024:
raise ValueError(
f"Progress snapshot file {self._file_path} exceeds 10MB size limit"
)

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

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

Let's replace the hardcoded size limit 10 * 1024 * 1024 with a module-level constant (e.g., _MAX_FILE_SIZE_BYTES) to improve code clarity and make future changes easier. This should be applied consistently across all modified adapters.

Suggested change
content = f.read(10 * 1024 * 1024 + 1)
if len(content) > 10 * 1024 * 1024:
content = f.read(_MAX_FILE_SIZE_BYTES + 1)
if len(content) > _MAX_FILE_SIZE_BYTES:

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