Open Pickleball v1 is local-first: there is no server, no account, and no personal data leaves the device. That removes most of the classic web attack surface, but we still hold the app to an industrial-standard baseline so it stays safe as features grow.
Applied to every route, in dev and in production (including on Vercel):
| Header | Value / purpose |
|---|---|
Content-Security-Policy |
Locks sources to 'self'; object-src 'none', frame-ancestors 'none', frame-src 'none', child-src 'none', base-uri 'self', form-action 'self', connect-src 'self', upgrade-insecure-requests. |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload — force HTTPS. |
X-Frame-Options: DENY |
Anti-clickjacking (with frame-ancestors). |
X-Content-Type-Options: nosniff |
No MIME sniffing. |
Referrer-Policy |
strict-origin-when-cross-origin. |
Permissions-Policy |
Mic, geolocation, payment, USB, and sensors all disabled (=()). Camera is =(self) only — used locally by the QR scanner (Players → Import); frames are processed on-device and never leave the browser. |
Cross-Origin-Opener-Policy |
same-origin — isolates the browsing context. |
Cross-Origin-Resource-Policy |
same-origin — blocks cross-origin embedding of our resources. |
X-Permitted-Cross-Domain-Policies |
none. |
X-DNS-Prefetch-Control |
off. |
poweredByHeader: false |
Don't advertise the framework. |
images: { unoptimized: true } |
Disables the Next image optimizer entirely (the app uses no next/image), removing that attack surface. |
- All user text (player names, court names) is validated and sanitised with
Zod schemas in
src/lib/validation.tsbefore it enters state — length limits, whitespace collapsing, and ASCII control-character stripping. - Output is rendered through React, which escapes by default. The app uses
no
dangerouslySetInnerHTML, so stored-XSS via a player name is not possible. - Scores are clamped to integers
0–99; ties are rejected; team composition is validated (size, no duplicate/overlapping players). - Match officials (
umpire/recordedBy) are optional and stored only as roster player ids that are re-checked against the current roster before being saved (resolveOfficialinsrc/lib/store.ts) — an unknown id is dropped, so a record can never reference a non-player. - Profile photos are accepted only as small raster
data:image URLs (photoSchema) — never remote URLs — so a photo can't trigger a network request, and a size cap keepslocalStoragebounded. Images are compressed on-device (src/lib/image.ts). - Imported share codes are untrusted input.
decodeProfile()insrc/lib/share.tsvalidates the magic/version, clamps numbers, bounds list lengths, trims the name through the same rules, drops any oversized/invalid photo, and length-caps the umpire/scorer names carried on each result before the data reaches the store. Sharing is peer-to-peer (QR / code / file) — there is still no network call. - The downloadable player card (
src/lib/profileCard.ts) is built by string concatenation, so every interpolated value — names, opponents, officials — is HTML-escaped, and the document contains no<script>. It's a self-contained file with inline data-URL images only; opening it makes no network requests.
- Mutations go through guarded actions that return typed results
(
COURT_BUSY,PLAYER_IN_MATCH,NEED_PLAYERS, …) instead of throwing, so a bad action can never corrupt the store. - The persisted store is versioned (
open-pickleball:v1) so future schema changes can migrate cleanly.
- Small, well-known dependency set. No secrets in the client bundle (there are none to leak in v1).
npm auditis clean — 0 known vulnerabilities.- The framework was upgraded to Next.js 15 (
15.5.x) + React 19, which clears the Next.js advisories that affected the 14.x line. Apostcssoverride (^8.5.10) pins the build-time transitive dependency above its advisory range. - Recommended CI gate:
npm auditplus the typecheck / lint / build pipeline. Re-runnpm auditon every dependency change.
If you ever add a hosted Postgres backend (see ARCHITECTURE.md), follow this
security model:
- Enable Row Level Security (RLS) on every table before any public client connects. Public reads limited to safe fields; writes restricted to the owner/participant.
- Keep high-impact actions server-side.
join_match,cancel_match,confirm_playerrun as transaction-safe RPCs / Edge Functions that lock the match row and re-check capacity — never trust the client for capacity. - Never expose the service-role key. Server-only. Never prefix a secret
with
NEXT_PUBLIC_. Use Vercel/CI encrypted env vars..env*is gitignored. - Reuse the Zod schemas server-side so validation runs on both ends.
- Tighten CSP with per-request nonces (drop
'unsafe-inline'for scripts) and add aconnect-srcentry for your backend origin. - Auth: email/password first; rate-limit auth endpoints; hash with the provider's defaults; enforce host-only permissions in policies, not just UI.
- Privacy: make profile/match visibility explicit; log backend failures only, not personal activity.
This is an open-source educational project. If you find a security issue, please open a private report / security advisory on the repository rather than a public issue, and avoid sharing exploit details publicly until it's addressed.