This document describes what the application protects, against whom, by what means, how each guarantee is verified automatically, and what it does not guarantee. It is the reference for code review: a change that weakens any of the controls below must say so explicitly and update this document.
Reporting a vulnerability: see SECURITY.md.
Users drop identity documents, tax notices, payslips and rent receipts: critical documents. The assets to protect are therefore:
| Asset | Expected property |
|---|---|
| Dropped files (PDF, images) | Confidentiality: they never leave the device. |
| Hidden content of the files (text layer, metadata, EXIF/GPS, attachments) | Non-disclosure: absent from the output file. |
| Output file | Integrity of the watermark: it cannot be removed by simply editing the PDF. |
| Passwords (opening a locked PDF, encrypting the output) | Confidentiality: in memory only, never persisted or transmitted. |
| Signature image (drawn, typed or uploaded), and the name, place and note typed with it | Confidentiality: in memory only, never persisted or transmitted (§4.4 quater, §4.10). |
| Served code | Integrity: no third-party script, no unapproved inline script. |
The application is a static export (Next.js output: "export") served
by Cloudflare Pages. There is no application server, no database, no API,
no account, no cookie, no analytics. All processing happens in the
browser:
file ──► pdf.js (Web Worker) ──► canvas ──► watermark ──► JPEG/PNG ──► pdf-lib ──► [AES-256] ──► download (blob:)
The sign tool (/<lang>/sign) is a second mode of the same component and
the same engine: the signature is painted into the page pixels between the
masks and the watermark, and the output is always a PDF (§4.4 quater):
file ──► pdf.js (Web Worker) ──► canvas ──► [masks] ──► signature ──► [watermark] ──► JPEG/PNG ──► pdf-lib ──► [AES-256] ──► download (blob:)
The remaining attack surface is therefore:
- Hostile files parsed by pdf.js, libheif and the browser's image decoders.
Sign mode adds one decoder path and no library: the uploaded signature
image, which goes through the same header pre-check, the same
createImageBitmap/libheif decoding and the same pixel cap as document images (§4.3). - Script injection (XSS) that could read the files and exfiltrate them.
- The supply chain: npm dependencies, CI actions, host.
- Leakage through the output file: original data that would survive processing.
- The service worker, which controls every request of the origin.
| # | Threat | Attacker | Controls | Verification |
|---|---|---|---|---|
| T1 | Exfiltration of the files (or of the signature) to a third-party server | Injected script, compromised dependency | CSP connect-src 'self', default-src 'self' (§4.1), on the sign pages too |
ui.smoke.e2e.ts (watermark and sign modes: exfiltration blocked, zero off-origin requests), security-headers.test.ts, security.static.test.ts |
| T2 | XSS via file name, watermark text, localStorage |
Booby-trapped file or link | React escaping, no dangerouslySetInnerHTML/eval, hash-based CSP (§4.2) |
security.static.test.ts |
| T3 | Code execution via a booby-trapped PDF (PDF JavaScript, XFA, malicious font) | Sender of the document | pdf.js 6 without an eval path, XFA disabled, rendering in a Worker, CSP without unsafe-eval (§4.3) |
watermark.seam.e2e.ts (adversarial corpus) |
| T4 | Leakage of hidden data in the output (text, author, GPS, attachment, JS, links) | Recipient of the watermarked or signed document | Full rasterisation, re-encoding of images (§4.4); a signed page is rebuilt the same way (§4.4 quater) | watermark.seam.e2e.ts (sanitisation, also on a signed hostile PDF), sign.e2e.ts |
| T5 | Removal of the watermark, or moving or deleting the signature, by editing the PDF | Recipient | The watermark and the signature are blended into the pixels, not layers (§4.4, §4.4 quater) | same |
| T6 | Cryptographic attack on the protected PDF | Recipient, thief of the file | AES-256 (PDF 2.0, /V 5 /R 6), never RC4, generated secret of 132 bits (12 words) or 256 bits (§4.5) | file-password.test.ts |
| T7 | Zip-Slip when extracting the downloaded archive | Forged file name | Only the basename is kept, names de-duplicated (§4.6) | files.test.ts |
| T8 | Clickjacking, tab-nabbing, Referer leakage | Third-party site | frame-ancestors 'none', X-Frame-Options, COOP, noopener noreferrer, Referrer-Policy (§4.7); only /embed/* can be framed, and only by cachety.com and the sites in partners.json (embed.md) |
security-headers.test.ts, security.static.test.ts, embed.e2e.ts |
| T9 | Vulnerable or compromised dependency | Supply chain | Frozen lockfile, bun audit in CI + weekly, Dependabot, actions pinned by SHA (§4.8) |
CI |
| T10 | Poisoned or stale service worker | Network, old deployment | Same origin, GET only, revalidation of sw.js (§4.9) |
security-headers.test.ts |
| T11 | Reuse of the signature by a recipient (cut out of the signed copy and pasted elsewhere) | Recipient | Not prevented technically, as with a scanned paper signature: the legal section and the FAQ of the sign page say so; the "Also add a watermark" option ties the copy to a purpose and a recipient; initials on every page make page substitution visible (§4.4 quater, §6) | site-copy.test.ts (the FAQ says it in all 11 languages); no technical verification, since nothing technical is claimed |
The Content-Security-Policy (public/_headers) is the technical proof that nothing leaves:
default-src 'self'; script-src 'self' <sha256 hashes> 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:;
worker-src 'self' blob:; connect-src 'self' blob: data:; object-src 'none';
base-uri 'none'; frame-ancestors 'none'; form-action 'none'
connect-src 'self':fetch, XHR, WebSocket andsendBeaconto another domain are refused by the browser, even if a malicious script managed to run.form-action 'none',base-uri 'none': no exfiltration through a form or by hijacking relative URLs.- No external resource (font, CDN, analytics): fonts are self-hosted
(
@fontsource), and the pdf.js resources are copied intopublic/pdfjs/.
One policy per page. Each page carries its own React Server Components
payload, so each has different inline bootstrap scripts. A single policy
listing every hash would exceed Cloudflare Pages' limit of 2,000 characters
per _headers line, and Cloudflare silently ignores an over-long rule, which
would leave no CSP at all. Cloudflare also joins two matching rules that set
the same header with a comma, which makes the browser enforce both
policies. The build (scripts/gen-sw.mjs) therefore:
- removes the CSP from the
/*block (which keeps every other header); - adds one exact-path rule for each page outside a language folder (
/,/about,/sign, and each home page/fr,/ja…), whosescript-srclists only that page's hashes; - groups the pages of each language into one rule (
/fr/*), listing the hashes of that language's pages only. If that line would exceed the limit, the language falls back to one rule per guide or comparison section (/fr/guides/*) and one exact rule per other page. Each page still carries its exact<meta>policy, and the browser enforces both, so a page can only run its own scripts; - adds one rule for the partner embed pages (
/embed/*), the only ones that may be framed:frame-ancestors 'self'plus the origins ofpartners.json,X-Frame-Optionsremoved there (! X-Frame-Options), andX-Robots-Tag: noindex, nofollow. The embedded tool only sends its height to the parent page (see embed.md); - adds a strict rule with no hash at all for
/_next/*,/pdfjs/*and/sw.js(scripts, the pdf.js worker and the service worker); - fails the build if the result exceeds 100 rules or 2,000 characters per line.
404.html is served for unknown URLs, so no path rule matches it. It is
covered by its <meta> policy (below).
Sign pages. /<lang>/sign and /sign are ordinary pages of the export,
with the same policy: /fr/sign falls under its language rule (/fr/*),
/sign (English, x-default) has its own exact rule. Like /, /sign
carries one inline script of its own: the redirect to the visitor's language
(lib/lang-redirect.ts), built at build time from the language list only,
and allowed by its sha256 hash like Next's bootstrap scripts, never by
'unsafe-inline'. Verified by e2e/security.static.test.ts ("page Signer":
the page exists in the 11 languages plus /sign; one <meta> CSP with
connect-src 'self' blob: data:, form-action 'none' and a hash-only
script-src listing exactly the page's inline scripts; one header CSP with
only scripts of the page's language and frame-ancestors 'none'; the /sign
redirect is an inline script allowed by its hash) and by e2e/ui.smoke.e2e.ts
("mode « signer » (/en/sign, /fr/sign)": the header and <meta> policies on
both pages, a complete signing run with no off-origin request and no CSP
violation, an exfiltration attempt via fetch blocked). The general static
tests above cover these pages like every other one.
Self-hosting. _headers is only read by Cloudflare Pages. The build
therefore copies each page's policy into a <meta http-equiv> tag placed
before any script on that page (scripts/gen-sw.mjs).
A self-hosted deployment thus keeps the network lock; only
frame-ancestors, HSTS and the other HTTP headers must be reconfigured on
the server (see §6).
- All untrusted data (file names, watermark text, the typed signature name,
place and note,
localStoragevalues) goes through React rendering, which escapes HTML, or is drawn on a canvas as text. The code uses neitherinnerHTML, noreval, nornew Function, anddangerouslySetInnerHTMLfor one script only: the language-redirect script of/and/sign(app/page.tsx,app/sign/page.tsx), a constant built fromlib/lang-redirect.tsand the language list, with no user input, allowed by its hash (§4.1). script-srcallows no arbitrary inline script: at build time,'unsafe-inline'is replaced, page by page, with the sha256 hashes of the bootstrap scripts Next actually emitted on that page. The build fails if a<script>escapes the analysis; the static test fails if an inline script is not covered or if a hash is orphaned.'unsafe-eval'is absent; only'wasm-unsafe-eval'is granted, required by the WebAssembly decoders (pdf.js for JPEG 2000 and JBIG2, libheif for HEIC).style-src 'unsafe-inline'remains necessary (React inline styles). The residual risk (CSS injection) is low: with no script and no external connection, an injected stylesheet cannot exfiltrate anything.- Structured data (JSON-LD,
type="application/ld+json") is injected into the exported HTML by scripts/seo.ts, not by React, so nodangerouslySetInnerHTMLis needed. It is built only from constants in the repository, never from user input, with<escaped as\u003c. It is data, never executed, so the CSP doesn't apply to it. - Preferences read from
localStorage(language, style) are validated against an allowlist and bounded (normalizeStyle,detectLang); a forged value falls back to the defaults. The sign tool's display preferences (last tab, ink, date on/off) are compared with their few allowed values (components/SignaturePad.tsx), with the same fallback.
- Format allowlist (
lib/files.ts,ACCEPTandfileKind): PDF, PNG, JPEG, WebP, GIF, BMP, AVIF, and HEIC/HEIF (image/heic,image/heif,image/heic-sequence,image/heif-sequence, or the.heic/.heifextension when the type is empty or generic; seelib/heic.ts). SVG (scriptable), TIFF, office formats and any other type are refused. - pdf.js 6: the code-execution path through font
eval(the CVE-2024-4367 class) no longer exists; the CSP would forbid it anyway. pdf.js never executes a PDF's embedded JavaScript (nopdf.scripting), and the XFA engine is explicitly disabled (enableXfa: false). - The PDF is parsed in a Web Worker isolated from the DOM.
- Canvas dimensions are capped (
fitScale: 16.7 Mpx, 8192 px per side): an oversized page does not crash the tab. - Input caps (
lib/watermark.ts,lib/image-size.ts): 200 MB per file, 500 pages per PDF, 250 megapixels per image. Checked before any rendering (size before the file is read), for every entry point. - Decompression bombs: an image's dimensions are read from its header
(PNG, JPEG, GIF, BMP, WebP —
lib/image-size.ts) before any decoding; a few KB announcing 40,000 × 40,000 pixels are refused. For HEIC decoded by libheif, the dimensions are checked before the pixels are decoded (same 250 Mpx cap). AVIF, and HEIC decoded natively by the browser, have no header pre-check (see §6). - Format detection (
fileKind): MIME type first, then a closed list of legacy aliases, and the extension only if the type is empty or generic (application/octet-stream). An explicit type of another format (an SVG renamed to.png) is still refused. - Uploaded signature image (sign mode,
fromFileinlib/signature.ts): the only new decoder path, with no new library. 50 MB per file (SIGNATURE_MAX_FILE_BYTES), checked before the file is read. Same decompression-bomb guard as document images: the dimensions are read from the first 512 KB withimageSize, andtooManyPixels(250 Mpx) refuses the file beforecreateImageBitmap. HEIC follows the same path as a dropped HEIC:createImageBitmapfirst, then libheif (decodeHeic, same cap before the pixels are decoded). The decoded image is then reduced to 3,000 px on its longest side (UPLOAD_MAX_SIDE) before any pixel processing (background removal, recolouring, trimming). The file picker offers PNG, JPEG, WebP and HEIC/HEIF (components/SignaturePad.tsx);fromFilerefuses any type that is neitherimage/*nor HEIC (unsupported), but does not apply thefileKindallowlist: anotherimage/*type is left to the browser's decoder (see §6). Errors are neutral codes (unsupported,file_too_large,image_too_large,image_unreadable). - Read errors are converted to neutral codes (
pdf_unreadable,image_unreadable…): no internal message is displayed.
This is the central property for critical documents. Each page is rendered to an image, the watermark is drawn into the pixels, then the page is re-encoded (JPEG for a PDF, PNG for an image) into a document created from scratch. Consequences, verified by the sanitisation test:
- No text layer: neither the visible text nor hidden text (white on white, outside the frame) can be copied or extracted.
- No original metadata: title, author, subject, keywords, XMP.
- No attachment, no JavaScript, no open action, no link, no form, no embedded font.
- Images: EXIF metadata (GPS, camera, date) disappears when re-encoding canvas → PNG.
- The watermark cannot be removed by editing the PDF: there is no "watermark" object to remove, only pixels.
- Masking (redaction): the areas drawn on the preview are painted black
on the rendered page, before the watermark and the encoding
(
lib/masks.ts). The masked content is not covered by a layer: it no longer exists in the file (no text, no original pixel). Edges are rounded outwards so that no sliver remains. Verified by the masking tests (engine and UI). - Target size: the reduction (JPEG quality, then resolution) is applied after the watermark; it can neither remove it nor reintroduce data.
Public removal tools apply a few known techniques. Each one is replayed on
the engine's real output by a test bench (e2e/removal.e2e.ts), which
measures the share of the watermark that survives (1: intact, 0: removed)
and fails CI below a threshold.
| Technique | Countermeasure | Survival (before → after) |
|---|---|---|
| Delete the watermark object from the PDF | Rasterised pages: there is no object (§4.4) | — |
| Selecting the watermark's colour | Dark, neutral outline around each letter: a thin dark stroke like the document's text; without the fill, the letters remain readable as hollow shapes | 0.16 → 0.40 |
| "Levels": whiten everything that is not dark | Same outline: erasing it erases the document's text | 0.16 → 0.50 |
| Median filter | Strokes and tiling thick enough | 0.89 → 0.83 |
| Multi-copy estimation (Dekel et al., CVPR 2017): median of several documents bearing the same stamp, then inversion of the alpha blending | Layout variation on every render (angle 45° ± 7°, tiling offset, spacing ± 15 %), drawn from crypto.getRandomValues: no common pattern to estimate |
0.20 → 0.98 |
The watermark covers the whole page and crosses the content (names, amounts, IBAN): an AI erasing tool has to reinvent those characters, and the reference is repeated everywhere — a single forgotten fragment identifies the copy.
- A copy's reference (5 characters, Crockford base32) is drawn from
crypto.getRandomValues: unpredictable, so a third party cannot forge the reference of another copy. - Nothing is retained: neither the recipients, nor the references, nor the register. The register is displayed so that the user keeps it themselves (copied to the clipboard on request).
- Each recipient receives a separately rendered version (different text in
the pixels). File and folder names derived from recipients are reduced to
letters, digits and hyphens (
recipientSlug): no path separator, no...
- The signature is pixels. It is painted into the rendered page
(
paintOverlays,lib/overlays.ts, called fromlib/watermark.ts) after the masks, so a mask cannot hide it, and before the watermark, so a watermark, if added, covers it too. The page is then re-encoded like any other (§4.4): the signature cannot be moved or deleted as a PDF element, and the signed copy carries no text layer, metadata, attachment, JavaScript or link from the original. The output is always a PDF: an image is wrapped into a one-page PDF (forcePdf). - Memory only. The signature is a transparent PNG (
SignatureImage, bytes in memory) plus oneImageBitmapfor the engine, decoded straight from those bytes (signatureBitmap): noblob:URL. When the signature is replaced, the old bitmap is closed (.close()) as soon as no render or download in progress can still paint it, and all of them are closed when the tool unmounts (components/CoreProduct.tsx). The signature, the typed name, the place and the note are never written tolocalStorage, the URL, logs or the service worker, and never leave the page (§4.1). - Initials (option): the same signature image, scaled down, on every page except the one carrying the signature, so that a substituted page shows. "Also add a watermark" (option, off by default): a purpose and one recipient, drawn over the whole page, signature included, which ties the copy to its use (T11).
- Fingerprint: after the download, the SHA-256 of each file produced is
computed in memory and shown once, never stored
(
components/Fingerprint.tsx). It lets the signer recognise the version they sent; it does not prove who signed (§6). - Verified by
e2e/watermark.seam.e2e.ts("signature (mode « signer »)": placement on the right page, order masks → signature → watermark, sanitisation of a signed hostile PDF, image wrapped into a PDF, initials, adversarial corpus with a signature),e2e/sign.e2e.ts(full runs: draw, keyboard-only type, upload, phone, right-to-left, initials, watermark, the fingerprint equals the SHA-256 of the downloaded bytes) ande2e/signature.seam.e2e.ts(upload guards, §4.3).
- AES-256, PDF 2.0 (/V 5 /R 6, /AESV3) via
pdf-lib-encrypt, built on WebCrypto. RC4 is never requested, and the library refuses to fall back to RC4 if WebCrypto is missing (it throws an error). - Review of
pdf-lib-encrypt1.0.3 (write path, the only one used): 256-bit file key and random salts (CSPRNG), algorithm 2.B compliant with ISO 32000-2, correct/UE,/OE,/Perms, random IV per string and per stream, metadata encrypted, no object left in clear (no object streams), no network access oreval, no dependency or install script. Gap fixed: the library omits SASLprep; the application normalises the password to NFKC before encryption, otherwise an "é" typed in decomposed form (macOS) made the file impossible to open in Acrobat. Limitation inherent to the format: unauthenticated AES-CBC; a third party can corrupt the file, not read it. - The password copied with "Copy" is cleared from the clipboard once
60 s have passed, at the user's next click or key press in the page,
unless the user copied something else in the page in the meantime
(
lib/clipboard.ts). The site never accesses the clipboard outside a user gesture and never reads it: a background access makes some browsers and systems ask for clipboard permission. - Ticking "Lock with a password" makes a password of 12 words from the
BIP39 English list (2,048 words,
lib/bip39-english.ts), drawn withcrypto.getRandomValues: 11 bits per word with no modulo bias, 132 bits in total, the strength of a 12-word crypto wallet recovery phrase. At most 107 bytes, so never truncated by the 127-byte PDF limit. The list is checked against the SHA-256 of the original file (lib/passphrase.test.ts). - "Generate a maximum-strength password" (behind "Choose my own password")
produces 256 bits from
crypto.getRandomValues(CSPRNG), encoded as base64url (43 characters). Only this one is described as quantum-resistant. - The password is shown after the download, with the advice to send it by another channel than the file.
- A weak password (< 12 characters or < 80 estimated bits) is flagged.
- The PDF limit of 127 UTF-8 bytes is enforced without splitting a character.
- Passwords live only in the component's memory: never in
localStorage, the URL, logs or the service worker.autoComplete="one-time-code"prevents the browser from remembering them.
File names are reduced to their basename (no ../) and de-duplicated
before compression (zipDocuments). Per-recipient folders are likewise
reduced to a single segment.
| Header | Value | Role |
|---|---|---|
Content-Security-Policy |
see §4.1 | Network isolation, anti-XSS |
Strict-Transport-Security |
2 years, subdomains, preload | HTTPS mandatory |
X-Content-Type-Options |
nosniff |
No interpretation of a file as a script |
X-Frame-Options |
DENY |
Anti-clickjacking (older browsers) |
Referrer-Policy |
no-referrer |
No URL passed to outbound links |
Permissions-Policy |
camera, microphone, geolocation, USB, serial, HID, Bluetooth, payment, Topics disabled | Least privilege |
Cross-Origin-Opener-Policy |
same-origin |
Window isolation (XS-Leaks, Spectre) |
Cross-Origin-Resource-Policy |
same-origin |
Resources cannot be embedded by another site |
Cross-Origin-Embedder-Policy |
require-corp |
With COOP: isolated page (crossOriginIsolated), better protection against Spectre |
Cache-Control (/sw.js) |
no-cache |
A fix propagates on the next visit |
Outbound links (source code, upstream projects, donations) carry
rel="noopener noreferrer": they send no referrer. The one exception is the
author's name, linked to amirmoradi.com with referrerpolicy="origin": it
sends only https://cachety.com, never a path or query. The donation link only
opens GitHub Sponsors in a new tab; the site embeds no payment form or
third-party script. No user data is ever in a URL, so nothing could leak.
Sponsored links (none today; the privacy page reserves the possibility) must
follow the same rules: an <a> with rel="sponsored noopener noreferrer"
and, at most, an image served from 'self'. No ad network, no third-party
script, iframe or pixel, and no change to connect-src, img-src or
script-src. A sponsored slot never reads anything from the tool's state.
bun.lockfrozen (bun install --frozen-lockfilein CI). Bun does not run thepostinstallscripts of unapproved dependencies.bun auditon every CI run and every Monday (scheduled trigger), so that a published CVE fails CI without waiting for a commit.- Dependabot opens PRs for npm dependencies and GitHub Actions.
- GitHub Actions pinned by commit SHA,
permissions: contents: read,persist-credentials: false: a compromised job has no write token. - Bun version pinned in CI.
- No source map published.
- Deliberately few runtime dependencies:
pdfjs-dist(Mozilla),pdf-lib,pdf-lib-encrypt,fflate,libheif-js, React/Next.pdf-lib-encryptis a small, single-maintainer library: its version is pinned exactly (1.0.3) and any update must be reviewed (diff of the source code, not just the changelog). libheif-js1.23.5 (HEIC decoding), pinned exactly. Review: Emscripten wrapper of libheif (Struktur AG, the reference implementation of the format), actively maintained; LGPL-3.0 licence, compatible with the project's AGPL; no install script; neitherevalnornew Function. The variant used (libheif-bundle.mjs) embeds its WebAssembly: Emscripten's download code is never called (and the CSP would forbid it outside the origin). Loaded on demand, only for the first HEIC file; dimensions checked before the pixels are decoded (same 250 Mpx cap). Rejected:heic2any, abandoned since 2023 and bundling an old libheif.
It only handles GET requests from the same origin, and only caches
successful basic responses. Files dropped into the page only travel
through in-memory File/Blob objects.
Bounded exception: sharing from a phone (Web Share Target). The system
sends the shared files as POST /share-target; the service worker
intercepts this request (it never goes to the network), stores the files in
a temporary local cache (cachety-share-inbox, at most 50 files), then
opens /?shared=1. The page picks up the files and deletes the cache
immediately, even on error (lib/share-inbox.ts). Verified by the
sharing test: after loading, the cache no longer exists.
The HEIC decoder (~2 MB) is not precached: it is only downloaded (from the origin) for the first HEIC file.
The sign pages (/sign, /<lang>/sign) are precached like the home and
"about" pages (scripts/gen-sw.mjs), so the
airplane-mode test works there too. The signature never goes through the
service worker: it is neither a request nor a blob: URL.
localStorage only holds preferences: language, watermark appearance,
automatic application, target size, and for the sign tool the ink colour,
the last tab used and whether the date is added (signInk, signTab,
signDate). No file, no watermark text, no password, no signature, no
typed name, place or note. Previews are blob: URLs revoked as soon as
they are no longer needed.
Every guarantee above is covered by a test that fails if it is weakened.
The browser tests run under the production headers: the e2e server
replays out/_headers (e2e/helpers/server.ts).
| File | What is verified |
|---|---|
lib/security-headers.test.ts |
CSP directives (no external origin, no unsafe-eval, object-src/base-uri/form-action/frame-ancestors set to 'none') and hardening headers |
e2e/security.static.test.ts |
Real export: no 'unsafe-inline' left in any policy; each page's <meta> CSP lists exactly that page's inline scripts, and its header rule allows them and only scripts of pages in the same language; no CSP on /* (no joined policies); a strict hash-free CSP on scripts and workers; Cloudflare limits (100 rules, 2,000 characters per line); <meta> before any script; no external resource; no source map |
e2e/ui.smoke.e2e.ts (security) |
A complete watermarking run only contacts the origin, with no CSP violation; an exfiltration attempt via fetch is blocked |
e2e/watermark.seam.e2e.ts (sanitisation) |
A hostile PDF (hidden text, Info/XMP metadata, attachment, OpenAction JavaScript, link) leaves nothing in the output; a JPEG with EXIF comes out without metadata |
e2e/watermark.seam.e2e.ts (adversarial corpus) |
Empty file, rotated page, oversized page: clean rejection or valid output |
lib/file-password.test.ts |
AES-256 /R 6, unreadable without the password, 256-bit generated secret, 127-byte limit, accented password openable (NFKC), key/salts/IV never reused |
lib/passphrase.test.ts |
BIP39 list identical to the original (SHA-256), 2,048 distinct a-z words, CSPRNG used, uniform word frequency (chi-square), 12 words always ≤ 127 bytes |
lib/clipboard.test.ts |
Password cleared from the clipboard when the delay expires, or when focus returns; another copy is not overwritten |
e2e/watermark.seam.e2e.ts (error contract) |
Caps: > 200 MB → file_too_large, > 500 pages → pdf_too_many_pages |
lib/files.test.ts |
Format allowlist (SVG refused), Zip-Slip, name collisions |
lib/masks.test.ts, lib/stamp-text.test.ts |
Mask geometry; references (alphabet, unbiased randomness), safe folder names |
e2e/watermark.seam.e2e.ts (masking, target size, HEIC) |
No original pixel under a mask; noise compressed below the target; real HEIC decoded, fake HEIC rejected |
e2e/features.e2e.ts |
One file per recipient, distinct references; mask drawn in the UI; share received, then inbox emptied |
e2e/removal.e2e.ts |
Removal bench: colour selection, levels, 5×5 median, multi-copy estimation; survival thresholds in CI |
lib/image-size.test.ts |
Dimensions read without decoding; 40,000 × 40,000 bomb refused, 200 Mpx photo accepted |
e2e/security.static.test.ts (page Signer) |
/sign and /<lang>/sign in 11 languages: one <meta> CSP, hash-only script-src listing exactly the page's scripts, header CSP limited to the page's language, frame-ancestors 'none'; the /sign language redirect allowed by its hash |
e2e/ui.smoke.e2e.ts (sign mode) |
/en/sign and /fr/sign carry the header and <meta> CSP; a complete signing run only contacts the origin, with no CSP violation; an exfiltration attempt via fetch is blocked |
e2e/watermark.seam.e2e.ts (signature) |
Signature painted at its rectangle on the right page; masks under it, watermark over it; a signed hostile PDF leaves nothing; adversarial corpus with a signature |
e2e/signature.seam.e2e.ts (fromFile) |
Header announcing 40,000 × 40,000 px → image_too_large without decoding; junk bytes → image_unreadable; a PDF → unsupported; real HEIC decoded locally by libheif |
e2e/sign.e2e.ts |
Full signing runs (draw, keyboard-only type, upload, phone, right-to-left, initials, watermark); fingerprint equal to the SHA-256 of the downloaded bytes |
lib/site-copy.test.ts (T11) |
The sign page's FAQ says, in all 11 languages, that a signature can be cut out and pasted elsewhere; the legal section cites eIDAS art. 25 and Civil Code art. 1367 everywhere |
bun audit (CI) |
No known CVE in the dependency tree |
Run everything:
bun audit # known CVEs
bun test # unit tests + header policy
bun run build && bun test ./e2e/security.static.test.ts
bun run test:e2e # browser, under the production CSP
# Locally without Playwright's Chromium: CHROMIUM_EXE="/path/to/chrome" bun run test:e2eWhat the application does not guarantee, stated plainly:
- A watermark is a deterrent, not DRM. Anyone who receives the file can photograph it, crop it or retouch the pixels. The watermark makes reuse detectable and traceable; it does not prevent it. The defences in §4.4 ter make removal costly and imperfect, not impossible: an AI erasing tool (inpainting, image-editing models) can repaint the page; it then has to reinvent the covered text, which makes the forgery unreliable. Retyping the document (OCR) bypasses any watermark: only verification with the issuer addresses that.
- No invisible watermark. Evaluated (trial of 2026-10-10 on the reference implementation of a keyed DCT mark): it survives JPEG re-encoding but disappears with the slightest screenshot, print to PDF, crop or resize (shifted 8×8 block grid), and leaves visible mottling on a white background. Invisible marks can moreover be erased by AI regeneration (Zhao et al., 2023).
- Password protection is only as good as the password. AES-256 encryption is strong; a short password can be guessed offline. Prefer the "Generate" button and send the password through a different channel than the file.
- A simple electronic signature, nothing more. The sign tool places an image of a signature on the pages. The PDF carries no cryptographic signature, no certificate and no trusted timestamp, and the tool does not prove who signed: anyone can type any name or upload any image. This is what is commonly called a simple electronic signature, without the presumption of reliability of a qualified one; French courts treat a signature image as weak evidence once it is disputed, and the file proves neither who placed it nor that the document was not changed afterwards. The sign page says so, with the cases where it is not the right tool. The SHA-256 fingerprint shown after the download proves nothing to a third party on its own.
- The signature can be reused (T11): a recipient can cut it out of the signed copy and paste it elsewhere, as with a scanned paper signature. A watermark and initials limit the damage; nothing prevents it.
- Signed copies are flattened. Each page is rebuilt from its pixels (§4.4 quater): the text of the signed copy can no longer be selected, searched or copied, and the file is usually larger than the original text PDF. This is the price of the sanitisation; the original stays with the user, unchanged.
- Trust in the host. The code is served by Cloudflare Pages; a compromised deployment could serve different code. The repository is public and the site can be self-hosted with one command (see README) for anyone who does not want to trust any deployment.
- Self-hosting: the
<meta>CSP is present, but HTTPS, HSTS,X-Frame-Options/frame-ancestors,X-Content-Type-Options,Referrer-Policy,Permissions-Policyand COOP/CORP must be configured on the server, based onout/_headers. - Resource exhaustion: files, pages and pixels are capped (§4.3). An
AVIF image has no header reading: its dimensions are left to the
browser's decoder. The same applies to a HEIC file when the browser
decodes it natively (Safari):
createImageBitmapis tried first, and the 250 Mpx check only runs in the libheif fallback. The same applies to an uploaded signature image (§4.3) in AVIF, in HEIC decoded natively, or in anotherimage/*formatimageSizedoes not read: it is capped at 50 MB, then its dimensions are left to the browser's decoder. The impact of a failure stays local to the tab. - Compromised device: browser extensions, spyware and software reading the clipboard are out of scope. The copied password is cleared from the clipboard after one minute (§4.5), but spyware can read it before then.
- Trusted Types not enabled. Tried (
require-trusted-types-for 'script'): it breaks the application, because Next's module loader and the creation of the pdf.js Worker write to protected sinks. Enabling it would require a default policy covering these internal components. The risk covered (DOM XSS) is already greatly reduced: noinnerHTMLin the code, hash-based CSP, no external connection possible.
Before merging a PR:
- No request, font, image or script to an external domain.
- No
dangerouslySetInnerHTML(beyond the language redirect, §4.2),innerHTML,eval,new Function. -
public/_headersnot weakened (otherwise: justification + update of this document). - No user data (file, text, password, signature) in
localStorage, the URL, logs or the service worker. - A new dependency is justified, maintained and reviewed; an update of
pdf-lib-encryptis reviewed line by line. - A new input format goes through the allowlist and the rasterisation pipeline (no copying of objects from the original document).
-
bun audit,bun testandbun run test:e2eare green.