fix(cv): harden document parsing
Upgrade and hash-lock upload-facing parser dependencies, reject resource-heavy or mismatched inputs, remove unsafe backend binary fallbacks, and prevent internal parser failures from leaking to users.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11
|
||||
FROM python:3.12.10-slim-bookworm
|
||||
ENV PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
@@ -8,9 +8,9 @@ WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt ./
|
||||
COPY requirements-linux.lock ./
|
||||
RUN python -m pip install --upgrade pip setuptools wheel \
|
||||
&& python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt
|
||||
&& python -m pip install --require-hashes --extra-index-url https://download.pytorch.org/whl/cpu -r requirements-linux.lock
|
||||
COPY . .
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
|
||||
@@ -17,7 +17,7 @@ Windows:
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
||||
```
|
||||
|
||||
@@ -26,7 +26,7 @@ Linux / macOS:
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
||||
```
|
||||
|
||||
|
||||
+99
-8
@@ -17,6 +17,7 @@ import torch
|
||||
import pytesseract
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from urllib import request as urllib_request
|
||||
from urllib.error import URLError, HTTPError
|
||||
from contextvars import ContextVar
|
||||
@@ -91,7 +92,17 @@ async def require_service_token(request: Request, call_next):
|
||||
MODEL_NAME = "sshleifer/distilbart-cnn-12-6"
|
||||
MAX_INPUT_CHARS = 20000
|
||||
MAX_CONTEXT_CHARS = 2200
|
||||
MAX_EXTRACT_FILE_BYTES = 8 * 1024 * 1024
|
||||
MAX_EXTRACT_FILE_BYTES = 5 * 1024 * 1024
|
||||
MAX_EXTRACT_CHARS = 200_000
|
||||
MAX_PDF_PAGES = 40
|
||||
MAX_DOCX_ENTRIES = 256
|
||||
MAX_DOCX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024
|
||||
MAX_DOCX_ENTRY_BYTES = 8 * 1024 * 1024
|
||||
MAX_DOCX_COMPRESSION_RATIO = 100
|
||||
MAX_IMAGE_DIMENSION = 12_000
|
||||
MAX_IMAGE_PIXELS = 40_000_000
|
||||
MAX_PDF_PAGE_PIXELS = 12_000_000
|
||||
MAX_PDF_OCR_PIXELS = 120_000_000
|
||||
OCR_LANGUAGES = "eng"
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
||||
@@ -1068,36 +1079,110 @@ def _ocr_image(image: Image.Image) -> str:
|
||||
return _normalize_text(text)
|
||||
|
||||
|
||||
def _check_extracted_size(text: str) -> str:
|
||||
if len(text) > MAX_EXTRACT_CHARS:
|
||||
raise HTTPException(status_code=422, detail="The document contains too much extracted text.")
|
||||
return text
|
||||
|
||||
|
||||
def _check_image_size(image: Image.Image, *, pixel_limit: int | None = None) -> None:
|
||||
pixel_limit = MAX_IMAGE_PIXELS if pixel_limit is None else pixel_limit
|
||||
width, height = image.size
|
||||
if width <= 0 or height <= 0 or width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
|
||||
raise HTTPException(status_code=422, detail="The document image dimensions are not supported.")
|
||||
if width * height > pixel_limit:
|
||||
raise HTTPException(status_code=422, detail="The document image contains too many pixels.")
|
||||
if getattr(image, "n_frames", 1) != 1:
|
||||
raise HTTPException(status_code=422, detail="Multi-frame document images are not supported.")
|
||||
|
||||
|
||||
def _validate_docx_container(data: bytes) -> None:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
entries = archive.infolist()
|
||||
names = {entry.filename for entry in entries}
|
||||
if "[Content_Types].xml" not in names or "word/document.xml" not in names:
|
||||
raise HTTPException(status_code=400, detail="The uploaded file does not match its DOCX extension.")
|
||||
if len(entries) > MAX_DOCX_ENTRIES:
|
||||
raise HTTPException(status_code=422, detail="The DOCX contains too many files.")
|
||||
total_size = 0
|
||||
for entry in entries:
|
||||
total_size += entry.file_size
|
||||
if entry.file_size > MAX_DOCX_ENTRY_BYTES:
|
||||
raise HTTPException(status_code=422, detail="The DOCX contains an oversized file.")
|
||||
if entry.file_size > 0 and entry.compress_size == 0:
|
||||
raise HTTPException(status_code=422, detail="The DOCX compression ratio is not supported.")
|
||||
if entry.compress_size > 0 and entry.file_size / entry.compress_size > MAX_DOCX_COMPRESSION_RATIO:
|
||||
raise HTTPException(status_code=422, detail="The DOCX compression ratio is not supported.")
|
||||
if total_size > MAX_DOCX_UNCOMPRESSED_BYTES:
|
||||
raise HTTPException(status_code=422, detail="The DOCX expands beyond the supported size.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
raise HTTPException(status_code=400, detail="The uploaded file does not match its DOCX extension.") from exc
|
||||
|
||||
|
||||
def _validate_file_signature(extension: str, data: bytes) -> None:
|
||||
matches = {
|
||||
".pdf": data.startswith(b"%PDF-"),
|
||||
".docx": data.startswith(b"PK"),
|
||||
".png": data.startswith(b"\x89PNG\r\n\x1a\n"),
|
||||
".jpg": data.startswith(b"\xff\xd8\xff"),
|
||||
".jpeg": data.startswith(b"\xff\xd8\xff"),
|
||||
".webp": len(data) >= 12 and data.startswith(b"RIFF") and data[8:12] == b"WEBP",
|
||||
}
|
||||
if extension in matches and not matches[extension]:
|
||||
raise HTTPException(status_code=400, detail=f"The uploaded file does not match its {extension[1:].upper()} extension.")
|
||||
if extension in {".txt", ".md"} and b"\x00" in data:
|
||||
raise HTTPException(status_code=400, detail="The uploaded text file contains binary data.")
|
||||
|
||||
|
||||
def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]:
|
||||
page_count = 0
|
||||
extracted_pages = []
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(data))
|
||||
page_count = len(reader.pages)
|
||||
if page_count > MAX_PDF_PAGES:
|
||||
raise HTTPException(status_code=422, detail="The PDF contains too many pages.")
|
||||
for page in reader.pages:
|
||||
try:
|
||||
extracted_pages.append(page.extract_text(extraction_mode="layout") or "")
|
||||
except TypeError:
|
||||
extracted_pages.append(page.extract_text() or "")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
extracted_pages = []
|
||||
|
||||
text = _normalize_text("\n".join(extracted_pages))
|
||||
text = _check_extracted_size(_normalize_text("\n".join(extracted_pages)))
|
||||
if len(text) >= 80:
|
||||
return text, False, page_count
|
||||
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
page_count = doc.page_count
|
||||
if page_count > MAX_PDF_PAGES:
|
||||
doc.close()
|
||||
raise HTTPException(status_code=422, detail="The PDF contains too many pages.")
|
||||
ocr_pages = []
|
||||
total_pixels = 0
|
||||
for page in doc:
|
||||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
|
||||
page_pixels = pix.width * pix.height
|
||||
total_pixels += page_pixels
|
||||
if page_pixels > MAX_PDF_PAGE_PIXELS or total_pixels > MAX_PDF_OCR_PIXELS:
|
||||
doc.close()
|
||||
raise HTTPException(status_code=422, detail="The PDF requires too much OCR processing.")
|
||||
image = Image.open(io.BytesIO(pix.tobytes("png")))
|
||||
_check_image_size(image, pixel_limit=MAX_PDF_PAGE_PIXELS)
|
||||
ocr_pages.append(_ocr_image(image))
|
||||
_check_extracted_size("\n".join(ocr_pages))
|
||||
doc.close()
|
||||
return _normalize_text("\n".join(ocr_pages)), True, page_count
|
||||
return _check_extracted_size(_normalize_text("\n".join(ocr_pages))), True, page_count
|
||||
|
||||
|
||||
def _extract_docx_text(data: bytes) -> str:
|
||||
_validate_docx_container(data)
|
||||
document = Document(io.BytesIO(data))
|
||||
parts = []
|
||||
blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs
|
||||
@@ -1113,11 +1198,15 @@ def _extract_docx_text(data: bytes) -> str:
|
||||
if "role" in style and parts:
|
||||
parts.append("")
|
||||
parts.append(f"- {text}" if "bullet" in style else text)
|
||||
return _normalize_text("\n".join(parts))
|
||||
return _check_extracted_size(_normalize_text("\n".join(parts)))
|
||||
|
||||
|
||||
def _extract_plain_text(data: bytes) -> str:
|
||||
return _normalize_text(data.decode("utf-8", errors="ignore"))
|
||||
try:
|
||||
decoded = data.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="The uploaded text file is not valid UTF-8.") from exc
|
||||
return _check_extracted_size(_normalize_text(decoded))
|
||||
|
||||
|
||||
@app.post("/extract-text")
|
||||
@@ -1135,6 +1224,9 @@ async def extract_text(file: UploadFile = File(...)):
|
||||
raise HTTPException(status_code=400, detail="The uploaded file was empty.")
|
||||
if len(data) > MAX_EXTRACT_FILE_BYTES:
|
||||
raise HTTPException(status_code=400, detail="The uploaded file is too large for AI extraction.")
|
||||
if extension not in {".txt", ".md", ".docx", ".pdf", *IMAGE_EXTENSIONS}:
|
||||
raise HTTPException(status_code=400, detail="This file type is not supported for AI extraction.")
|
||||
_validate_file_signature(extension, data)
|
||||
|
||||
try:
|
||||
if extension in {".txt", ".md"}:
|
||||
@@ -1149,15 +1241,14 @@ async def extract_text(file: UploadFile = File(...)):
|
||||
text, ocr_used, page_count = _extract_pdf_text(data)
|
||||
elif extension in IMAGE_EXTENSIONS:
|
||||
image = Image.open(io.BytesIO(data))
|
||||
_check_image_size(image)
|
||||
text = _ocr_image(image)
|
||||
ocr_used = True
|
||||
page_count = 1
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="This file type is not supported for AI extraction.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"AI extraction failed: {exc}") from exc
|
||||
raise HTTPException(status_code=422, detail="Document extraction failed.") from exc
|
||||
|
||||
if not text:
|
||||
raise HTTPException(status_code=422, detail="AI extraction did not find readable text in the uploaded file.")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
fastapi==0.115.12
|
||||
fastapi==0.141.1
|
||||
starlette==1.6.0
|
||||
uvicorn[standard]==0.34.0
|
||||
transformers==4.48.3
|
||||
cachetools==5.5.2
|
||||
pydantic==2.10.6
|
||||
torch==2.6.0
|
||||
pillow==11.1.0
|
||||
pillow==12.3.0
|
||||
pytesseract==0.3.13
|
||||
pypdf==5.4.0
|
||||
pypdf==6.16.2
|
||||
pymupdf==1.25.5
|
||||
python-docx==1.1.2
|
||||
python-multipart==0.0.20
|
||||
python-multipart==0.0.32
|
||||
|
||||
@@ -2,9 +2,12 @@ import importlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from pypdf import PdfWriter
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -523,6 +526,86 @@ def test_extract_text_rejects_oversized_upload_before_parsing(monkeypatch):
|
||||
assert "too large" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_extract_text_rejects_extension_signature_mismatch(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.post("/extract-text", files={"file": ("resume.pdf", b"not a pdf", "application/pdf")})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "The uploaded file does not match its PDF extension."
|
||||
|
||||
|
||||
def test_extract_text_rejects_binary_plain_text(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.post("/extract-text", files={"file": ("resume.txt", b"Name\x00binary", "text/plain")})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "The uploaded text file contains binary data."
|
||||
|
||||
|
||||
def test_extract_text_rejects_pdf_over_page_limit(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
monkeypatch.setattr(module, "MAX_PDF_PAGES", 1)
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=100, height=100)
|
||||
writer.add_blank_page(width=100, height=100)
|
||||
payload = io.BytesIO()
|
||||
writer.write(payload)
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.post("/extract-text", files={"file": ("resume.pdf", payload.getvalue(), "application/pdf")})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == "The PDF contains too many pages."
|
||||
|
||||
|
||||
def test_extract_text_rejects_image_pixel_limit_before_ocr(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
monkeypatch.setattr(module, "MAX_IMAGE_PIXELS", 50)
|
||||
monkeypatch.setattr(module, "_ocr_image", lambda image: (_ for _ in ()).throw(AssertionError("OCR must not run")))
|
||||
payload = io.BytesIO()
|
||||
Image.new("RGB", (10, 10), "white").save(payload, format="PNG")
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.post("/extract-text", files={"file": ("resume.png", payload.getvalue(), "image/png")})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == "The document image contains too many pixels."
|
||||
|
||||
|
||||
def test_docx_container_rejects_excessive_entries(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
monkeypatch.setattr(module, "MAX_DOCX_ENTRIES", 2)
|
||||
payload = io.BytesIO()
|
||||
with zipfile.ZipFile(payload, "w") as archive:
|
||||
archive.writestr("[Content_Types].xml", "types")
|
||||
archive.writestr("word/document.xml", "document")
|
||||
archive.writestr("word/styles.xml", "styles")
|
||||
|
||||
try:
|
||||
module._validate_docx_container(payload.getvalue())
|
||||
except module.HTTPException as exc:
|
||||
assert exc.status_code == 422
|
||||
assert exc.detail == "The DOCX contains too many files."
|
||||
else:
|
||||
raise AssertionError("Expected the DOCX entry limit to reject the container")
|
||||
|
||||
|
||||
def test_extract_text_does_not_leak_parser_exception(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
monkeypatch.setattr(module, "_extract_pdf_text", lambda data: (_ for _ in ()).throw(RuntimeError("private path C:/secret")))
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.post("/extract-text", files={"file": ("resume.pdf", b"%PDF-invalid", "application/pdf")})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == "Document extraction failed."
|
||||
assert "secret" not in response.text
|
||||
|
||||
|
||||
def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user