Skip to content

fix(acquisition): accept PDFs that carry only an owner password - #208

Open
L4XB wants to merge 1 commit into
mainfrom
fix/upload-permission-encrypted-pdf
Open

L4XB wants to merge 1 commit into
mainfrom
fix/upload-permission-encrypted-pdf

Conversation

@L4XB

@L4XB L4XB commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Closes #115

Summary

validate_pdf_structure refused every PDF whose is_encrypted flag is set. That flag is also true for a PDF with an empty user password and only an owner password, which opens in every reader and merely restricts printing or copying. This is how publishers commonly ship article PDFs. Such a file was refused with "encrypted PDFs are not supported".

The check now calls reader.decrypt("") and refuses only when pypdf returns PasswordType.NOT_DECRYPTED, with the message unchanged. No other password is tried, and none is accepted, stored or logged. The page-count limit and the page-tree walk run after decryption exactly as before.

One case the issue did not mention: pypdf checks the empty password without AES, but it can only decrypt AES-encrypted content through an optional crypto backend (cryptography or pycryptodome). The API ships pycryptodomex, whose Cryptodome namespace pypdf does not look for, so pypdf runs on its RC4-only fallback here. Without a guard, an AES-128 owner-only PDF would pass validation and then be stored with no text. For an encrypted file, the check therefore also decrypts the first page's content stream once. A DependencyError from pypdf now maps to "encrypted PDFs are not supported". Before, an AES-256 file was refused as "not a structurally valid PDF".

Behavior and compatibility

Only services/api/src/sixsentences_server/acquisition/upload.py (validate_pdf_structure) changes in source. Measured on synthetic files built with pypdf 6.19.0 (the locked version), using the API's locked environment:

file main this branch
RC4-40 or RC4-128, owner password only refused: encrypted accepted, text parsed
RC4-40 or RC4-128, user password refused: encrypted refused: encrypted
AES-128, owner password only refused: encrypted refused: encrypted
AES-128, user password refused: encrypted refused: encrypted
AES-256, owner password or user password refused: not a structurally valid PDF refused: encrypted

With cryptography added to the same environment, the same code accepts the AES-128 and AES-256 owner-only files with text parsed and still refuses every user-password file. Making AES work therefore needs only a crypto backend for pypdf. CONTRIBUTING asks for an issue before a new dependency, so that is not part of this change. The proposal with these measurements is #209.

No API, event, schema or migration change. Rollback is a revert.

Validation

cd services/api && uv sync --frozen --extra dev
uv run --frozen --project services/api pytest services/api/tests -q        # 3453 collected, exit 0; the 3 PostgreSQL-only tests skip outside CI
(cd services/api && uv run --frozen ruff check src tests)               # All checks passed
uv run --frozen --project services/api mypy services/api/src/sixsentences_server/acquisition/upload.py   # no issues
uv run --frozen --project services/api python services/api/scripts/audit_community_export.py services/api   # audit passed, manifest refreshed

ruff format --check on tests/test_documents.py reports the same five hunks as on main, all in older tests. The lines added here are formatted.

New tests in services/api/tests/test_documents.py build every PDF in the test from _mini_pdf and encrypt it with RC4-128, which pypdf writes and reads without a crypto backend:

  • an owner-only PDF ingests with text_status == "parsed", and its title comes from the extracted text;
  • a PDF with a user password is still refused with exactly "encrypted PDFs are not supported";
  • an owner-only PDF over the page limit is still refused (limit patched to 1);
  • an owner-only PDF whose page tree cannot be resolved is still refused;
  • an encrypted PDF whose content raises pypdf's DependencyError, as AES does without a backend, is refused as encrypted.

Five mutants of the change each fail at least one of these tests: refusing every encrypted PDF (as on main), accepting every encrypted PDF, dropping the content check, dropping the DependencyError mapping, and skipping the page checks once a file is decrypted.

Engine, worker, migrations, web app, browser extension, Companion and self-hosting are unaffected.

Review boundaries

The decrypt attempt runs before bytes are persisted or handed to another parser, like the rest of this function. No password input is added, and the only value ever passed to decrypt is the empty string. Bytes reaching the extractor are the uploaded bytes, unchanged. The extractors (PdfTextExtractor and the page helpers in acquisition/pdf.py) already open such files through pypdf's automatic empty-password attempt, so they need no change. No dependency, network call or processor is added.

Source-release hygiene

Every test file is generated in the test. No publisher PDF, upload or other data is committed. CHANGELOG.md has an entry under Unreleased. The commit is signed and carries my DCO sign-off, and the CLA acceptance sentence follows as a standalone comment.

Visual evidence

None. No UI change.

validate_pdf_structure refused every PDF with is_encrypted set, which is
also true for a PDF with an empty user password and only an owner
password: it opens in every reader and only restricts printing or
copying, which is how publishers commonly ship article PDFs.

Try the empty password and refuse only when pypdf reports
NOT_DECRYPTED, with the message unchanged. No other password is tried.
The page limit and the page-tree walk still run after decryption.

pypdf checks the empty password without AES but needs an optional
crypto backend to decrypt AES content, and the API has none that pypdf
detects. Decrypt the first page's content once, and refuse a file this
server cannot decrypt as encrypted (pypdf's DependencyError) instead of
storing it without text. That also replaces "not a structurally valid
PDF" for AES-256 files.

Closes #115

Signed-off-by: L4XB <lukas.buck@e-mail.de>
@L4XB

L4XB commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

I have read and agree to the SixSentences CLA v1.0.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

acquisition: accept the permission-encrypted PDFs publishers actually hand out

1 participant