Security
Bounded inputs, parameterized queries, rate limits, checked uploads, and a CSP on every page — enforced by a checklist and four automated scans.
The problem
Security in a new app is usually a promise to do it later. By the time later comes, there are fifty endpoints to audit, and nobody remembers which ones take user input.
What Bones does
The protections are built in, and every pull request is checked against them.
In the code:
- Bounded inputs. Every API input has a schema with an upper bound on every string, array, and number. The limits live in one file,
backend/src/input-limits.ts. - Parameterized queries. Every query goes through Drizzle's builder or its
sqltagged template. Search terms are escaped, so%matches a literal percent sign. - Permissions on every call. Procedures check a feature permission, and organization-scoped ones check the caller belongs to that organization. See Roles and permissions.
- Rate limits. Sign-in and sign-up use Better Auth's limiter. Search allows 120 calls a minute per user, upload URLs 30, and chat 20.
- Checked uploads. Files go straight to a private bucket. The backend then reads the real size and type from storage and deletes anything that doesn't match.
- Sanitized content. Markdown is sanitized before it renders. Structured data on blog posts is escaped, so a title can't break out of its script tag.
- Secrets stay on the server. A client can only read env vars on an allowlist, each with a reason. Log files redact passwords, tokens, and secrets.
- Headers and a CSP on every frontend. One file,
scripts/security/headers.mjs, sets them for the web app, desktop app, marketing site, blog, and docs. No page allows inline or eval'd scripts.
On every pull request:
- Semgrep runs 15 Bones-specific rules, such as raw SQL, unbounded strings, and secrets read outside their module.
- OWASP ZAP scans a running copy of the web app and attacks the backend's signed-out API.
- A CSP check loads 59 signed-out pages and 19 signed-in routes in Chromium, and fails on any policy violation.
- A secrets check builds every client with fake secret values and fails if one shows up in the output.
The judgment calls — is this the right permission, is this query scoped to the caller — are in SECURITY_REVIEW.md, a checklist for any change that touches user input.
What isn't done yet
Bones is honest about what's open. Each of these is written down, with a date, in SECURITY_REVIEW.md:
- Rate limits count per process. More than one backend instance needs a shared store.
- The public blog routes have no app-level rate limit. A deploy needs a CDN or firewall rule in front of them.
- An oversized upload is deleted after it lands, not stopped before.
- Nothing scans a deployed site yet, because nothing is deployed.
How it compares
| Option | Trade-off |
|---|---|
| A typical starter | Auth and a database, and the rest is up to you |
| A hosted backend (Supabase) | Solid defaults, but row-level security policies are yours to write and review |
| A commercial scanner (Snyk, GitHub Advanced Security) | Broad coverage, but paid, and it doesn't know your app's rules |
Bones writes its own rules down as code, so a scan fails on the mistakes this codebase can make.
Go deeper
- Semgrep and ZAP — why these two scanners.
- Security — running the checks and the review.