Spaces:
Running
Running
| """ | |
| Upload validation. | |
| The PDF parsers (PyMuPDF, pdfplumber) are C extensions fed entirely | |
| attacker-controlled bytes. Before anything reaches them we check that the | |
| payload is bounded and actually looks like a PDF, and we translate every | |
| failure into a 422 rather than letting an exception become a 500. | |
| Audit reproductions this closes: | |
| - empty file named .pdf β was 500, now 422 | |
| - text file renamed to .pdf β was 500, now 422 | |
| """ | |
| from __future__ import annotations | |
| from fastapi import HTTPException, UploadFile, status | |
| from starlette.concurrency import run_in_threadpool | |
| from app.config import get_settings | |
| # Every PDF begins with "%PDF-" per ISO 32000. Some generators emit a few junk | |
| # bytes first, so we scan a small prefix rather than demanding offset 0. | |
| _PDF_MAGIC = b"%PDF-" | |
| _MAGIC_SEARCH_WINDOW = 1024 | |
| _READ_CHUNK = 1 << 20 # 1 MiB | |
| _ALLOWED_CONTENT_TYPES = { | |
| "application/pdf", | |
| "application/x-pdf", | |
| "application/octet-stream", # some browsers send this for drag-and-drop | |
| "", # curl/multipart without an explicit type | |
| } | |
| def _reject(detail: str) -> HTTPException: | |
| return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail) | |
| async def read_validated_pdf(file: UploadFile) -> bytes: | |
| """ | |
| Read an uploaded file, enforcing size, declared type and magic bytes. | |
| Returns the raw bytes when valid; raises 413 (too large) or 422 (anything | |
| else) so the client always gets an actionable, non-5xx answer. | |
| """ | |
| cfg = get_settings() | |
| max_bytes = cfg.max_upload_mb * 1024 * 1024 | |
| declared = (file.content_type or "").split(";")[0].strip().lower() | |
| if declared not in _ALLOWED_CONTENT_TYPES: | |
| raise _reject(f"Unsupported content type '{declared}'. Only PDF files are accepted.") | |
| def _too_large() -> HTTPException: | |
| return HTTPException( | |
| status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, | |
| detail=f"File exceeds the {cfg.max_upload_mb} MB limit.", | |
| ) | |
| # Check the declared size first, then stream with a running total. Reading | |
| # the whole body before measuring it meant the limit rejected the upload but | |
| # never protected the worker: N concurrent oversized posts were all fully | |
| # resident before the first 413 was raised. | |
| if file.size is not None and file.size > max_bytes: | |
| raise _too_large() | |
| parts: list[bytes] = [] | |
| total = 0 | |
| while blob := await file.read(_READ_CHUNK): | |
| total += len(blob) | |
| if total > max_bytes: | |
| raise _too_large() | |
| parts.append(blob) | |
| content = b"".join(parts) | |
| if not content: | |
| raise _reject("The uploaded file is empty.") | |
| # Content sniffing β the filename and declared type are both client-supplied | |
| # and therefore untrusted. | |
| if _PDF_MAGIC not in content[:_MAGIC_SEARCH_WINDOW]: | |
| raise _reject( | |
| "File does not appear to be a valid PDF (missing %PDF- header). " | |
| "Renaming another file type to .pdf will not work." | |
| ) | |
| # fitz.open() parses the xref table in a C extension β tens of ms on a large | |
| # document, and this is one of the two `async def` handlers. Keep it off the | |
| # event loop so a malformed 20 MB upload cannot stall every other request. | |
| await run_in_threadpool(_assert_parseable, content) | |
| return content | |
| def _assert_parseable(content: bytes) -> None: | |
| """ | |
| Structural probe: can the parser actually open this document? | |
| A correct ``%PDF-`` header proves nothing about the body β a truncated or | |
| corrupted file passes the magic-byte check and then raises deep inside the | |
| MuPDF C extension. Those errors are not ``ValueError`` and would surface as | |
| a 500, so we open the document here (cheap: headers and the xref table only) | |
| and convert *any* parser failure into a 422. | |
| """ | |
| try: | |
| import fitz # PyMuPDF | |
| with fitz.open(stream=content, filetype="pdf") as doc: | |
| if doc.page_count < 1: | |
| raise _reject("PDF contains no pages.") | |
| if doc.needs_pass: | |
| raise _reject("PDF is password-protected. Remove the password and retry.") | |
| except HTTPException: | |
| raise | |
| except Exception as exc: # noqa: BLE001 β any parser failure is a client error | |
| raise _reject(f"PDF could not be parsed: {type(exc).__name__}. The file may be corrupted.") | |