24 KiB
PDF Security / Unlock / Protect — Feature Audit Report
Date of Audit: August 13, 2026
Audited Subsystems: Frontend (pdf/frontend), Gateway API (pdf/gateway), C++ PDF Engine (pdf/engine), Pybind11 Bindings (pdf/bindings), Security Tests (pdf/tests/security)
Audit Purpose: Evaluate the exact current state of PDF security, password authentication, permission enforcement, encryption detection, password removal, and PDF protection capabilities in the existing codebase.
1. Executive Summary
The existing PDF editor codebase possesses a robust reading, authenticating, and permission-enforcing pipeline for password-protected PDFs, but lacks all writing/creation capabilities for PDF encryption and protection.
Key findings:
- Reading & Authenticating Encrypted PDFs: FULLY IMPLEMENTED. The system detects encrypted PDFs, prompts the user via a React modal, passes the password to PDFium in C++, validates credentials, returns helpful error messages on invalid passwords, and renders the document upon success.
- Granular Permission Surfacing & Enforcement: FULLY IMPLEMENTED. PDFium extracts security revision numbers, encryption types (RC4, AES-128, AES-256), and permission flags. The Python FastAPI Gateway exposes these flags in
PermissionsResponseand enforces HTTP403 Forbiddenerrors if a user attempts forbidden edits (annotations, text replacements, page rotations) or unauthorized exports. - Password Removal / Unlocking: PARTIALLY IMPLEMENTED (Implicit). Opening a protected PDF with a valid password loads the decrypted document into memory. Exporting the document via
/documents/{id}/exportwrites out an unencrypted PDF file. However, there is no explicit UI button or API endpoint dedicated to "Remove Password". - Protecting / Encrypting PDFs: NOT IMPLEMENTED / MISSING. There is no functionality in the C++ engine (PDFium/QPDF), Pybind11 bindings, Gateway API, or Frontend UI to password-protect an unencrypted PDF, set user/owner passwords, or configure output permissions.
2. User-Facing Capability Summary
CURRENTLY AVAILABLE
- ✓ Open Password-Protected PDFs: Prompts for credentials when an encrypted PDF is uploaded.
- ✓ Password Validation & Error Feedback: Rejects incorrect passwords with clear inline UI feedback and allows unlimited retries.
- ✓ Post-Authentication Rendering & Extraction: Full page rendering, OCR, text extraction, font listing, layout analysis, and display list extraction work seamlessly after authentication.
- ✓ Encryption & Security Inspection: Detects and displays encryption standards (RC4-40, RC4-128, AES-128, AES-256) and security revision level (2 through 6) in the Inspector panel.
- ✓ Permission Enforcement: Gateway blocks unauthorized edits, annotations, page reordering, and exports with HTTP
403 Forbiddenresponses if disallowed by the PDF's security settings. - ✓ Unprotected Export: Exporting an authenticated PDF generates an unencrypted PDF that can subsequently be opened without a password.
NOT CURRENTLY AVAILABLE
- ✗ Explicit Password Removal UI/API: No button or endpoint explicitly labeled "Unlock PDF" or "Remove Security".
- ✗ Password-Protect PDF / Lock PDF: Cannot apply passwords to an unencrypted PDF.
- ✗ Configure Output Permissions: Cannot set or modify permission flags for printing, copying, editing, or annotating.
- ✗ Separate Owner Password Prompting: Prompts only with a generic "Document password" input; does not request owner password specifically when attempting restricted operations.
- ✗ Re-encrypting Edited PDFs: Saved/exported PDFs are saved without encryption.
- ✗ Attempt Rate Limiting: No rate limiting on password validation attempts at the API level.
3. Protected PDF Open Flow
The upload and document initialization flow is traced across the full stack:
User Selects Encrypted PDF
│
▼
[Frontend] gatewayService.uploadDocument(file, password="")
│
▼ (POST /documents)
[Gateway API] upload_document() in crud.py
│
▼
[Pybind11] PdfDocument.load_from_memory(bytes_data, "")
│
▼
[C++ Engine] PdfDocument::loadFromMemory() -> FPDF_LoadMemDocument()
│
▼ (PDFium returns FPDF_ERR_PASSWORD)
[C++ Engine] mapPdfiumError() returns EngineError::PasswordRequired
│
▼
[Pybind11] Throws ValueError("Password required to open this PDF")
│
▼
[Gateway API] Catches ValueError -> Raises HTTP 401 ("Password required")
│
▼
[Frontend] gatewayService catches 401 -> Throws PasswordError
│
▼
[Frontend] App.tsx sets passwordPrompt state -> PasswordModal renders
Exact Code Implementation Points
- Encryption Detection & Password Loading (C++ Engine):
- File:
pdf/engine/src/parser/pdfium_document.cpp - Function:
pdfengine::PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string& password) - C++ API:
FPDF_LoadMemDocument(buffer_copy.data(), static_cast<int>(buffer_copy.size()), password.empty() ? nullptr : password.c_str())
- File:
- Error Mapping (C++ Engine):
- File:
pdf/engine/src/parser/pdfium_internal.cpp - Function:
pdfengine::parser::mapPdfiumError(unsigned long err, bool passwordProvided) - Logic: Maps
FPDF_ERR_PASSWORDtoEngineError::PasswordRequired(ifpasswordis empty) orEngineError::InvalidPassword(ifpasswordwas provided).
- File:
- Pybind11 Translation:
- File:
pdf/bindings/python/pdfengine_py.cpp - Function:
throw_on_error(pdfengine::EngineError err) - Logic: Maps
PasswordRequired->PyExc_ValueError("Password required to open this PDF")andInvalidPassword->PyExc_ValueError("Invalid password provided for this PDF").
- File:
- Gateway Endpoint:
- File:
pdf/gateway/app/routers/documents/crud.py - Endpoint:
POST /documents?password={password}(upload_document) - Logic: Catches
ValueErrorfrom pybind11 and raisesHTTPException(status_code=401, detail="Password required")orHTTPException(status_code=401, detail="Invalid password").
- File:
- Frontend Password Dialog & Resubmission:
- Files:
pdf/frontend/src/lib/gatewayService.ts,pdf/frontend/src/App.tsx,pdf/frontend/src/components/PasswordModal.tsx - Logic:
gatewayServicethrowsPasswordError.App.tsxcatchesPasswordErrorand opensPasswordModal. User enters password, triggering resubmission touploadDocument(file, password).
- Files:
4. Password Authentication & Validation Flow
| Stage | Implementation Status | Evidence / Location |
|---|---|---|
| Password Reaches Backend | IMPLEMENTED | uploadDocument(file, password) in gatewayService.ts:L476 sends POST /documents?password=.... |
| Backend Passes Password to C++ | IMPLEMENTED | crud.py:L173 calls doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password). |
| C++ Engine Validation | IMPLEMENTED | pdfium_document.cpp:L42 executes FPDF_LoadMemDocument(..., password.c_str()). |
| Incorrect Password Rejected | IMPLEMENTED | pdfium_internal.cpp:L280 returns EngineError::InvalidPassword -> HTTP 401 "Invalid password" -> App.tsx shows red error message in modal. |
| Correct Password Accepted | IMPLEMENTED | FPDF_LoadMemDocument returns document pointer -> Gateway stores document info and returns HTTP 201 response. |
| Password Retry Loop | IMPLEMENTED | App.tsx:L650 retains modal open on failure with updated error message, allowing infinite retry attempts. |
| Rate Limiting / Attempt Limit | MISSING | Neither Gateway nor C++ Engine tracks failed attempts or implements delays/lockouts. |
5. Rendering & Feature Support After Authentication
Once authenticated, all engine features operate on the unlocked in-memory PDF handle:
| Feature | Status | Evidence / Implementation Location |
|---|---|---|
| Page Count | IMPLEMENTED | pdfium_document.cpp:L101 (FPDF_GetPageCount) |
| Document Metadata | IMPLEMENTED | pdfium_document.cpp:L109 (FPDF_GetMetaText) |
| Font Inventory | IMPLEMENTED | pdfium_document.cpp:L280, fonts.py:L16 |
| Page Text Extraction | IMPLEMENTED | pdfium_page.cpp:L100, render.py:L172 |
| Page Image Rendering | IMPLEMENTED | pdfium_page.cpp:L50, render.py:L19 |
| Display List Extraction | IMPLEMENTED | pdfium_page.cpp:L300, content.py:L133 |
| OCR Support | IMPLEMENTED | ocr.py:L63 (Runs Tesseract on rendered page image) |
| Layout Model Extraction | IMPLEMENTED | layout.py:L120 (extract_document_model()) |
| Editing Operations | PARTIAL | edits.py:L395 checks permissions. Allowed edits modify stream in-memory. |
| Exporting Document | IMPLEMENTED | export.py:L17 checks canCopy and calls save_full_for_export(). |
6. Unlock / Decrypt Capability
| Action | Status | Description |
|---|---|---|
| Open Protected PDF | IMPLEMENTED | Via upload_document with password. |
| Authenticate | IMPLEMENTED | Validated via FPDF_LoadMemDocument. |
| In-Memory Decryption | IMPLEMENTED | PDFium decrypts document structure in RAM for standard operations. |
| Save/Export Unprotected Copy | IMPLEMENTED (Implicit) | doc.save_full_for_export() calls PDFium's FPDF_SaveWithVersion(doc_, &writer, 0, 14). Because PDFium does not attach an encryption handler during save, the output PDF is unencrypted. |
| Reopen Exported Copy Without Password | IMPLEMENTED | The exported PDF contains no /Encrypt dictionary; reopening requires no password. |
| Explicit "Remove Password" Endpoint / UI | MISSING | No dedicated route (e.g. POST /documents/{id}/unlock) or UI action exists. |
7. Protect / Encrypt Capability
A comprehensive search across C++ Engine (pdf/engine), Pybind11 (pdf/bindings), Gateway (pdf/gateway), and Frontend (pdf/frontend) reveals NO code for creating encrypted PDFs:
- QPDF Encryption Writer: Not implemented.
qpdf_writer.cppcontains stream replacement and appearance helpers, but noQPDFWriter::setEncryptioncalls. - PDFium Encryption Output: PDFium's public writing API lacks native PDF encryption creation functions.
- Python Encryption Libraries:
pikepdfis imported only inpdf/tests/security/test_permissions.pyto generate test fixtures. It is not present in Gateway production services. - Frontend Encryption Controls: No modal, form, or state exists for password-protecting documents.
8. Password Types & Permission Management
Password Types
- User Password: IMPLEMENTED. Used for opening documents.
- Owner Password: PARTIALLY IMPLEMENTED. When opened with an Owner password, PDFium elevates document permissions. The C++ engine detects this by comparing user vs doc permissions (
perms.ownerUnlocked = (p != up)inpdfium_document.cpp:L168) and displays an"Owner"badge inInspectorPanel.tsx:L569. However, there is no UI workflow to enter an owner password separately to unlock restricted actions.
PDF Permissions Matrix
| Permission | Existing Support | Where Implemented |
|---|---|---|
Print (canPrint) |
Surfaced & Displayed | pdfium_document.cpp:L171, store.py:L18, InspectorPanel.tsx:L573 |
Modify (canModify) |
Surfaced & Enforced | pdfium_document.cpp:L172, edits.py:L375, edits.py:L395 (HTTP 403) |
Copy (canCopy) |
Surfaced & Enforced | pdfium_document.cpp:L173, export.py:L30, export.py:L65 (HTTP 403 on Export) |
Extract (canCopy) |
Surfaced & Enforced | Same bit as Copy (0x10) in PDFium spec |
Annotate (canAnnotate) |
Surfaced & Enforced | pdfium_document.cpp:L174, edits.py:L370-374, edits.py:L395 (HTTP 403) |
Fill Forms (canFillForms) |
Surfaced & Enforced | pdfium_document.cpp:L175, edits.py:L376, edits.py:L395 |
Accessibility (canExtractForAccessibility) |
Surfaced & Displayed | pdfium_document.cpp:L176, store.py:L24 |
Document Assembly (canAssemble) |
Surfaced & Enforced | pdfium_document.cpp:L177, edits.py:L377 (HTTP 403 on rotation/deletion) |
High-Quality Print (canPrintHighRes) |
Surfaced & Displayed | pdfium_document.cpp:L178, store.py:L19 |
| Permission Configuration (Writing) | MISSING | No engine or gateway code exists to modify permission flags. |
9. Encryption Algorithm Surfacing
The C++ engine inspects the PDF security handler revision via FPDF_GetSecurityHandlerRevision(doc_):
// pdfium_document.cpp (lines 147-164)
switch (rev) {
case 2: perms.encryption = "RC4-40"; break;
case 3: perms.encryption = "RC4-128"; break;
case 4: perms.encryption = "AES-128"; break;
case 5:
case 6: perms.encryption = "AES-256"; break;
default: perms.encryption = "Unknown"; break;
}
- Surfacing: Mapped to
PermissionsResponse.encryption(document.py:L12) and rendered as a badge in the Inspector panel (InspectorPanel.tsx:L567). - Creation: Encryption creation was not found in the codebase.
10. Frontend UI State
| Capability | Status | Front-End Evidence |
|---|---|---|
| A. Unlock existing protected PDF | IMPLEMENTED | PasswordModal.tsx renders when passwordPrompt state is non-null. |
| B. Remove password / security | MISSING | No UI button or option. |
| C. Protect an unprotected PDF | MISSING | No UI button or option. |
| D. Set a password | MISSING | No input fields for protecting PDFs. |
| E. Configure permissions | MISSING | No permissions toggle matrix in settings or export dialog. |
| F. Export document | IMPLEMENTED | TopBar export button triggers file download. |
11. Gateway / API Endpoints Audit
| Endpoint | Method | Purpose | Implemented Behavior | C++ Call |
|---|---|---|---|---|
/documents |
POST |
Upload & open PDF | Accepts password query param. Passes password to engine. Returns HTTP 401 on missing/wrong password, HTTP 201 with permissions on success. |
PdfDocument::loadFromMemory |
/documents/{id}/export |
GET |
Export PDF | Verifies permissions.canCopy. Returns HTTP 403 if forbidden. Calls save_full_for_export(). |
PdfiumDocument::saveFullForExport |
/documents/{id}/export-remote |
POST |
Export to remote URL | Verifies permissions.canCopy. Streams file to target URL. |
PdfiumDocument::saveFullForExport |
/edits |
POST |
Apply PDF edits | Maps operation types to permissions (canAnnotate, canModify, canFillForms, canAssemble). Returns HTTP 403 if restricted. |
Engine edit APIs |
/documents/{id}/unlock |
N/A | Dedicated unlock | MISSING | N/A |
/documents/{id}/protect |
N/A | Protect document | MISSING | N/A |
12. End-to-End Export & Reopen Verification Scenarios
SCENARIO A: Unprotected PDF -> Protect with Password -> Export -> Reopen -> Prompted for Password
- Status: NOT WORKING / IMPOSSIBLE TODAY
- Reason: "Protect with password" is not implemented anywhere in the backend or engine.
SCENARIO B: Protected PDF -> Enter Password -> Opened -> Export -> Reopen Exported PDF -> No Password Required
- Status: FULLY WORKING TODAY (Implicitly)
- Reason: PDFium loads the decrypted PDF structure into memory. Exporting via
GET /documents/{id}/exportwrites the file without encryption. Reopening the exported file requires no password.
SCENARIO C: Protected PDF -> Enter Wrong Password -> Rejected -> Enter Correct Password -> Opened
- Status: FULLY WORKING TODAY
- Reason: Invalid password returns HTTP 401 with
"Invalid password". The frontend displays"Incorrect password — please try again."and keeps the modal open. Re-submitting with the correct password opens the document cleanly.
13. Security Observations & Risks
- CONFIRMED FROM CODE — Password Passed in Query String:
In
gatewayService.ts:L476, the upload URL is constructed as${this.baseUrl}/documents?password=${encodeURIComponent(password)}. Transmitting passwords in GET/POST URL query parameters poses a security risk because query parameters may be recorded in server access logs or proxy logs. - CONFIRMED FROM CODE — Absence of API Rate Limiting:
In
crud.py:L142-L190, there is no rate-limiting or lock-out mechanism for password validation requests, allowing automated brute-force attempts. - CONFIRMED FROM CODE — Implicit Decryption on Export:
In
export.py:L38, exported PDFs are saved unencrypted. Users who upload a password-protected PDF and subsequently export it will receive an unencrypted file without explicit warning that password protection has been stripped. - CONFIRMED FROM CODE — Plaintext Password In-Memory Only:
Passwords are passed directly to
load_from_memoryand are not persisted indocument_storeor written to disk.
14. Complete Feature Matrix
| Feature | Status | Existing Location | Evidence |
|---|---|---|---|
| Detect encrypted PDF | IMPLEMENTED |
pdfium_document.cpp:L142 |
perms.isEncrypted = (rev != -1) |
| Password popup | IMPLEMENTED |
PasswordModal.tsx:L15 |
<PasswordModal state={passwordPrompt} ... /> |
| Validate password | IMPLEMENTED |
pdfium_document.cpp:L42, crud.py:L173 |
load_from_memory(bytes_data, password) |
| Wrong password handling | IMPLEMENTED |
crud.py:L183, App.tsx:L650 |
HTTP 401 "Invalid password" -> UI error |
| Correct password handling | IMPLEMENTED |
crud.py:L174, App.tsx:L646 |
Returns DocumentInfoResponse -> Document opens |
| Render protected PDF | IMPLEMENTED |
pdfium_page.cpp:L50, render.py:L19 |
Renders tiles/pages post-authentication |
| OCR protected PDF | IMPLEMENTED |
ocr.py:L63 |
Executes Tesseract on authenticated doc pages |
| Edit protected PDF | PARTIAL |
edits.py:L395 |
Enforces permissions, but doesn't re-encrypt |
| Export protected PDF | PARTIAL |
export.py:L17 |
Enforces canCopy, but exports UNENCRYPTED |
| Remove password | PARTIAL |
export.py:L38 |
Exporting strips password (implicit, no explicit API) |
| Export unprotected PDF | IMPLEMENTED |
export.py:L38 |
save_full_for_export() outputs unencrypted PDF |
| Reopen unprotected PDF | IMPLEMENTED |
crud.py:L141 |
Exported file reopens without password |
| Protect PDF | MISSING |
N/A | No code exists to protect/encrypt PDF |
| Set user password | MISSING |
N/A | No functionality to set user password |
| Set owner password | MISSING |
N/A | No functionality to set owner password |
| AES encryption (detection) | IMPLEMENTED |
pdfium_document.cpp:L155-160 |
Revision 4/5/6 mapped to "AES-128" / "AES-256" |
| AES-256 (detection) | IMPLEMENTED |
pdfium_document.cpp:L158 |
Revision 5/6 mapped to "AES-256" |
| RC4 (detection) | IMPLEMENTED |
pdfium_document.cpp:L148-153 |
Revision 2/3 mapped to "RC4-40" / "RC4-128" |
| Print permission | IMPLEMENTED |
pdfium_document.cpp:L171, store.py:L18 |
Surfaced in permissions API |
| Copy permission | IMPLEMENTED |
pdfium_document.cpp:L173, export.py:L30 |
Enforced on Export (returns HTTP 403) |
| Modify permission | IMPLEMENTED |
pdfium_document.cpp:L172, edits.py:L375 |
Enforced on edits (returns HTTP 403) |
| Annotation permission | IMPLEMENTED |
pdfium_document.cpp:L174, edits.py:L370 |
Enforced on annotations (returns HTTP 403) |
| Form permission | IMPLEMENTED |
pdfium_document.cpp:L175, edits.py:L376 |
Enforced on form fills (allows if permitted) |
| Extraction permission | IMPLEMENTED |
pdfium_document.cpp:L173, store.py:L21 |
Surfaced as canCopy |
| Document assembly | IMPLEMENTED |
pdfium_document.cpp:L177, edits.py:L377 |
Enforced on page rotate/delete ops |
| Accessibility permission | IMPLEMENTED |
pdfium_document.cpp:L176, store.py:L24 |
Surfaced as canExtractForAccessibility |
| High-quality printing | IMPLEMENTED |
pdfium_document.cpp:L178, store.py:L19 |
Surfaced as canPrintHighRes |
| Security UI | PARTIAL |
PasswordModal.tsx, InspectorPanel.tsx |
Password prompt modal + Inspector security badge exist |
| Security API | PARTIAL |
crud.py, export.py, edits.py |
Upload & export handle passwords & perms |
| C++ security implementation | PARTIAL |
pdfium_document.cpp |
Document load & permission inspection implemented |
| Pybind security bindings | PARTIAL |
pdfengine_py.cpp:L252 |
DocumentPermissions & load_from_memory bound |
15. Production Readiness Summary
- Opening & Viewing Protected PDFs: PRODUCTION READY. Robust, fully tested with unit tests (
pdf/engine/tests/document_load_test.cpp) and security integration tests (pdf/tests/security/test_permissions.py). - Permission Enforcement: PRODUCTION READY. Gateway correctly returns HTTP 403 Forbidden for restricted edits and exports.
- Password Removal / Unlocking: NEEDS FEATURIZATION. Works implicitly when exporting, but lacks dedicated API routes and UI buttons for explicit unlock workflows.
- Protecting / Encrypting PDFs: NOT PRODUCTION READY (0% IMPLEMENTED). Creation of password-protected PDFs or custom permission dictionaries requires adding QPDF or pikepdf encryption writers to the engine/gateway layer.