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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
**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.

## 2024-05-18 - [TOCTOU in file size limit checking]
**Vulnerability:** Checking file size using `path.stat().st_size` prior to reading is vulnerable to Time-Of-Check to Time-Of-Use (TOCTOU) and out-of-memory DoS, as well as bypassing checks via device files like `/dev/zero` which report size 0.
**Learning:** `path.exists()` or `path.stat().st_size` checks the state of the filesystem. By the time the file is read, the file could have been changed, or a different file type like a device file could have been placed there, evading the check.
**Prevention:** Verify it's a regular file first (`path.is_file()`), then read with a strict bound like `f.read(limit + 1)`, and if the read returned size is strictly greater than the limit, throw a size limit exception.

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

For improved clarity and technical accuracy, I suggest a small wording change in the prevention description. The read() method returns the file content (as a string or bytes), not its size. The check is performed on the length of this content.

Suggested change
**Prevention:** Verify it's a regular file first (`path.is_file()`), then read with a strict bound like `f.read(limit + 1)`, and if the read returned size is strictly greater than the limit, throw a size limit exception.
**Prevention:** Verify it's a regular file first (`path.is_file()`), then read with a strict bound like `f.read(limit + 1)`, and if the length of the content read is greater than the limit, throw a size limit exception.

12 changes: 9 additions & 3 deletions src/python_learning_orchestrated/adapters/checkpoint_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,17 @@ 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:

# Security: Use bounded read (limit + 1) to prevent out-of-memory DoS
# from excessively large files or malicious device files (e.g., /dev/zero).
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")
Comment on lines +239 to 242

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 repeated and is a 'magic number'. To improve readability and maintainability, it's best to define it as a constant. This makes the code easier to understand and safer to modify in the future.

Additionally, consider updating the hardcoded '10MB' in the error message to be derived from the constant to prevent inconsistencies if the limit changes.

Suggested change
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")
_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
with open(path, encoding="utf-8") as f:
content = f.read(_MAX_FILE_SIZE_BYTES + 1)
if len(content) > _MAX_FILE_SIZE_BYTES:
raise ValueError(f"Checkpoint file {path} exceeds {_MAX_FILE_SIZE_BYTES // (1024*1024)}MB size limit")

parsed = json.loads(path.read_text(encoding="utf-8"))

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 @@ -103,15 +103,20 @@ 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")
# Security: Use bounded read (limit + 1) to prevent out-of-memory DoS
# from excessively large files or malicious device files (e.g., /dev/zero).
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"Practice repository file {self._file_path} "
"exceeds 10MB size limit"
)
Comment on lines +110 to +118

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 repeated and is a 'magic number'. To improve readability and maintainability, it's best to define it as a constant. This makes the code easier to understand and safer to modify in the future.

Additionally, consider updating the hardcoded '10MB' in the error message to be derived from the constant to prevent inconsistencies if the limit changes.

Suggested change
# Security: Use bounded read (limit + 1) to prevent out-of-memory DoS
# from excessively large files or malicious device files (e.g., /dev/zero).
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"Practice repository file {self._file_path} "
"exceeds 10MB size limit"
)
_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
# Security: Use bounded read (limit + 1) to prevent out-of-memory DoS
# from excessively large files or malicious device files (e.g., /dev/zero).
with open(self._file_path, encoding="utf-8") as f:
content = f.read(_MAX_FILE_SIZE_BYTES + 1)
if len(content) > _MAX_FILE_SIZE_BYTES:
raise ValueError(
f"Practice repository file {self._file_path} "
f"exceeds {_MAX_FILE_SIZE_BYTES // (1024*1024)}MB 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 @@ -34,14 +34,18 @@ 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"))
# Security: Use bounded read (limit + 1) to prevent out-of-memory DoS
# from excessively large files or malicious device files (e.g., /dev/zero).
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"
)
Comment on lines +40 to +47

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 repeated and is a 'magic number'. To improve readability and maintainability, it's best to define it as a constant. This makes the code easier to understand and safer to modify in the future.

Additionally, consider updating the hardcoded '10MB' in the error message to be derived from the constant to prevent inconsistencies if the limit changes.

Suggested change
# Security: Use bounded read (limit + 1) to prevent out-of-memory DoS
# from excessively large files or malicious device files (e.g., /dev/zero).
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"
)
_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
# Security: Use bounded read (limit + 1) to prevent out-of-memory DoS
# from excessively large files or malicious device files (e.g., /dev/zero).
with open(self._file_path, encoding="utf-8") as f:
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 {_MAX_FILE_SIZE_BYTES // (1024*1024)}MB size limit"
)

parsed = json.loads(content)
except (OSError, json.JSONDecodeError):
return {}
return parsed if isinstance(parsed, dict) else {}
Expand Down
Loading