Documentation
Security
Auth model, secrets storage, TOTP, session handling, and responsible disclosure.
Security
Briter is a single-user application. Its security model is designed for one trusted operator, not for multi-tenant adversarial environments.
Authentication
Password — bcrypt-hashed with a work factor of 12. The plain-text password is never written to disk or logged.
Session — an HttpOnly, SameSite=Lax cookie named briter_session, marked Secure in production (NODE_ENV=production). The token is an HMAC-SHA256 signature over an issued-at timestamp, signed with a per-instance secret stored in storage/secrets.json. Sessions expire 7 days after issue (both the cookie maxAge and the token's own validity window); logging out clears the cookie.
TOTP — optional 6-digit TOTP (RFC 6238). When enabled, the login form shows a TOTP field after the password. The TOTP secret is stored in storage/secrets.json.
Rate limiting — login attempts are throttled at 8 failed attempts per 10-minute window (lib/auth/rate-limit.ts). The counter resets on successful login.
Secrets storage
Two files under storage/:
| File | Contents | Permissions |
|---|---|---|
config.json |
Site name, adapter settings, public config | 644 (readable) |
secrets.json |
bcrypt hash, TOTP secret, session secret | 600 (owner-only) |
secrets.json is created with 0600 permissions on POSIX systems. On non-POSIX environments (e.g. Docker on Windows), ensure the volume is not world-readable.
storage/ is gitignored. Never commit it.
Middleware and route protection
Next.js middleware runs at the edge and checks for a valid session cookie on all non-public routes. Public routes (reader, RSS, sitemap, health check) pass through without authentication.
The cookie check at the edge is a presence check only. Full session verification happens in each route handler via requireAdmin(). This is an intentional two-stage model: the edge check is fast (no I/O), the handler check is authoritative. See ADR 001 for why middleware.ts (not an instrumentation-based approach) gates auth.
CSRF protection
Every mutating admin request (POST/PUT/PATCH/DELETE) is protected by a same-origin check layered on top of the SameSite=Lax session cookie. The shared helper enforceSameOrigin(request) (lib/auth/csrf.ts):
- Lets safe methods (
GET/HEAD/OPTIONS) pass through untouched. - Reads the request
Originheader (falling back to theReferer's origin). - Compares it against the trusted origins — the request's own host (honoring
x-forwarded-host/x-forwarded-protofrom a reverse proxy) and the configuredBRITER_PUBLIC_BASE_URL. - Rejects mismatches — and unsafe requests that send no
Origin/Referer— with403 { "error": "Cross-origin request blocked" }.
requireAdminMutation(request) composes this with requireAdmin(), so a new mutating route opts into both session auth and CSRF protection in one line. The browser sets Origin automatically and scripts cannot forge it, which makes this a sound CSRF defense for a cookie-session app without a token lifecycle.
This is why deployments behind a reverse proxy must set BRITER_PUBLIC_BASE_URL to the public URL — it is a trusted origin for the check. The full rationale and rejected alternatives (double-submit token, SameSite=Strict alone) are in ADR 002: CSRF Posture.
Image uploads
- Magic-byte inspection rejects files that don't match supported image formats
- Filenames are sanitized and never passed to shell commands
- EXIF metadata is stripped unconditionally (including GPS)
- Uploaded files are served through a Next.js route handler, not directly from the filesystem
Content Security Policy
A restrictive Content Security Policy is set in next.config.ts (default-src 'self'). Scripts and styles are limited to the same origin ('unsafe-inline' is allowed; 'unsafe-eval' is permitted only in development for React's debugging features and is dropped in production). object-src is 'none', frame-ancestors is 'none', and base-uri/form-action are 'self'. Alongside it, next.config.ts sets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, a Referrer-Policy, a Permissions-Policy, and — in production — HSTS. Adjust the CSP in next.config.ts if your theme requires a CDN.
Responsible disclosure
Found a vulnerability? Email the maintainer at the address in package.json. Please do not open a public GitHub issue for security reports. Allow 90 days for a fix before public disclosure.
What Briter does not protect against
- Compromised server — if the server is compromised,
secrets.jsonis readable by the attacker. This is the same threat model as any self-hosted application. - Brute force over Tor/VPN — the rate limiter operates per-IP. Rotate-IP attackers can bypass it. Add a firewall rule or fail2ban at the infrastructure level.
- Multi-user isolation — Briter is single-user. Do not grant multiple people access to the admin surface.