pdf-quiz-generator/backend/app/services/file_intake.py
Daniel d591813f02 fix: an upload is what its bytes say, not what its name claims
The document matcher decided by extension and fell through to "decode whatever
this is as UTF-8" for everything that was not a .pdf. A shell script, an HTML
page, a CSV or a JPEG all became a search query, and a file called report.pdf
holding something else was read as a PDF.

Three questions now, in order, before anything else touches the file: is it
under 2 MB (read one byte past the cap, so a huge file is never held in memory
to be measured); what is it, sniffed from the leading bytes; and what text is
in it. PDF, DOCX and images, and nothing else — 415 with a sentence naming what
to upload instead. DOCX is parsed from the zip with no new dependency and is
checked against the one attack that shape allows, a member that unpacks to far
more than the file's size suggests. An image is read by the tool model, and
where no tool model is configured it says so rather than 500ing.

On injection, since that is the question people mean: there is no path from an
uploaded file to code that runs. The extracted text is a search query — bound
parameter to Postgres, never concatenated into SQL — and the content of a
message to a model. It is never rendered as HTML, never written to disk, never
passed to a shell. Control characters are stripped because they make queries
that match nothing, not because anything would interpret them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:05:12 +02:00

164 lines
6.3 KiB
Python

"""What a person may upload, decided by the bytes rather than by the name.
Three questions, in this order, and all three before anything else touches the
file:
1. **Is it small enough.** Read one byte past the cap and refuse if that byte
exists, so an enormous file is never held in memory to be measured.
2. **What is it, actually.** Sniffed from the leading bytes. An extension is a
claim the uploader makes, and `report.pdf` holding a shell script is not a
PDF; the type we act on is the one the file's own header states.
3. **What text is in it.** PDF and DOCX are parsed for their text; an image is
read by the tool model. Nothing is executed, ever, and none of it is stored.
On code injection specifically, because it is the question people mean when
they ask about uploads: there is no path from an uploaded file to code that
runs. The extracted text is used as a *search query* — passed as a bound
parameter to Postgres, never concatenated into SQL — and as the content of a
message to a language model. It is never rendered as HTML (the reader escapes
raw HTML in Markdown), never written to disk here, never passed to a shell, and
never `eval`'d. A DOCX is a zip file, so it is also checked against the one
attack that shape does allow: a member that expands to far more than the cap.
"""
import io
import re
import zipfile
#: Two megabytes. Not a technical limit — a PDF of a lecture is comfortably
#: under it and a scanned textbook chapter is not, and the second one is a
#: different feature.
MAX_BYTES = 2 * 1024 * 1024
#: How far a DOCX may expand once unzipped. A 40 KB zip that becomes a
#: gigabyte of XML is the only interesting thing about the format's shape.
MAX_UNZIPPED = 40 * 1024 * 1024
PDF = "pdf"
DOCX = "docx"
IMAGE = "image"
IMAGE_TYPES = {
b"\x89PNG\r\n\x1a\n": "image/png",
b"\xff\xd8\xff": "image/jpeg",
b"GIF87a": "image/gif",
b"GIF89a": "image/gif",
}
#: What the upload control offers, and what the error message names. Kept here
#: so the two cannot drift apart.
ACCEPT = ".pdf,.docx,.png,.jpg,.jpeg,.gif,.webp"
ACCEPT_HUMAN = "a PDF, a Word document (.docx), or an image"
class Rejected(ValueError):
"""The file may not be taken, with the sentence to show the person."""
def media_type(data: bytes) -> str | None:
"""The image type these bytes are, or None if they are not an image."""
for signature, mime in IMAGE_TYPES.items():
if data.startswith(signature):
return mime
# RIFF....WEBP — the size sits between the two markers.
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
def kind_of(data: bytes) -> str | None:
"""pdf, docx, image — or None for anything else at all."""
if data.startswith(b"%PDF-"):
return PDF
if media_type(data):
return IMAGE
# Every OOXML file is a zip whose first member is [Content_Types].xml. A
# plain zip, a jar and an xlsx all start the same way, so the manifest is
# what separates a Word document from them.
if data[:2] == b"PK" and b"word/document.xml" in data[:4096] + data[-4096:]:
return DOCX
if data[:2] == b"PK":
try:
with zipfile.ZipFile(io.BytesIO(data)) as bundle:
if "word/document.xml" in bundle.namelist():
return DOCX
except zipfile.BadZipFile:
return None
return None
def read(upload) -> bytes:
"""The file's bytes, refused if there are too many of them.
One byte past the cap is read deliberately: its presence is the test, and
reading it costs one byte rather than the whole file.
"""
data = upload.file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise Rejected(
f"That file is over {MAX_BYTES // (1024 * 1024)} MB. Upload a "
"smaller one — a single chapter or handout rather than a whole book.")
if not data:
raise Rejected("That file is empty.")
return data
def _docx_text(data: bytes, limit: int) -> str:
try:
with zipfile.ZipFile(io.BytesIO(data)) as bundle:
total = sum(info.file_size for info in bundle.infolist())
if total > MAX_UNZIPPED:
raise Rejected("That Word document unpacks to far more than its size suggests.")
xml = bundle.read("word/document.xml").decode("utf-8", errors="ignore")
except Rejected:
raise
except Exception:
raise Rejected("That Word document could not be read.")
# Paragraph and line breaks become newlines before the tags go, so the text
# does not run together into one sentence.
xml = re.sub(r"</w:p>", "\n", xml)
xml = re.sub(r"<w:br[^>]*/?>", "\n", xml)
xml = re.sub(r"<[^>]+>", "", xml)
return clean(xml)[:limit]
def _pdf_text(data: bytes, limit: int) -> str:
try:
import fitz
with fitz.open(stream=data, filetype="pdf") as document:
return clean(" ".join(page.get_text() for page in document))[:limit]
except Exception:
raise Rejected("That PDF could not be read.")
def clean(text: str) -> str:
"""Printable text, with the control characters gone.
Not a security measure — nothing downstream interprets them — but a file
full of NULs makes a query that matches nothing and an error nobody can
read, and stripping them is one line.
"""
text = text.replace("\x00", " ")
text = re.sub(r"[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]", " ", text)
return re.sub(r"[ \t]{2,}", " ", text).strip()
def text_from(data: bytes, kind: str, limit: int, *, describe=None) -> str:
"""The words in this file.
`describe` is how an image becomes text: a callable taking (bytes, mime)
and returning what the tool model read in it. Passed in rather than
imported so this module stays a file-handling module — and so the caller,
which knows whether a tool model is even configured, decides.
"""
if kind == PDF:
return _pdf_text(data, limit)
if kind == DOCX:
return _docx_text(data, limit)
if kind == IMAGE:
if describe is None:
raise Rejected(
"Reading an image needs a tool model, and no model is configured "
"for that job. Upload a PDF or a Word document instead.")
return clean(describe(data, media_type(data) or "image/png"))[:limit]
raise Rejected(f"Upload {ACCEPT_HUMAN}.")