data-extract / app /utils /validators.py
validops-east-1's picture
feat: updated
936f0bf
Raw
History Blame
3.89 kB
from __future__ import annotations
import io
import ipaddress
import socket
from urllib.parse import urlparse
import httpx
import structlog
from fastapi import UploadFile
from app.core.config import settings
from app.core.exceptions import FileTooLargeError, InvalidFileTypeError, InvalidParameterError
logger = structlog.get_logger(__name__)
_PDF_MAGIC = b"%PDF"
_MAX_URL_REDIRECTS = 5
_ALLOWED_SCHEMES = {"http", "https"}
_PRIVATE_RANGES = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
]
async def read_and_validate_pdf(upload: UploadFile) -> bytes:
buffer = io.BytesIO()
total = 0
chunk_size = 64 * 1024
while chunk := await upload.read(chunk_size):
total += len(chunk)
if total > settings.MAX_FILE_SIZE_BYTES:
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
buffer.write(chunk)
if total == 0:
raise InvalidFileTypeError("Uploaded file is empty")
pdf_bytes = buffer.getvalue()
_assert_pdf_magic(pdf_bytes)
logger.debug("pdf_upload_validated", size_bytes=total)
return pdf_bytes
async def fetch_pdf_from_url(url: str) -> bytes:
_validate_url_scheme(url)
_validate_url_host(url)
async with httpx.AsyncClient(
follow_redirects=True,
max_redirects=_MAX_URL_REDIRECTS,
timeout=httpx.Timeout(30.0, connect=10.0),
) as client:
try:
response = await client.get(url)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise InvalidFileTypeError(
f"Failed to fetch PDF from URL: HTTP {exc.response.status_code}"
) from exc
except httpx.RequestError as exc:
raise InvalidFileTypeError(f"Failed to fetch PDF from URL: {exc}") from exc
_validate_url_host(str(response.url))
content_length = response.headers.get("content-length")
if content_length and int(content_length) > settings.MAX_FILE_SIZE_BYTES:
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
pdf_bytes = b""
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
pdf_bytes += chunk
if len(pdf_bytes) > settings.MAX_FILE_SIZE_BYTES:
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
if len(pdf_bytes) == 0:
raise InvalidFileTypeError("URL returned an empty response")
_assert_pdf_magic(pdf_bytes)
logger.debug("pdf_url_validated", url=url, size_bytes=len(pdf_bytes))
return pdf_bytes
def _assert_pdf_magic(data: bytes) -> None:
if not data.startswith(_PDF_MAGIC):
raise InvalidFileTypeError(
"File does not appear to be a valid PDF (missing PDF magic bytes)"
)
def _validate_url_scheme(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
raise InvalidParameterError(
f"URL scheme '{parsed.scheme}' is not allowed. Only http and https are permitted."
)
def _validate_url_host(url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise InvalidParameterError("URL has no valid hostname")
try:
resolved_ip = socket.gethostbyname(hostname)
addr = ipaddress.ip_address(resolved_ip)
except (socket.gaierror, ValueError):
raise InvalidParameterError(f"Could not resolve hostname: {hostname}")
for private_range in _PRIVATE_RANGES:
if addr in private_range:
raise InvalidParameterError(
"Requests to private or loopback IP addresses are not permitted (SSRF protection)"
)