How to protect a browser app

From docs/how-to/secure-browser-app.md in the framework repository.

A browser app needs sessions, CSRF protection, and per-client rate limits. Session cookies are Secure, HttpOnly and SameSite=Lax by default.

ts
// samveys.config.ts
import { defineConfig } from '@samveys/core'
import { http } from '@samveys/http'
import { database, databaseFromEnv } from '@samveys/database'
import { cache, defineCacheConfig, databaseStore } from '@samveys/cache'
import { session } from '@samveys/session'
import { security } from '@samveys/security'

export default defineConfig({
  plugins: [
    http({ trustProxy: ['10.0.0.9'] }),               // the load balancer's address, if any
    database({ default: 'main', connections: { main: databaseFromEnv() } }),
    cache(defineCacheConfig({ default: 'db', stores: { db: databaseStore({ database: () => app.container.make('db') }) } })),
    session({ store: 'database' }),
    security({
      csrf: { secret: process.env.CSRF_SECRET },      // set it, or form tokens break on restart
      cors: { origins: ['https://app.example.com'], credentials: true },
      rateLimit: { limit: 60, windowMs: 60_000 },
    }),
  ],
})

Then put the middleware on the routes that need it:

ts
router.group({ use: ['session', 'csrf'] }, (web) => {
  web.get('/settings', ({ session }) => ({ theme: session.get('theme') }))
  web.post('/settings', { use: ['rateLimit'] }, ({ session, body }) => session.put('theme', body.theme))
})
  • CSRF.
    • The check uses Fetch Metadata first (Sec-Fetch-Site), with Origin as the fallback, and adds Vary: Sec-Fetch-Site, Origin to every response.
    • A cross-site POST gets 403 SVS_E091.
    • HTML forms include csrfToken(ctx) as _csrf.
    • To roll the check out safely, start with csrf: { mode: 'log' }: it logs what would be rejected without rejecting anything.
  • CORS.
    • Only listed origins get CORS headers.
    • credentials together with '*' is refused at boot.
    • Preflights are answered before routing.
  • Rate limits.
    • Each client gets its own bucket by ctx.clientIp, the socket peer. That is the first X-Forwarded-For hop only when trustProxy trusts the peer, so clients cannot choose their bucket.
    • Over the limit, the response is 429 SVS_E092 with Retry-After.
    • Use the database cache store so every replica shares the count.
  • Sign-in. Call regenerate() on login so the session id changes. That stops session fixation. The session guard of @samveys/auth does it for you.