Secure AI File Uploads: A Fail-Closed Admission Boundary
Checking that a filename ends in .pdf is not a secure upload. Internal extraction and RAG systems pass uploaded bytes straight to OCR, parsers, models and indexes, so a renamed executable, a decompression bomb, malware or a hidden instruction reaches software that trusts it. The fix is a fail-closed admission boundary: quarantine every file, verify its type from independent signals, cap the work it can cause, scan it, parse it in isolation, and release only a specific sanitized artifact.
Why AI ingestion needs its own boundary
A normal upload endpoint protects the web app. An AI document workflow adds interpreters downstream: a PDF library decodes object streams, OCR processes images, archive handlers expand nested files, a model reads the text as language and a RAG pipeline stores it for later users. The OWASP File Upload Cheat Sheet covers the baseline: allowlisted extensions, independent media-type and signature checks, application-generated filenames, size limits, safe storage, authorized uploaders and malware scanning. The semantic layer needs separate decisions, and the OWASP prompt injection cheat sheet lists hidden text in documents and emails among indirect-injection channels. It also describes a quarantined parser with no tool access, kept apart from the privileged component that plans and acts. A clean malware result does not make extracted instructions trustworthy.
The text layer is not what the reviewer saw
Human review of an uploaded PDF does not protect the model. Keuper's study of prompt injection in LLM-generated paper reviews tested white-on-white and tiny-font instructions against several parsers. Every tool that extracts text from the PDF source, including PyMuPDF and Mistral OCR in PDF mode, passed the hidden text into the output as ordinary text. Image-based OCR ignored it. In that test the Gemini web service behaved like an image-based parser and ChatGPT like a text-layer one.
That suggests a cheap control. For documents where the human-visible page is the truth (contracts, invoices, forms), render each page to an image, OCR it, and compare the result with the text layer. Large differences are a review flag, and for some workflows the rendered version can be the only one released. The cost is real: OCR loses fidelity on dense tables and tiny print, so use it where the visible page is the evidence and measure the accuracy loss on your own documents. Screen for zero-width and Unicode tag characters in either path, since OWASP notes tag characters can carry instructions that models read.
Use states instead of an accepted flag
Model each upload as a state machine so workers, operators and audits can tell what has been checked:
received: metadata recorded, body not trusted.quarantined: bytes in private storage with no public download path.type_verified: extension, declared media type, detected signature and business policy agree.malware_cleared: the scanner finished with an allowed result.parsed_isolated: a restricted worker produced an artifact within its limits.content_screened: extracted text passed the semantic policy.released: one specific sanitized artifact version may enter extraction or retrieval.rejected: a stable rejection reason is recorded.
Scanners can be unavailable or return an indeterminate result. Keep the object quarantined and retry under a bounded policy, because a timeout must never be recorded as clean. Managed scanners also have documented limits. Amazon's GuardDuty Malware Protection for S3 quotas list maximums for object size, extracted files and nesting depth, and say objects with an extremely high compression ratio may hit limits even when the extracted file count is below quota. A skipped scan is therefore a real outcome, so give your state model an unsupported result next to clean and malicious, and never let it fall through to released.
Store enough evidence to make each transition reproducible:
{
"upload_id": "upl_1042",
"tenant_id": "tenant_19",
"original_name": "supplier-pack.pdf",
"storage_name": "4d/7a/upl_1042.bin",
"declared_media_type": "application/pdf",
"detected_type": "pdf",
"content_digest": "sha256:stored-server-side",
"compressed_bytes": 1842390,
"page_limit": 300,
"expanded_byte_limit": 50000000,
"parser_profile": "pdf-restricted-v4",
"scanner_version": "managed-scan-policy-3",
"status": "quarantined",
"sanitized_artifact_id": null,
"rejection_code": null
}
Keep the raw object immutable. Any converted or normalized file gets its own digest and provenance, because replacing the original in place destroys the evidence you need to explain a parser failure.
The admission sequence
Resolve tenant, user, workflow and allowed document classes from server context before reading the body, and never let a form field pick a more permissive parser profile. Apply request-size limits at the proxy. Generate the upload ID and storage name on the server, and keep the original filename only as sanitized display text.
Verify the type from independent signals against a short allowlist tied to the business task: normalized extension, declared Content-Type and detected signature, rejecting any mismatch. Office formats are archives, so also reject absolute paths, parent-directory traversal, symlinks, encrypted members and unexpected executables inside them.
Cap the work, not just the bytes: pages, image dimensions, archive members, nesting depth, expanded bytes, compression ratio, parser time, memory, processes and output size. A 2 MB archive that expands to 20 GB is a different input from its size. An Unstructured pull request that bounds quadratic array-stream decoding in a PDF complexity check is repository evidence for one parser, and it is a good reason to enforce limits even when a library claims to handle large inputs. When a budget fires, abort, record which one, and do not rerun the same bytes under a bigger profile.
Write uploads to private storage with no execute permission, and let the upload service enqueue an ID instead of parsing. Run the parser in an isolated worker with an unprivileged identity, a read-only base image, no application secrets, one input mount, a separate output location and no outbound network, and destroy it after the job. Pin parser, OCR, image and archive library versions in the profile so an incident can identify which code touched a file.
Semantic release is a separate decision
Successful extraction proves the parser stayed in budget. A contract may be fine as evidence for a summarizer and forbidden as a source of instructions for an autonomous agent. Screen extracted text as one layer, keep document content apart from system instructions, and never let retrieved text choose tools, permissions or destinations. Indexers and extraction workers should accept only a sanitized artifact reference with its digest, parser profile, scanner result, tenant and policy version, and reject raw upload IDs outright. That one rule stops a later developer from wiring the converter's output around the checks.
The parser is part of the attack surface too. pypdf, one of the most common Python PDF libraries, has published a run of advisories in which a crafted file sends it into an infinite loop, including GHSA-52x6-gq3r-vpf4, which fires only when text is extracted in layout mode and is fixed in 6.13.0. A page-count limit does not help against a loop inside one page. Run extraction in a separate process or container with a wall-clock kill and a memory ceiling, treat a killed worker as a quarantine reason, and pin and update the parser like any other dependency exposed to untrusted input.
Failures never release unchecked files
Reject type mismatches, forbidden inner files, path traversal, password-protected content you cannot inspect, known malware and exceeded hard limits, with a reason that helps a real user and exposes no signatures or internal paths. Keep scanner outages and capacity failures quarantined, with capped retries and an expiry. A parser crash is data and no licence for an unrestricted fallback parser. If semantic screening flags a document attack, an analyst may read the extracted text in a safe view, and a tool-using agent should never receive it.
Test the whole transaction
Test through the real upload API, object storage, queue, scanner adapter, parser worker and release consumer, because unit tests on filename validation miss most of the boundary. Include a valid file for each allowed format, a forbidden extension with a plausible media type, a PDF-named file of other bytes, a valid container with a forbidden inner file, traversal paths and symlinks in an archive, excess members, nesting, pages and image sizes, a parser timeout or crash, scanner results for every state including unsupported and unavailable, a PDF with white-on-white instructions, a raw upload ID sent directly to the indexer, two tenants reading each other's quarantine, and a parser upgrade replayed against the full set.
Assert the negatives: a rejected object never yields a released artifact, a quarantined object has no public URL, an expired scan does not advance, and a timed-out parser process is dead. Alert on any release that lacks its full evidence. That invariant tells you more than a generic upload-error rate. Start with one staging workflow and push a renamed non-document, an over-budget archive, a hidden-text PDF and a scanner outage through it.
References
- OWASP File Upload Cheat Sheet for the upload allowlist, independent type checks, safe naming, size limits and scanning controls.
- OWASP LLM Prompt Injection Prevention Cheat Sheet for hidden text in documents, invisible characters and the quarantined-parser pattern.
- Keuper, Prompt Injection Attacks on LLM Generated Reviews of Scientific Publications for hidden-text behavior of text-layer and image-based PDF parsing.
- Amazon GuardDuty Malware Protection for S3 quotas for scanner object, extraction and depth limits.
- Unstructured pull request 4437 for bounding resource use in a PDF parser complexity check.
- pypdf advisory GHSA-52x6-gq3r-vpf4 for the layout-mode infinite loop on a crafted PDF.