Repository navigation
Security Model
The value you accepted is not in the file you downloaded.
That is the entire product. Everything else (the editor, the models, the streaming, the design) exists to make that sentence true and to make it checkable by someone who does not trust us.
Canonical sources: SECURITY.md (policy) · docs/security-internals.md (mechanisms).
Not preferences. A change that breaks one will not be merged, however good the rest of it is.
| # | Invariant | Why |
|---|---|---|
| 1 | The source file is never mutated. | A bad redaction must be a bad export, never a lost document. |
| 2 | Only status === "accepted" reaches the exporter. |
Detection proposes, a person decides. No code path applies a detector's output on its own, and Hush's changes wait for Approve. |
| 3 | Redaction removes; it never covers. | No overlay, no white text, no annotation that can be deleted. |
| 4 | Every export is verified before delivery. | Re-open it, read it adversarially, refuse it if an accepted value survived. |
| 5 | A model's output is located, never trusted. | It returns text it claims to have seen; we find that text ourselves and discard what we cannot locate. |
| 6 | Nothing logs document content. | Not prompts, extracted text, OCR output, model replies or keys. Ids, stages, durations, error categories only. |
| 7 | SHA-256 is not encryption. | It is for integrity and HMAC. Encryption is AES-256-GCM with random keys. |
| 8 | Keys are random. | Never derived from an IP, a MAC, browser behaviour or anything else an attacker controls. |
If you find a way to violate one that the tests do not catch, that is a security issue and a welcome contribution. Report it privately (§12).
-
Envelope encryption everywhere. Each document has its own random 256-bit data key, wrapped by
ENCRYPTION_KEY. Source, normalized model, exports, reports and batch vaults are all sealed under it. -
Chunked AEAD (
v1, since 1.6.0). 1 MiB chunks, each with its own GCM tag. The nonce is a per-object prefix plus a counter that fails closed. The AAD binds the header, the object's logical path and a final-chunk flag:- no truncation (no prefix of an object is valid),
- no reordering (the counter is in the nonce),
- no splicing between objects (the path is in the AAD). Before 1.6.0, someone with bucket write access could swap one of a document's objects for another, such as a report for a source.
- No plaintext leaves a chunk before its tag verifies. A tampered chunk fails the stream at that chunk.
- The envelope is read from the record, never guessed from the bytes.
- Master-key-sealed secrets: global-rule patterns and the ChatGPT sign-in token.
-
assertMasterKeyrefuses to boot on a malformed key (#27: YAML once turned a 64-zero key into the integer0). - Plaintext at rest exists only between a direct upload landing and ingest re-sealing it, which is stated, not hidden. Ingest deletes it.
Rotating
ENCRYPTION_KEYdoes not reset anything. It makes everything already stored permanently unreadable.
Detail: Storage and Encryption.
No accounts. A server-issued random session id lives in the anonify_sid cookie:
| Attribute | Value | Why |
|---|---|---|
httpOnly |
true | out of reach of any XSS that gets to document.cookie
|
sameSite |
lax |
a link from elsewhere still lands authenticated; cross-site subrequests do not carry it |
secure |
true in production | HTTPS only; plain HTTP allowed in local development |
maxAge |
30 days, rolling on uploads, rule create/edit/import and Hush turns (since 1.9.0) | long enough to come back; a global rule never outlives the cookie that reaches it |
The session id is split into three salted hashes, computed server-side and never sent to the browser: ownerKey (ownership), quotaKey (session + coarsened IP: daily allowance), networkKey (coarsened IP: rate limits). IPs are coarsened to /24 and /48 before hashing. Clearing the cookie resets ownership and quota, but not the network rate-limit bucket. No browser fingerprinting and no MAC addresses. peekIdentity is the read-only variant for page renders, because a GET should not mint cookies.
requireDocument gates every document read:
| Situation | Status |
|---|---|
| no session | 401 |
| not found, or exists and is not yours |
404, never 403, so ownership cannot be probed by id |
| yours, but expired |
410 (the bytes are on their way out) |
Storage URLs are never handed to a browser. A download is authorized by a token naming the document, the artifact, the owner and an expiry, signed with FINGERPRINT_SECRET:
- TTL 300 seconds.
-
Domain-separated: document tokens are signed under
"download-url|"and batch-archive tokens under"batch-download-url|". The domain is fed into the HMAC before the payload, so one kind can never verify as the other. -
Timing-safe: a length check, then
timingSafeEqual. A failure returnsnull, with no hint about which part was wrong. - Necessary but not sufficient: the route re-derives the session, re-checks ownership, and hashes the bytes as they stream, holding the last piece until the checksum recorded at export matches. A mismatch breaks the download off short.
Every export (every variant, and every document of a batch export, through one shared deliver function) is re-opened and read the way an adversary would: PDF text plus a raw byte scan, the absence of annotations, every OOXML part, every sheet including hidden ones, a re-parsed grid, two MIME parsers, pixel samples and metadata. If any accepted value is present, the export fails: not saved, no link. The operator log records how many values leaked; the client is told nothing that would help locate them.
- Values under 4 characters are not asserted on, because they collide by coincidence.
- Strings the pipeline authored (surrogates, ciphertexts) are excised before searching, because a random ciphertext can contain a short value by chance.
- A rebuilt mailbox is verified as its own export: split again by the upload's scanner, the same count, each message byte for byte its verified export, and no accepted value on any separator line. Otherwise it is withheld whole.
- An attachment substituted into its message is checked by checksum against the child's verified artifact.
Per-format detail: Supported Formats · Export and Anonymisation.
The export report carries counts and never content, because a report that lists what was removed verbatim is a leak with a covering letter. assertReportOmitsValues makes that a check: before the report is stored it walks every string leaf and throws ReportLeakError (the report is not saved, so neither is the export) if any free string contains an accepted value.
| Constant | Role |
|---|---|
AUTHORED_PATHS |
paths whose content the server chose (ids, hashes, timestamps, MIME type, preset label), skipped because prose collides: port occurs in report
|
AUTHORED_PATTERNS |
attachment fields by index: part path, child id, checksum, reason. The list is keyed by part path, never filename. |
CLOSED_VOCABULARY |
categories, types, sources, statuses, styles, dispositions, kinds: strings that cannot be a value. Unknown categories are rewritten to other first, because a model can return any category string. |
MIN_VERIFIABLE_LENGTH = 4 |
the same coincidence floor as the export gate |
pnpm smoke also reads the delivered report bytes. The batch report names documents by id and part-path chain, never by subject or filename, and batch folder names follow the same rule.
tokenize and encrypt are reversible by whoever holds the vault. For a single export, the vault is returned inline and stored nowhere, so Anonify itself cannot reverse an encrypt export. /api/restore takes no id and stores nothing. The one stored vault is the batch-export vault, sealed under the document's own key and purged with it. That grants nothing the stored source did not already grant, and the report says the archive holds both halves. pseudonymize keeps no mapping anywhere. None of the methods is anonymisation in the regulatory sense, and the product does not claim it. See Export and Anonymisation.
-
categoryAllowedapplies to model proposals (per-chunk text detections and spreadsheet column judgements) as well as to the deterministic detectors. A preset that turned off account numbers and then accepted the model's account-number suggestions would narrow nothing. -
PRESET_DISCLAIMERis a constant shown under the chooser. The editor header says what was looked for, and the report'slookedForblock records whether the search was narrowed, with a note that anything outside it was never proposed.presetNarrowskeeps the warning honest: the default preset narrows nothing, so it shows none. - A preset whose id, label, summary or description names a regulation or claims an outcome fails validation at import. See Redaction Presets.
Search and rules run a pattern someone typed against every page of every document in scope. The defences: RE2 (linear time; backreferences and lookaround do not compile), a 500-character limit, no empty matches, 5 s of matching and 10,000 matches per document (search: 50,000). Over budget means the pattern fails whole, with nothing written. The browser runs the same compiler. Patterns travel in bodies, never URLs. Global-rule patterns are sealed and expire 30 days after last use. See Search and Rules.
A document is untrusted input to Hush. The worst a hostile paragraph should be able to do is get a bad change proposed.
| Defence | Why |
|---|---|
| Every change needs Approve on a card showing what it will do and the evidence | nothing a model decides reaches the review without a person |
| Approvals are HMAC-signed, binding tool, call id and input | the conversation is client-held; a forged or edited approval is refused before anything runs |
| Reading is asked once, revocable; before consent the prompt holds no filename, and the selection is described by category, status and page only | nothing from the document reaches the provider until you allow it, and every read is listed |
| Writes re-validate (the rule compiles and fits its budget; a ref still holds the exact approved text) | the model's input is a proposal, not a fact |
| Output cannot fetch (links as text, images as alt text, no raw HTML) | no tracking pixel through a model that quoted the document |
| Tool results are declared data, not instructions | and the approval step makes that safe when it is ignored |
| Bounded: 12 steps, 80 messages, 1.5 MB, the spend cap, a per-visitor token allowance, checked before every step | a runaway loop costs a bounded amount, and one visitor cannot spend the instance's cap |
| Stream errors are a fixed sentence; only the failure's name is logged | a provider's error can quote the prompt |
See Hush Assistant.
- Logging: ids, stages, durations and error categories only. User-facing failure messages come from a fixed table, never the thrown error (Failure Codes). Provider errors are quoted only by the setup CLI during verification (synthetic requests), after redacting credentials and key-shaped strings.
- Quotas and rate limits are enforced server-side in the database, never only in the browser.
-
Email safety: no message content reaches an
href,srcor any fetchable attribute, and there is nodangerouslySetInnerHTMLin the viewer. - Retention: 1 h / 6 h / 24 h / 3 days, extendable to at most 72 h from creation. The sweep deletes storage first and the row last, only if storage cleared. Self-hosters must run the sweep (Deployment).
-
Supply chain: since 1.5.0, Next.js 16.3.6 and patched transitive dependencies, with
pnpm auditclean. -
Build output: since 1.8.0 a standalone build no longer copies the working tree (which could include
.anonify-storageand.env).
| Advisory | Fixed in | What it was |
|---|---|---|
| GHSA-h3hv-rpqw-7p84 | 1.5.0 | PDF redaction boxes were placed by a proportional approximation, so on proportional text a box could sit a few characters off its value and leave descenders showing. The export burned in the same box, so part of an accepted value could survive in the PDF. Boxes now come from the font's own widths and reach below the baseline. PDFs analysed earlier are covered a whole text run at a time; re-upload them for tight boxes. |
| release note | 1.6.0 | stored objects bound to their logical names (splicing) |
| release note | 1.8.0 | standalone build no longer copies local storage and .env
|
| release note | 1.5.0 | Next.js critical/high advisories; transitive dependency advisories |
Full history: Release History.
Use GitHub's private vulnerability reporting: the Security tab → Report a vulnerability. Do not open a public issue or PR that demonstrates a bypass, even a small one. This repo is cloned and run by people on their own documents, and a public recipe reaches them before a fix does.
Include: what you did ("upload this file, accept this suggestion, export"), what you got back and which value survived, a synthetic file that reproduces it (never real personal data; tests/fixtures.ts builds such files), and the commit or release you tested.
SECURITY.md is authoritative. The examples below that involve newer features (rebuilt mailboxes, archive folders, object names, Hush) are instances of its categories.
- A value survives an export: via copy-paste,
strings, unzipping the OOXML, XMP or EXIF, an undo history, a thumbnail the format kept, a separator line in a rebuilt mailbox, a folder name in an archive, or any other route. - The verification pass can be fooled, e.g. it looked in a different normalization than the file stored.
- The canvas and the export disagree. A placement bug is a security bug here, not a visual one.
- A redaction that covers rather than removes.
- Cross-tenant reads: a guessable id, a missing ownership check, a token that outlives its grant or works for another document or batch.
- Content in a log, or in a report or batch report.
- Key and crypto handling: anything derived rather than random, nonce reuse, a ciphertext accepted without its tag, a stored object accepted under another object's name.
- A quota or rate limit enforced only in the browser.
- Hush applying a change without an approval, or with a forged or altered one.
- Blur and pixelate are not equivalent to a solid fill. Solid is the default, text is always solid, and the dialog says so. An attack on a blurred face is expected; recovering pixels a solid fill covered is a real bug.
- A rasterised PDF page loses its text layer. Deliberate (#40).
- The model missed something. Detection is a proposal. A model inventing a value that then got applied would be a finding, so a path around locate-or-discard is worth reporting.
- The hosted demo has no accounts. Guessing your own cookie is not a finding; reading someone else's documents is.
- Scanner findings with no working proof. A version-bump PR for a real advisory is welcome.
- Testing and Benchmarks (the adversarial suites) · Architecture · Storage and Encryption · Contributing
Anonify: AI proposes, the application applies, and only what a person accepted is removed.
Repository · Report a bug · Request a feature · Security policy · Apache-2.0
This wiki describes v1.10.0 and summarises and links the in-repo documentation. Where the two disagree, the files under docs/, CHANGELOG.md and SECURITY.md are canonical.
Getting going
Using it
- Supported Formats
- Review Workspace
- Search and Rules
- Hush Assistant
- Redaction Presets
- Export and Anonymisation
- Batches and Mailboxes
How it works
- Architecture
- Processing Workflow
- Redaction Engine
- Storage and Encryption
- Streaming IO
- Data Model
- API Reference
- Security Model
When it breaks
Contributing
Repository · Issues · Releases · Changelog