diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 2b5d819c..361227f5 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -90,3 +90,7 @@ **Vulnerability:** The `_safe_upload_filename` function used `filename.replace`, `PurePosixPath`, and `re.sub` on unbounded client input, making it vulnerable to ReDoS or CPU/memory exhaustion (DoS) when fed extremely long strings. **Learning:** Even fast standard library functions like `PurePosixPath` and string replacements can cause significant lag when chained on strings in the megabytes. String processing operations should always bound their inputs first if the input is untrusted and can be arbitrarily large. **Prevention:** Cap the length of client-provided filename strings early by slicing them (e.g. `filename = filename[-512:]`) before doing more complex string parsing or regex replacements, especially when only the basename suffix is relevant. +## 2026-08-29 - [DoS Risk Mitigation] Form Field Max Length +**Vulnerability:** FastAPIs using `Form()` without a `max_length` can be exploited to cause memory exhaustion by uploading arbitrarily large fields, as `python-multipart` loads them fully into memory. +**Learning:** Even auxiliary configuration fields (like `language` and `mode`) can present critical resource exhaustion vectors if their length is unbounded. +**Prevention:** Always define a tight `max_length` (e.g. `max_length=50`) on string `Form()` parameters. diff --git a/src/newsdom_api/main.py b/src/newsdom_api/main.py index f61aafc2..eaaf24ef 100644 --- a/src/newsdom_api/main.py +++ b/src/newsdom_api/main.py @@ -205,19 +205,21 @@ async def parse( language: Annotated[ str, Form( + max_length=50, description=( "MinerU language family or compatibility alias (e.g. `ch`, " "`en`, `japan`, `korean`, `arabic`, `devanagari`)." - ) + ), ), ] = DEFAULT_LANGUAGE, mode: Annotated[ str, Form( + max_length=50, description=( "MinerU parsing mode: `auto` (born-digital text PDFs skip forced " "OCR), `ocr` (force OCR), or `txt` (embedded text layer only)." - ) + ), ), ] = DEFAULT_MODE, ) -> ParseResponse: diff --git a/tests/test_parse_endpoint.py b/tests/test_parse_endpoint.py index 1491ada0..a173032f 100644 --- a/tests/test_parse_endpoint.py +++ b/tests/test_parse_endpoint.py @@ -555,3 +555,23 @@ def spy_unlink(self, missing_ok=False): # We should have unlinked exactly one file, which should be in the temp directory assert len(unlinked_paths) == 1 assert "tmp" in unlinked_paths[0].lower() or "temp" in unlinked_paths[0].lower() + + +def test_parse_endpoint_rejects_excessively_long_form_fields(): + client = TestClient(app) + + long_string = "a" * 100 + + response_lang = client.post( + "/parse", + files={"file": ("test.pdf", b"%PDF-1.4\n...", "application/pdf")}, + data={"language": long_string, "mode": "auto"}, + ) + assert response_lang.status_code == 422 + + response_mode = client.post( + "/parse", + files={"file": ("test.pdf", b"%PDF-1.4\n...", "application/pdf")}, + data={"language": "ch", "mode": long_string}, + ) + assert response_mode.status_code == 422