Security Notes for CalcoTools
A summary of the security posture built into the app, for anyone maintaining or auditing it.
Last updated: August 20, 2026
Architecture-level security
- No backend, no database, for the vast majority of the site. Tier 0/1 tools (nearly all of them) have no server to breach, no SQL to inject, no session to hijack. A small set of API routes now exist (
/api/uploads/sign,/api/tools/[slug]/run,/api/csp-report) for the two Tier 2 tools still in development — see "Tier 2 infrastructure" below. - No
eval()orFunction()constructor anywhere in the codebase. The Scientific Calculator evaluates math expressions viamathjs, not JavaScript'seval(). - No
dangerouslySetInnerHTMLon user input. The only two uses in the codebase are on trusted, author-controlled content — JSON-LD schema built from our own tool registry, and static legal-page markdown read from repo.mdfiles — never on anything a visitor types. All calculator output (JSON formatter, Base64, Morse code, hash digests, OCR results) renders through React's default JSX, which auto-escapes. - Runtime CDN dependencies exist, and are deliberately scoped. Unlike an earlier version of this document claimed, this is not a zero-CDN-dependency build: the OCR tool loads its WebAssembly recognition engine from
cdn.jsdelivr.netandtessdata.projectnaptha.comon first use (never your image — see the Privacy Policy), and the Turnstile bot-check widget (once wired into a live upload form) loads fromchallenges.cloudflare.com. Every external origin the site is allowed to talk to is enumerated explicitly in the Content Security Policy (below) — nothing else is permitted.
Content Security Policy
A real Content-Security-Policy header (not a meta tag) is enforced on every response, set in next.config.ts. It shipped in report-only mode first, was verified against a real click-through of every tool category with zero unexpected violations, then flipped to enforcing. object-src 'none', frame-ancestors 'none', base-uri 'self', and form-action 'self' are all fully strict. script-src carries a scoped 'unsafe-inline' — a deliberate, documented tradeoff (see the comment in next.config.ts): the alternative, a per-request nonce, requires Next.js to render every page dynamically, which conflicts with this site's static-generated tool pages. Violations are logged to /api/csp-report (visible in Vercel Function Logs) so a regression is observable, not silent.
Other headers set the same way: Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy.
Input validation
- All numeric fields fall back safely (
parseFloat(x) || 0) rather than crashing or producingundefinedon invalid input, and results are formatted through a shared helper that shows "—" instead ofNaN/Infinityfor any non-finite value. - Every numeric input that drives a loop, array length, or generation count (random number count, prime-list limit, UUID/lorem-ipsum batch size) has an explicit hard ceiling, independent of the HTML
min/maxattributes (which a user can bypass by typing directly). - File uploads (image tools, PDF tools) validate the file's actual header bytes against known signatures (
src/lib/magic-bytes.js) rather than trusting the file extension or browser-reported MIME type, which are both trivially spoofable. - A
harness.jsfuzz pass runs every tool through a fixed set of boundary values (huge numbers,1e400, negative numbers, empty/whitespace input,<script>alert(1)</script>, malformed dates) on every build, checking for console errors, frozen pages, and bad output artefacts.
Client-side file handling
- Image tools (resize/compress/crop) and PDF tools (merge/compress) never send a file anywhere — processing happens via the Canvas API or a local PDF library respectively.
- Every
URL.createObjectURL()call has a matchingURL.revokeObjectURL()on replacement and unmount, closing a memory leak that existed in an earlier version of these tools. - Corrupt or malformed input files (a fake PDF, an unreadable image) are caught and shown as a friendly in-app message rather than crashing the tab — verified with real malformed test files, not just assumed.
- Not yet done: none of this runs inside a Web Worker, so a sufficiently large or adversarial file could still block the UI thread. Worth revisiting before large files become common in practice.
Tier 2 infrastructure (Background Remover, PDF to Word — not live yet)
Two tools need brief server-side processing. The infrastructure for this is built and gated behind environment variables (see .env.example), even though neither tool is live:
- Storage: Cloudflare R2, chosen specifically because it supports a bucket-level lifecycle rule (auto-delete after 60 minutes) that the alternative considered, Vercel Blob, does not. Uploads use single-use, 5-minute-expiry presigned URLs.
- Rate limiting: Upstash Redis, tighter on upload-URL issuance than on processing itself, since issuing a signed upload URL is the actual point where abuse gets expensive.
- Bot protection: Cloudflare Turnstile, verified server-side only (the secret key never reaches the client).
- Spend circuit-breaker: a daily request cap; once hit, the tool reports "temporarily unavailable" instead of continuing to process files.
- What's still missing: a real backend to actually do the background removal or PDF-to-Word conversion. Both are stubbed to return "not implemented" — no ML model or conversion engine has been chosen yet, since that's a real cost/vendor decision, not something to pick unilaterally.
Secrets management
Every credential used above (R2, Upstash, Turnstile) is read from an environment variable, never hardcoded, and the routes that need them fail closed with a clear error when they're unset rather than crashing or silently doing nothing. .env.example documents every variable and where to get it. .env itself stays out of version control (see .gitignore).
Usage counters are per-browser, not shared — correcting an earlier version of this document
The "N tools used" / site-visit counters shown in the footer and on tool cards are stored entirely in localStorage, per browser. An earlier version of this document (and the Privacy Policy) incorrectly described them as shared, server-visible counters that a script could inflate for everyone — that's not how they're implemented. There is no server-side counter at all; each visitor only ever sees their own device's count. This means the "abuse" scenario described previously (a script hammering a shared increment endpoint) isn't applicable to the current implementation — there's no shared endpoint to hit. If a real shared/global counter is added in the future (requiring an actual backend), it should be rate-limited at that point using the same pattern already in place for the Tier 2 routes above.
Supply chain
Every dependency in package.json is pinned to an exact resolved version (no ^/~ ranges), the lockfile is committed, .github/dependabot.yml requests weekly update PRs, and .github/workflows/ci.yml runs npm audit --audit-level=high on every PR — expect this to fail until Next.js's own transitively-bundled postcss/sharp versions are addressed via a deliberate Next.js major-version upgrade; that's the audit doing its job, not a misconfigured pipeline.