Skip to content

Latest commit

 

History

History
13 lines (10 loc) · 3.79 KB

security.md

File metadata and controls

13 lines (10 loc) · 3.79 KB

Security

The browser only ever talks to the UI: server-rendered pages, Datastar actions that answer with HTML fragments over SSE, and the embedded static assets. Nothing it receives names the JSON API.

  • The JSON API is not for browsers. /api/auth/*, /api/core/* and /healthz answer any request that carries browser headers (Sec-Fetch-*, Origin, Referer, or Accept: text/html) with the same bare 404 as an unknown path, and never send CORS headers. A wrong method is a 404 too (no 405 + Allow). The mobile app (native fetch) and servers send none of these. The one exception: a top-level navigation to auth's two link endpoints when an emailed link's callback is a deep link or another origin (mobile); they only redirect, and an error they would answer as JSON is the same 404. Emailed links (verify, reset, invite) open UI pages directly, and opening one spends nothing: mail scanners and link previews fetch links, so each page asks for a click and only that POST spends the token (the page carries it only as a hidden form field, with no-store and, behind https, no-referrer). The mobile app's verification links use the same page, which then hands over to the app's deep link.
  • Browser interactions are server actions. Forms and Datastar actions post to UI routes (CSRF-protected by Go's cross-origin protection), and those call the JSON API in-process through apiclient.Client, so the UI is held to the API's access policies and validation without exposing the API to the browser. Lint keeps it that way: no config.APIRoutes or HTTP client in UI code, and scripts/check-ui-api-paths.sh fails on any /api/ path in UI templates, code or assets.
  • The admin app has no JSON API on the network. Its UI calls its API in-process, sign-in and 2FA included; /api/admin/v1 exists as code (apps/admin/internal/api, tested) but cmd/admin mounts none of it and no admin auth endpoint. To let scripts call it, pass its routers as server.Deps.API (browsers still get a 404).
  • Headers on every UI response: a nonce-based CSP (script-src 'self' 'nonce-…', style-src 'self', frame-ancestors 'none', no unsafe-*: Datastar compiles expressions into nonced scripts from <html data-nonce>), X-Content-Type-Options, X-Frame-Options, Referrer-Policy: strict-origin, Permissions-Policy, COOP/CORP, Cache-Control: no-store on pages and SSE, and HSTS behind https. Templates never use inline style= or on*= attributes (show/hide with class="hidden" + data-class:hidden). The CSP keeps out foreign scripts, styles, frames and form targets, but it is not the XSS defence: Datastar runs every data-* expression it finds with the page's nonce, so injected markup would run. templ's escaping is the defence; nothing may bypass it (templ.Raw, templ.Unsafe*, templ.SafeScript*, ...), and redirect targets from the query go through safeNext.
  • Errors are generic. Panics answer a plain 500 (the envelope on the API); unknown service errors show "Something went wrong" and are logged; validation messages name JSON types, never Go types.
  • Cookies are HttpOnly, SameSite=Lax and Secure behind https; the admin session uses the __Host- prefix (the web app keeps Better Auth's __Secure- names for the mobile client).
  • Secrets are secrets.Secret values from config to the client that uses them (see Secrets); logs are redacted.

Enforced by lint, not review: depguard keeps UIs from importing their JSON API or packages/secrets, and keeps packages/secrets/reveal (reveal.Value, the only way to read a secret: Secret has no method that returns it) in cmd (make lint-layering proves both bite, and that reading a secret around reveal.Value does not compile). Tests live only in the services, route handlers and packages/auth (AGENTS.md "Test boundaries").