File size: 4,398 Bytes
f1fa34c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5688c6d
f1fa34c
 
 
 
 
 
 
 
5688c6d
 
f1fa34c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5688c6d
 
f1fa34c
 
 
 
5688c6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1fa34c
 
 
 
 
 
 
 
5688c6d
 
 
 
f1fa34c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
"""
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.")