July 11, 2026
PDFs uploaded by users should be treated as untrusted files. A document may have come from a scanner or a word processor, but a PDF is capable of containing much more than the text and images visible on its pages.
A valid PDF can include JavaScript, actions that run when the document is opened, embedded files, annotations, rich media, and references to external resources. Some of these features can cause a PDF reader to perform actions the user did not expect. A maliciously constructed file can also be unusually expensive to parse or exploit weaknesses in the software processing it.
The goal is to remove everything we do not need and leave only the document itself. We sanitize the PDF on the uploader’s device, before sending it anywhere else, so that downstream systems receive a newly created document rather than the original file. The original PDF is treated as potentially hostile input and is never passed through unchanged.
Basic filtering
If the bytes cannot be loaded as a PDF, reject the file.
Swift (iOS):
guard let inputDoc = PDFDocument(data: inputData) else { return nil }
Kotlin (Android):
try {
val document = PDDocument.load(java.io.ByteArrayInputStream(bytes))
if (document.isEncrypted) {
document.setAllSecurityToBeRemoved(true)
}
} catch (e: Exception) {
// reject
}
PDDocument.load throws if the bytes are not a PDF. If the file loads and is encrypted, security restrictions are removed so the rest of the sanitizer can read it. That encryption step exists only on Android.
Remove exploits on iOS
On iOS we can take what should be visible, draw it into a new document, and throw the original container away. Each page is drawn into a new PDF context. Vector content stays vector. JavaScript, actions, and the rest of the original object tree are not copied.
func flattenPDFKeepingVectors(inputData: Data) -> Data? {
guard let inputDoc = PDFDocument(data: inputData) else { return nil }
let pageCount = inputDoc.pageCount
guard let firstPage = inputDoc.page(at: 0) else { return nil }
var pageRect = firstPage.bounds(for: .mediaBox)
let outputData = NSMutableData()
guard let consumer = CGDataConsumer(data: outputData as CFMutableData),
let context = CGContext(consumer: consumer, mediaBox: &pageRect, nil) else {
return nil
}
for i in 0..<pageCount {
guard let page = inputDoc.page(at: i) else { continue }
context.beginPage(mediaBox: &pageRect)
context.saveGState()
page.draw(with: .mediaBox, to: context)
context.restoreGState()
context.endPage()
}
context.closePDF()
return outputData as Data
}
Remove exploits on Android
Android does not have an API that makes this as easy. We have no choice but to parse the loaded document with PDFBox: delete JavaScript names, strip /OpenAction, drop launch annotations, remove scripts from form fields while keeping the fields themselves, and delete page-level /AA, /RichMedia, and /Launch keys. Then we save a new byte array.
val document = PDDocument.load(ByteArrayInputStream(inputBytes))
try {
document.documentCatalog.setOpenAction(null)
document.documentCatalog.setActions(null)
for (page in document.pages) {
val cos = page.cosObject
cos.removeItem(COSName.getPDFName("AA"))
cos.removeItem(COSName.getPDFName("RichMedia"))
cos.removeItem(COSName.getPDFName("Launch"))
}
val output = ByteArrayOutputStream()
document.save(output)
output.toByteArray()
} finally {
document.close()
}
Keep reading
-
Shoulder Surfing QR Codes: A Defense
August 19, 2026
QR codes are a convenient way to pair a mobile device with a desktop application or a web application running on the desktop: the desktop displays a code, and the phone scans it. …
-
Sanitizing Images Uploaded by Users
June 27, 2026
Images uploaded by users should be treated as untrusted files. We focus on JPEG because, in most cases, an image uploaded by a user can simply be converted to JPEG first (if …