It runs on your PC, not ours. A separate cloud product is planned

How a document gets opened

The parser is where
the bugs live.

That is not a complaint about PDFium in particular; it is true of every PDF parser, and a malformed file is the classic way to set one of those bugs off. So the parsing does not happen in the window. PDFium loads in a separate process, held inside a Windows job object with resource limits, holding one document and nothing else.

A smaller how much one bug can reach is not immunity. No desktop PDF application can promise it will survive every hostile file, and one that says otherwise is guessing. Keep the current release installed, and read anything that matters before you send it on.

Ordinary operations rebuild the file and audit the result as a single PDF revision. Checked field signing and countersigning append a revision instead, because that is what keeps an existing signature valid. Single revision does not mean sanitised: cropped-away content, metadata and attachments all survive it. Removing something for good is Redact, and it is a separate step you take deliberately.

The summary is above. The document itself is below, in full, and is the same file that ships inside the application package.
Back to the workspace

The security policy in full

Status: incomplete. A full threat model is being written and is not finished. This file records what is already decided and enforced in code, and is explicit about what is not yet covered. It will be wrong to treat the gaps below as anything other than gaps.

Reporting a vulnerability

Write to [email protected]. We aim to acknowledge within 5 working days. Please do not post a proof of concept before we have had a chance to respond, and there is no bug bounty.

This route has been wrong twice, so it is worth saying exactly where it stands.

It first said [email protected] when that mailbox did not exist. It was then changed to GitHub's private vulnerability reporting — correct at the time, and now wrong for a different reason: the repository is private, so that Security tab is not reachable by anybody outside the company, which is precisely the population this section is written for.

A security contact nobody reads is worse than none: it converts a private report into a public one when the reporter gives up. If you are reading this and the address bounces, the company is a registered Indian entity, CIN U62013TS2026OPC220112, and its registered office is a matter of public record.

The threat model in one paragraph

Every file this application opens is attacker-controlled: a PDF arrives from a government portal, an employer, or a stranger. There is no server, no account and no network service, so the classic web attack surface does not exist. What replaces it is a large C++ parser reachable from untrusted input, a writer that can silently retain content the user believes they removed, and an operating system that copies documents around behind our back.

What is already enforced in code

ControlWhereWhy
Non-V8, non-XFA PDFium buildPdfiumLibrary, NOTICENo JavaScript engine exists in the process, so embedded PDF JavaScript is structurally unreachable rather than merely disabled.
No user font pathsPdfiumLibrary.EnsureInitialisedm_pUserFontPaths is null: PDFium never loads fonts from arbitrary filesystem locations.
AGG renderer, not SkiaPdfiumLibrary.EnsureInitialisedSmaller attack surface, and matches the build we vendor.
Input bounds before parsingSafetyLimits, PdfDocument.OpenFile size, page count, page dimensions and per-page object counts are checked. PDFium imposes no meaningful limits of its own; refusing absurd input is our job.
Single-revision save, alwaysSavePolicy, SaveAuditSee below. This is the most important control in the codebase. Countersigning is the single, checked exception, and it is described there too.
Pinned native dependencyEngine.targets, NOTICEPDFium is built from source at upstream chromium/8021 and pinned by SHA-256, one engine per architecture. A Release build refuses an engine whose hash is not on that list, and the packaging script checks the engine it packs again; both hashes are below. Bumping it is a security decision with a changelog review, not a routine update.
Lock file + audit on restoreDirectory.Build.propsRestorePackagesWithLockFile, NuGetAudit at low, NuGetAuditMode=all.

Why incremental save does not exist here

PDF permits incremental updates (ISO 32000-1 §7.5.6): a writer may append a new cross-reference section and leave every original byte in place, chained through /Prev. If a user edits a salary figure, covers a line, or deletes a page and we append rather than rewrite, the original content is still in the delivered file and any text extractor recovers it.

So FPDF_INCREMENTAL is never passed, the flag is not exposed through any public API, and SaveAudit re-reads our own output and refuses to return bytes that contain more than one revision. SavePolicyTests asserts this. If that test is ever deleted or weakened, treat it as a security regression, not a test-maintenance decision.

There is exactly one exception, and it is stated here rather than found later. Adding a second signature to an already-signed document appends a revision — PdfSigner.CounterSign, and nothing else in the product can reach that code. It is not a general incremental save: there is no option, no flag and no second caller, and it never runs an editing operation. The hazard the rule above exists to prevent belongs to operations that remove content, and this one removes nothing — the earlier revision is the document the signer was given, which they already have a copy of.

What makes that a fact rather than an assurance is that it is checked on every call, on the finished bytes, before they are returned (CountersignChecks):

  1. The output begins with the input, byte for byte. A redaction cannot hide inside an operation with that property, because a redaction changes bytes that are already there.
  2. Every signature the document already carried still points where it pointed, and every one that verified before still verifies after.
  3. The new signature's byte range spans the whole new file.
  4. Exactly one revision was added — startxref and /Prev each rise by one, no more.
  5. No object was replaced except the plumbing a signature field has to join: the catalog, the AcroForm, and the one page whose annotation list gained the field. Each of those is checked to be its old bytes with nothing changed but that plumbing, so an append that re-emits a page's content stream — a content change wearing an append's clothing — is refused.

Any of the five failing is a refusal, not a warning. If these checks are ever removed or made advisory, the exception above stops being justified and the feature has to go with them.

A countersigned file therefore holds more than one revision on purpose, and the tools say so instead of printing the single-revision line that is true of everything else.

Findings from the threat-model pass of 2026-08-31

Five domains, each researched and then adversarially re-verified. Severity is the researchers'. Anything marked CONFIRM has not been independently verified and must not be relied on.

Critical — these change decisions already made

The prebuilt PDFium we depended on shipped with PartitionAlloc disabled. bblanchon/pdfium-binaries emits pdf_use_partition_alloc = false unconditionally in steps/05-configure.sh, while PDFium's own default for a clang build is true. Use-after-free is the dominant PDFium bug class of 2026 — the pass counted 18 PDFium CVEs between January and August 2026, 14 at CVSS 8.8, 10 of them UAF — and PartitionAlloc is the allocator hardening that makes those bugs hard to weaponise. We were shipping Chrome's PDF parser with Chrome's main UAF defence removed, by someone else's build choice. We had to build PDFium ourselves, and after several attempts we do. As of 2026-08-31 we build a single self-contained pdfium.dll with PartitionAlloc compiled in, for x64 and arm64, at the current upstream revision chromium/8021 against its own pinned Windows SDK with no deviation. Verified by tools/Creasepoint.VerifyPdfium; all 27 tests pass against it, including the hardening assertion the prebuilt fails.

The switchover has landed (corrected 2026-09-02; this section said the opposite for a week after it stopped being true). The shipped engine is the self-built, PartitionAlloc-hardened one. Verified rather than asserted: we pinned one engine per architecture at chromium/8021, and the packaging script recomputes the engine's hash on every pack and refuses to continue if it does not match. You can check it yourself: an MSIX is a zip file, and the engine inside it is at a path that depends on which package you installed.

PackageEngine inside itSHA-256
x64runtimes/win-x64/native/pdfium.dll4e73431a0998df3c7fee821063ce689da664032dcf536a65fff9ba340709f48f
arm64runtimes/win-arm64/native/pdfium.dlle392cbe8a05c0fcd81313517bbd97a3902bc0ef5f06500cd3c702bc5fa580dbb

Each package carries only its own engine, so the other row's path is simply not there. Both hashes were read out of the 1.0.2 packages on 2026-09-24 and match the pinned pair.

Two design rules for that work, from the research pass of 2026-08-31: the gate must verify the engine's hash, not its presence, because an Exists() check passes on a zero-byte file and identity is what the claim rests on; and an MSBuild target is a developer convenience rather than an enforcement boundary, because dotnet publish --no-build bypasses it entirely, so CI needs its own post-publish check.

Independently confirmed on 2026-08-31 by tools/Creasepoint.VerifyPdfium against the exact binary we consumed until the switchover (bblanchon.PDFium.Win32 154.0.8021, win-x64, sha256 2a9031fa88f412147c3bc7115054550048c724db6ea70298b6c6b0d13e513882): XFA absent, V8 absent, renderer AGG, no PartitionAlloc markers. EngineProfileTests kept that finding executable while the prebuilt shipped. Since the switchover it asserts the opposite, and fails when the engine the process loads shows no PartitionAlloc markers.

PDFium's save flags are a bitfield and were renumbered upstream. FPDF_REMOVE_SECURITY moved to 1<<2; the legacy value 3 is bit-for-bit FPDF_INCREMENTAL | FPDF_NO_INCREMENTAL. Assigning rather than OR-ing can silently flip a save into incremental mode. This was a live bug in this repository — SavePolicy replaced the no-incremental flag when removing a password, so "unlock this PDF" could have appended and shipped the encrypted original inside the unlocked file. Fixed, with a regression test, and the flag combination is now asserted before every save.

Whiteout must not exist as a drawing tool. A white rectangle over text leaves the text extractable while looking, to the user, exactly like redaction. In a product branded on privacy that is not a rough edge, it is a trap we built. It is removed from the design; an "erase" tool may only ship if it genuinely removes the underlying page objects.

Editor plus signature validation in one binary is the NDSS 2021 shadow-attack toolchain. Shadow attacks hide and replace content in signed PDFs using exactly incremental updates; 16 of 29 tested viewers failed. If we also show a "signature valid" badge we become the last line of defence for an attack class our own editor makes easy. Signature validation must implement the CCS 2019 ByteRange algorithm, not naive PKCS#7 verification, which is broken three separate ways (USF, ISA, SWA).

Signature-gated guards fail open. PDFium's CollectSignatures() reads only the top level of /AcroForm/Fields — no /Kids recursion, no /FT inheritance. A signature field nested one level down reports zero signatures, which would unlock password removal and editing on a signed document. Any guard conditioned on FPDF_GetSignatureCount() needs its own traversal.

Do not LoadLibrary a vendor PKCS#11 middleware DLL. That loads proprietary third-party code into the process holding the user's documents and PIN. Use the Windows certificate store and CNG instead, which keeps the PIN out of our address space entirely. Indian DSC tokens register CSP/KSP providers, so this is a viable path — to be confirmed against real ePass2003 and ProxKey hardware.

On Android, an in-process ad SDK inherits our UID, our app-private storage, and every persisted SAF URI grant. Google's own remedy, the Privacy Sandbox SDK Runtime, was retired on 2025-10-17 and every Android Privacy Sandbox feature is now marked for phaseout. There is no mechanism, present or announced, that fixes this. The ad-supported build cannot carry the same privacy sentence as the ad-free one — permanently.

High — telemetry channels we do not control

Windows Error Reporting. A PDFium heap-corruption crash calls __fastfail, which Microsoft documents as bypassing every exception handler and invoking WER regardless. A crash dump of the process parsing a user's document can contain that document's plaintext. The commonly cited WerSetFlags/unhandled-exception-filter opt-outs do not survive a fast-fail. The only control that actually works is having the parse happen in a separate, minimal child process.

Microsoft Store Partner Center Health. Publishing through the Store enrols the publisher in crash reporting: counts and stack traces from users' machines are delivered to ARHAS, with no code from us and no opt-in. Reworded and closed on 2026-09-02. Invariant 1 no longer says "no telemetry" flatly: it says we collect none of our own, that Windows reports crashes to Microsoft regardless, and that a Store listing shows the publisher anonymous crash counts — neither of which is ours to switch off. The privacy policy says the same thing near the top, and adds the part that matters more: a dump taken from the process that was reading a document can contain that document.

Store re-signing breaks package-level verification. The Store re-signs our MSIX and is documented to mutate the manifest version field, so no user can hash-compare what they installed against anything we published. The strongest verifiable statement about a Store binary is per-file, not per-package, and the claim ladder must say so.

The IME sees every keystroke. An editor means the user types. On Android the keyboard is a third-party, network-capable application; there is no boundary available, only IME_FLAG_NO_PERSONALIZED_LEARNING as a request. This is unavoidable and must be disclosed rather than papered over.

Corrections the verifiers made to the research itself

Recorded because they are the kind of plausible-sounding mitigation that would have shipped:

Still open — no mitigation designed

  1. ~~No process isolation for parsing on Windows.~~ Built. PDFium now runs only in Creasepoint.Worker, confined by a job object and self-applied mitigation policies, and SandboxTests measures the confinement rather than assuming it. Still missing: ACG, which needs the worker published with NativeAOT, and reducing what a WER dump of the worker can contain.
  2. ~~GitHub Actions are referenced by tag, not commit SHA.~~ Done. Every uses: in both workflows is pinned to a full commit SHA with the tag recorded in a trailing comment, and .github/dependabot.yml opens a pull request when a pin moves, so the pins cannot rot into a different problem.
  3. No fuzzing. PDFium has in-tree targets under testing/fuzzers/; we have none.
  4. ~~Redaction stays unimplemented until it passes an adversarial corpus.~~ Shipped, and the adversarial corpus has not been built. Redaction works on one page at a time and removes every page object the mark touches, whole, rather than drawing over it, and the window and the command line both read the saved file back and count the removed text before they report success. What it cannot reach, and says so before it runs: a form field's value, which is held in the document's form data rather than on the page, and part of an image, because an image is one object and goes whole. Still open: removing glyphs is necessary and not sufficient (PETS 2023), and the attacks that paper describes, which work from the positions of the text left behind, have not been run against it.
  5. Metadata is not stripped on save. /Info, XMP, /ID, attachments and /OpenAction survive. Note that PDFsharp re-stamps /Producer at save time and republishes the source document's original producer, which no later pass can undo.
  6. No temp-file policy.
  7. The shell context-menu handler, the Tesseract/Leptonica pipeline, and the Android JNI layer were all outside the scope of this pass and have had no security review at all.
  8. ~~The CLI parses in-process.~~ Built. Every document command routes through SandboxedDocumentEngine; PDFium is not mapped in the front end at all, measured by sampling both processes' loaded modules across a 4,000-page split (424 samples, present in the worker on 402, in the front end on none). An adversarial review of the new boundary raised 34 findings, of which 5 were real and are fixed: an unreachable IsFaulted branch that let framing violations escape as AggregateException past the WorkerException contract, an unbounded ReadString, unfiltered worker text reaching the terminal, two request paths missing the oversize guard, and a quadratic SplitToPages that exceeded the worker's call timeout on large documents. Still open here: the worker replies are trusted for their content — a compromised worker can return a document that is not the one it was asked to build, and the host cannot tell without parsing, which is the thing it is refusing to do.
  1. The shipped package contains the .NET networking assemblies, and apart from the timestamp helper our code never calls them. A self-contained publish carries the whole runtime, so System.Net.Http.dll and its siblings are inside the MSIX. NoNetworkingTests proves that none of our assemblies references them except Creasepoint.Timestamp, the separate program that makes the optional timestamp request, which is what our claim about our own code rests on — but anyone who unzips the package will find HTTP libraries, and "our code does not call them" is a weaker answer than "they are not there." Trimming would remove them and shrink the package, but Avalonia's DesignerSupport and the built-in COM interop the shell needs are not trim-compatible (IL2104, IL2026), and forcing it past those warnings risks a UI that fails by reflection at run time. Left as-is deliberately. Do not describe the package as containing no networking code — say what is true: apart from the optional signature timestamp, no code we wrote makes a network request.

Three native files also show Windows networking functions in their import tables, and the same rule applies to them (measured in the 1.0.2 packages, x64 and arm64, on 2026-09-24). tesseract.dll, the OCR engine, imports ten from WS2_32.dll - getaddrinfo, freeaddrinfo, WSAStartup, WSACleanup, socket, connect, send, recv, select and closesocket - which its strings tie to ScrollView, Tesseract's own debugging viewer; no OCR feature switches it on. msquic.dll is the .NET runtime's HTTP/3 library, and nothing in this application asks for HTTP/3. createdump.exe is the runtime's crash-dump helper, which imports WSAStartup, gethostname and WSAGetLastError, and this application does not use it. An import is not a call, and PRIVACY.md §1 tells anyone who reads the tables what they will find.

What we do not claim

We do not claim this application cannot reach the network. Windows does not let an ordinary desktop application permanently surrender network access, and saying otherwise would violate the second invariant of the project. The wording that is actually defensible is the one on the trust page: apart from the optional signature timestamp, there is no code in this application that makes a network request, the build asserts it, and Windows does not enforce a network boundary for a full-trust desktop application.

We do not claim reproducible builds, and we do not offer signed build provenance either. The engine builds ask for a provenance attestation, but GitHub does not issue one for a private repository owned by an individual account, and this repository is private. What stands in its place is a checksum: each engine is pinned by SHA-256, a Release build refuses one whose hash is not on the list, and both hashes are in the table above for anybody to check against the package they hold.