| Channel | Version | Support |
|---|---|---|
beta (main, printifier.apk) |
2.4.x | security fixes land here first |
stable (stable, printifier-stable.apk) |
2.4.x | fixes are cherry-picked per release |
Please open a GitHub Security Advisory (private) or an issue marked security. Include the affected file/route and a minimal reproduction. You'll get a response within a week; fixes ship in the next beta build.
Everything user-facing is client-side. PDF parsing, filtering, composition and OCR run in the browser/WebView. Source files are held in memory (and, for the "recent jobs" convenience list, in the browser's own IndexedDB). There is no account system and no analytics SDK.
Static builds (GitHub Pages / the APK) make zero server calls — server-only
features are compiled out at build time (NEXT_PUBLIC_STATIC=1, set inside
scripts/static-export.sh so every static build path gets it, with a CI check that
the shipped bundle contains no /api/ references).
Data that leaves the device — self-hosted server mode only:
- Anonymous usage stat — after a successful job the app POSTs page/byte counts,
color mode and layout to
/api/prints. ThefileNamefield is a 16-hex truncated SHA-256 hash of the joined file names — real names are never transmitted, and the server rejects any request whosefileNameis not exactly that hash format, so non-app clients can't smuggle titles in either. - Cloud import (optional) — if the user pastes a Drive/Dropbox link, the server fetches that URL (converted to the direct-download form first) and streams the response straight back with no full-file buffering (declared-size responses are proxied as a stream; only chunked responses fall back to a 75 MB-capped buffer). The URL (not the file) transits the server; nothing is written to disk or logged. The route is rate-limited per client (10 imports / 5 min).
- AI routes (optional, need
Z_AI_*credentials) — the Print Doctor sends the document's file name + current settings to the configured LLM; voice commands send recorded audio; TTS sends the text to speak. Without credentials these routes answer from local heuristics and nothing leaves the server. - Shared presets — only when a user explicitly shares one: the settings snapshot (never document content) is stored server-side under a random 8-char id and expires after 30 days (enforced in the GET query).
Android app: ships the entire site inside the APK; it runs from a synthetic local
origin, declares no permissions at all (even INTERNET was removed — every
request is served from the in-APK assets or blocked with a 404), and allowBackup is
disabled. The JS bridge is injected only for the app's own origin, and the download
bridge sanitizes file names before writing to Downloads/Printifier.
GET /api/cloud-importfollows redirects manually and validates every hop: public DNS-resolved addresses only, private/link-local/CGNAT/loopback/benchmarking/ TEST-NET/multicast/reserved ranges blocked, integer/hex/octal IP encodings decoded and checked — including fully-expanded hex-mapped IPv6 forms such as0:0:0:0:0:ffff:7f00:1(= 127.0.0.1), which earlier revisions missed. The connection then dials the validated IP directly (nodelookuppinning), so a DNS rebinding between validation and fetch cannot re-point the socket.- Rate limiting (
src/lib/rate-limit.ts) treatsX-Forwarded-Foras client-spoofable: forwarded headers are honored only when the operator setsTRUST_PROXY=1behind a proxy that overwrites them; otherwise callers fall into per-user-agent buckets so users can't block each other (light abuse protection, not billing-grade). - Settings payloads arriving over any trust boundary — QR recipe links (
?r=), shared preset links (?p=), imported preset/backup files, the presets API (write AND read) — pass through the whitelist-and-clamp sanitizer insrc/lib/settings-validate.ts. Unknown keys are dropped, every value is type-checked and clamped, so a crafted link cannot setrows=9999or inject an unknown color mode. - Server mode sends
Content-Security-Policy(script-srcallows only'wasm-unsafe-eval'— no plainunsafe-eval),frame-ancestors 'self'+X-Frame-Options: SAMEORIGIN(override withFRAME_ANCESTORSif you must embed cross-origin),X-Content-Type-Options,Referrer-Policyand a locked-downPermissions-Policy. The production GitHub Pages host is a static export and serves no custom headers. - TypeScript errors are fatal:
tsc --noEmitgates CI (withnoImplicitAnyon) and there is noignoreBuildErrorsescape hatch. ESLint runs on every push, and the unit + API test suite (bun test tests/unit tests/api) gates CI too. - Vendored binaries (pdf.js worker/WASM, Tesseract cores, fonts) are hash-pinned in
public/vendor-manifest.json; CI re-verifies the hashes (scripts/sync-vendor.mjs --check) so a dependency bump can never silently ship a mismatched worker.
Last full audit: 2026-10-18 (fresh OSV querybatch over every resolved name@version
in bun.lock; prior full audit 2026-10-14 via bun audit).
- Removed unused scaffold packages that carried advisories:
next-auth(critical auth-bypass advisory, never used),@mdxeditor/editor,react-syntax-highlighter,@reactuses/core,next-intl,@tanstack/react-query,uuid,date-fns. - Updated
next16.1.3 → 16.4.0, clearing the critical Image-Optimization RCE (GHSA-2xp9-vwfh-vxw4) and the Windows-hosted RCE (GHSA-p293-qw3h-jr36) advisories. - Removed unused
sharp(GHSA-wq5f-xc86-pv6w, GHSA-rgj7-g3m4-5g8c, GHSA-f88m-g3jw-g9cj — the three Dependabot "high" alerts; sharp is not used anywhere: the static export runs withimages: { unoptimized: true }). The unusedrechartsdependency and theui/chart.tsxwrapper (itslodashchain carried advisories of its own) were also removed — as of 2026-11-03 this claim is true again: both are deleted from the code andpackage.json. - Pinned 17 vulnerable transitive dependencies via
package.json→overrides(ajv, js-yaml, minimatch, brace-expansion, @humanfs/node, flatted, defu, deepmerge-ts, effect, nanoid, source-map-js, baseline-browser-mapping, picomatch, browserslist, @babel/core, postcss) — all in dev/toolchain chains, all fixed in their existing major lines. - Result: 104 advisories (3 critical) → 1 (1 high). The single remaining finding
is
braces 3.0.3(GHSA-vfj7-8cjw-p6xm / CVE-2026-93687, stack-exhaustion ReDoS via deeply nested glob patterns) which has no upstream patch — 3.0.3 is the newest release. It is used only by the ESLint glob chain at lint time on trusted local files, never on user input; risk is accepted until upstream ships a fix. - The shipped client runtime (
next,react,pdfjs-dist,pdf-lib,tesseract.js,zustand,framer-motion) is current and advisory-free. Self-hosters running the API routes should re-runbun auditafterbun installand keep dependencies updated. Dependabot (.github/dependabot.yml) opens weekly update PRs. - About the Dependabot banner over-reporting (and its fix): the repo previously
used the
npmecosystem, which readspackage.jsonranges and ignores bothbun.lockand theoverridesblock — so it alerted on OLD in-range versions of toolchain transitives (the threesharp"high" alerts vianext's optional deps,minimatch/brace-expansion/ajvchains) that are never actually installed. Re-verified on 2026-10-18 by querying the OSV database for every resolvedname@versioninbun.lock(708 packages): exactly ONE real finding — the acceptedbracesentry above. The config now uses Dependabot's nativebunecosystem (readsbun.lock, override-aware), which should recompute the alert set from resolved versions. If any phantom alert lingers after the next scan, dismiss it on the Security tab as "Inaccurate" with a pointer to this file. Re-verified again on 2026-10-24 (bun auditagainst the refreshedbun.lock): unchanged — exactly one finding, the acceptedbracesentry.braces3.0.3 is still the newest upstream release (no fix available). - Dependabot PR policy (2026-10-24): the five weekly GitHub-Actions bumps
(checkout v7, upload-pages-artifact v5, configure-pages v6, codeql-action v4,
action-gh-release v3) were merged CI-green. Major-version bumps of runtime
libraries (typescript 7, prisma 7, eslint 10, framer-motion 14, lucide-react 1,
react-day-picker 10, @tanstack/react-table 9, react-resizable-panels 4) are
deliberately deferred and now ignored in
.github/dependabot.yml— the current major lines are advisory-free. In-range minor/patch updates are applied on the main line with the pinned bun 1.4.4 (CI + local agree): Dependabot's newer bun rewritesbun.lockformats, so its lockfile-editing PRs are closed in favour of local updates.